diff --git a/css/main.css b/css/main.css
index eec9b87..5014259 100644
--- a/css/main.css
+++ b/css/main.css
@@ -3630,3 +3630,110 @@ body:has(.presence-stage) {
#lc-embed { left: 0.5rem !important; bottom: 0.5rem !important; }
#lc-embed > section > div { min-width: 0 !important; }
}
+
+/* =====================================================================
+ * fronting.js — "currently fronting" box (sits below the terminal)
+ * ===================================================================== */
+.fronting-card {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ margin: 0.9rem auto 0;
+ padding: 0.85rem 1rem;
+ background: var(--mantle);
+ border: 1px solid var(--surface-1);
+ border-radius: 14px;
+ box-shadow: 0 18px 50px -22px rgba(0, 0, 0, 0.6);
+}
+
+.fronting-card[hidden] { display: none; }
+
+.fr-head {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin-bottom: 0.7rem;
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--subtext-0);
+}
+
+.fr-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--green);
+ box-shadow: 0 0 0 0 rgba(166, 227, 161, 0.55);
+ animation: fr-pulse 2.4s ease-out infinite;
+}
+
+@keyframes fr-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(166, 227, 161, 0.5); }
+ 70% { box-shadow: 0 0 0 7px rgba(166, 227, 161, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(166, 227, 161, 0); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .fr-dot { animation: none; }
+}
+
+.fr-members {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
+.fr-member {
+ --fr-accent: var(--pink);
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0.5rem 0.75rem 0.5rem 0.5rem;
+ border-radius: 12px;
+ background: var(--surface-0);
+ border: 1px solid var(--surface-1);
+ border-left: 3px solid var(--fr-accent);
+ color: var(--text);
+}
+
+.fr-av {
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex: 0 0 auto;
+ border: 2px solid var(--fr-accent);
+ background: var(--crust);
+}
+
+.fr-av--empty { display: inline-block; }
+
+.fr-meta {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ line-height: 1.25;
+}
+
+.fr-name {
+ font-weight: 600;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fr-pronouns {
+ font-size: 0.74rem;
+ color: var(--subtext-0);
+}
+
+.fr-empty {
+ font-size: 0.82rem;
+ color: var(--subtext-0);
+ font-style: italic;
+}
diff --git a/index.html b/index.html
index 9d92864..0a63463 100644
--- a/index.html
+++ b/index.html
@@ -60,6 +60,7 @@
(fae/faer)
+
@@ -69,6 +70,7 @@
+
diff --git a/js/fronting.js b/js/fronting.js
new file mode 100644
index 0000000..f574615
--- /dev/null
+++ b/js/fronting.js
@@ -0,0 +1,95 @@
+/* =====================================================================
+ * fronting.js — homepage "who's fronting" box.
+ *
+ * Polls the system's PluralKit-style API for the current fronter(s) and
+ * renders a small card. Each member links to their page on the system
+ * site. Refreshes every 30s so switches show without a reload.
+ * ===================================================================== */
+(function fronting() {
+ "use strict";
+
+ const mount = document.getElementById("fronting");
+ if (!mount) return;
+
+ const API = "https://doughmination.co.uk/api/fronters";
+ const POLL_MS = 30000;
+
+ function esc(s) {
+ return String(s == null ? "" : s)
+ .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ }
+ // member.color is a 6-char hex string (no leading #), may be null.
+ function colorHex(c) {
+ return /^[0-9a-fA-F]{6}$/.test(c || "") ? "#" + c : null;
+ }
+
+ // ---- build the shell ----------------------------------------------------
+ const card = document.createElement("section");
+ card.id = "fronting";
+ card.className = "fronting-card";
+ card.hidden = true;
+ card.setAttribute("aria-label", "Currently fronting");
+ card.innerHTML =
+ '' +
+ '' +
+ 'Currently fronting' +
+ '
' +
+ '';
+ mount.replaceWith(card);
+
+ const membersEl = card.querySelector(".fr-members");
+
+ function memberHtml(m) {
+ const name = m.display_name || m.name || "Unknown";
+ const accent = colorHex(m.color);
+ const av = m.avatar_url
+ ? '
'
+ : '';
+ const pronouns = m.pronouns
+ ? '' + esc(m.pronouns) + '' : "";
+ return '' +
+ av +
+ '' +
+ '' + esc(name) + '' +
+ pronouns +
+ '' +
+ '
';
+ }
+
+ function render(members) {
+ if (!Array.isArray(members) || !members.length) {
+ // No one registered as fronting — keep the box but say so.
+ membersEl.innerHTML = 'no one is currently fronting';
+ card.hidden = false;
+ return;
+ }
+ membersEl.innerHTML = members.map(memberHtml).join("");
+ card.hidden = false;
+ }
+
+ let failed = false;
+ function load() {
+ fetch(API, { headers: { Accept: "application/json" } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (j) {
+ if (!j) throw new Error("bad response");
+ render(j.members || []);
+ failed = false;
+ })
+ .catch(function () {
+ // On first failure hide the box quietly; if it was already showing,
+ // leave the last-known fronters up rather than flashing an error.
+ if (!failed && card.hidden) card.hidden = true;
+ failed = true;
+ });
+ }
+
+ load();
+ const timer = setInterval(load, POLL_MS);
+ // Refresh immediately when the tab becomes visible again.
+ document.addEventListener("visibilitychange", function () {
+ if (!document.hidden) load();
+ });
+ window.addEventListener("beforeunload", function () { clearInterval(timer); });
+})();