diff --git a/src/lib/admin-server.mjs b/src/lib/admin-server.mjs
index fed14e6a..b5f43d9d 100644
--- a/src/lib/admin-server.mjs
+++ b/src/lib/admin-server.mjs
@@ -153,7 +153,13 @@ export function startAdmin({ port = 7432, collect, resolveToken, pkg: injectedPk
// ── the page. One document, everything inline. Only first-party source is
// interpolated: `${CSS}` (the style), `${modelSrc}` + `${viewSrc}` (the one
-// module scope). The client fetches /api/admin-stats and renders live. ─────────
+// module scope). The client fetches /api/admin-stats and renders live.
+//
+// Layout: the dashboard's sticky segmented control (ADR-0005) splits the panel
+// into five views — Overview (reach + momentum), Review (since you last looked +
+// waiting on you), Humans, Activity (feed + referrers), Gaps — so each view fits
+// a viewport instead of one long scroll. Attention never hides behind a tab:
+// Review/Humans/Activity carry count badges when something needs eyes. ─────────
function renderPage({ modelSrc, viewSrc }) {
return `
@@ -171,9 +177,9 @@ function renderPage({ modelSrc, viewSrc }) {
agentic-kit — admin
-
What moved since you last looked, who moved it, and what is waiting on you. Every number is read live from GitHub or npm — nothing here is stored, assumed, or estimated. Maintainer-only, localhost-only, deliberate egress.
+
What moved since you last looked, who moved it, and what is waiting on you. Every number is read live from GitHub or npm — nothing here is stored, assumed, or estimated.
Maintainer dashboard. Paste the one-time session token printed in your terminal (it also rode in on the launch URL's # fragment).
@@ -189,58 +195,79 @@ function renderPage({ modelSrc, viewSrc }) {
-
-
How many people
-
The closest honest answer to "how many people use this". Each tile says what it counts and what it cannot — three of the four are machine-side, so the tile that means humans is the first.
-
-
-
-
-
-
Momentum last 7 days vs the 7 before — direction, not totals
-
Computed inside each source's own daily series, so both halves cover an equal window. These are machine counters; read them for shape and the humans below for truth.
-
-
-
-
-
-
Since you last looked
-
-
-
-
-
Waiting on you
-
Open issues and PRs opened by someone other than you, oldest first. Closing the loop is the work.
-
-
-
-
-
The humans
-
Everyone outside you who filed an issue, opened a PR, or forked — ranked by how recently they showed up, not alphabetically.
-
+
+
+
+
+
How many people
+
The closest honest answer to "how many people use this". Each tile says what it counts and what it cannot — three of the four are machine-side, so the tile that means humans is the first.
+
+
+
+
+
+
Momentum last 7 days vs the 7 before — direction, not totals
+
Computed inside each source's own daily series, so both halves cover an equal window. These are machine counters; read them for shape and the humans in their tab for truth.
+
+
+
-
-
Every human event
-
The one place the conversation lives. Titles link straight into the thread; this page deliberately does not paraphrase the sentiment (see "Not instrumented yet").
-
+
+
+
Since you last looked
+
+
+
+
+
Waiting on you
+
Open issues and PRs opened by someone other than you, oldest first. Closing the loop is the work.
+
+
-
-
Where they arrived from rolling 14 days
-
-
+
+
+
The humans
+
Everyone outside you who filed an issue, opened a PR, or forked — ranked by how recently they showed up, not alphabetically.
+
+
-
-
Not instrumented yet
-
Things this page genuinely cannot see — listed rather than estimated. A dashboard that fills a gap with a plausible number is worse than one that admits it.
-
+
+
+
Every human event
+
The one place the conversation lives. Titles link straight into the thread; this page deliberately does not paraphrase the sentiment (see the Gaps tab).
+
+
+
+
+
Where they arrived from rolling 14 days
+
+
+
-
-
Doors
-
+
+
+
Not instrumented yet
+
Things this page genuinely cannot see — listed rather than estimated. A dashboard that fills a gap with a plausible number is worse than one that admits it.
+
+
+
+
+
Doors
+
+
@@ -272,12 +299,11 @@ const CSS = `
html,body{margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums;min-height:100vh;overflow-x:hidden}
.mono{font-family:var(--mono)}
-.wrap{max-width:1120px;margin:0 auto;padding:44px clamp(16px,4vw,28px) 90px}
-.head{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:8px}
-.head h1{font-family:var(--display);font-weight:600;font-size:1.9rem;letter-spacing:-.012em;color:var(--ink);margin:0}
+.wrap{max-width:1120px;margin:0 auto;padding:26px clamp(16px,4vw,28px) 44px}
+.head{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:16px}
+.head h1{font-family:var(--display);font-weight:600;font-size:1.6rem;letter-spacing:-.012em;color:var(--ink);margin:0}
.head::after{content:"";flex-basis:100%;height:2px;background:var(--spectrum);opacity:.9;border-radius:2px;margin-top:6px}
.stamp{font-size:12px;color:var(--faint)}
-.sub{color:var(--muted);font-size:14px;margin:14px 0 26px;max-width:80ch}
.gate{max-width:460px;background:var(--surface);border:1px solid var(--ridge);border-radius:12px;padding:26px 28px}
.gate p{font-size:14px;color:var(--ink-2);margin:0 0 14px}
@@ -289,18 +315,46 @@ button{cursor:pointer;background:var(--surface);color:var(--ink);border:1px soli
button:hover{border-color:var(--accent)}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
button.primary{background:var(--accent);color:var(--on-accent);border:0;border-radius:10px;font-weight:700;font-size:14px;padding:10px 18px;font-family:var(--sans)}
-.controls{display:flex;align-items:center;gap:12px;margin-bottom:24px;font-family:var(--mono);font-size:12.5px;color:var(--muted);flex-wrap:wrap}
+.controls{display:flex;align-items:center;gap:12px;margin-bottom:12px;font-family:var(--mono);font-size:12.5px;color:var(--muted);flex-wrap:wrap}
.controls label{display:flex;align-items:center;gap:6px;cursor:pointer}
.controls .undo{border-color:color-mix(in srgb,var(--accent) 50%,transparent);color:var(--accent)}
.controls .right{margin-left:auto}
-.sec{margin-top:38px}
-.sec.first{margin-top:8px}
+/* ── sticky segmented control (the dashboard's tab idiom, amber-keyed) ── */
+.tabbar{position:sticky;top:0;z-index:20;display:flex;padding:10px 0 12px;margin-bottom:10px;
+ background:color-mix(in srgb,var(--bg) 80%,transparent);
+ -webkit-backdrop-filter:saturate(160%) blur(16px);backdrop-filter:saturate(160%) blur(16px);
+ border-bottom:1px solid var(--ridge)}
+.seg{position:relative;display:inline-flex;gap:2px;padding:3px;border-radius:999px;
+ background:var(--surface);border:1px solid var(--ridge);
+ max-width:100%;overflow-x:auto;scrollbar-width:none}
+.seg::-webkit-scrollbar{display:none}
+.seg-btn{position:relative;z-index:1;border:0;background:transparent;color:var(--muted);
+ font-family:var(--mono);font-size:12.5px;letter-spacing:.01em;padding:6px 15px;border-radius:999px;
+ cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:7px;transition:color .2s ease}
+.seg-btn[aria-selected="true"]{color:var(--ink)}
+.seg-btn:hover{color:var(--ink-2)}
+.seg-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
+.seg-thumb{position:absolute;top:3px;left:3px;height:calc(100% - 6px);width:0;border-radius:999px;
+ background:var(--bg-2);border:1px solid color-mix(in srgb,var(--accent) 55%,var(--ridge));
+ box-shadow:0 1px 6px rgba(0,0,0,.45);
+ transition:left .25s cubic-bezier(.3,.7,.3,1),width .25s cubic-bezier(.3,.7,.3,1)}
+.tbadge{min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:var(--bad);color:#fff;
+ font-size:10px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;line-height:1}
+.tbadge[data-tone="warn"]{background:var(--accent);color:var(--on-accent)}
+.tbadge[hidden]{display:none}
+
+.panel{animation:fade .25s ease}
+.panel[hidden]{display:none}
+@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
+
+.sec{margin-top:26px}
+.sec.first{margin-top:6px}
.sec h2{font-family:var(--display);font-weight:600;font-size:1.25rem;color:var(--ink);margin:0 0 4px}
.sec h2 .qual{font-size:11px;color:var(--faint);font-weight:400;letter-spacing:.03em}
.sec .lead{font-size:13px;color:var(--muted);margin:0 0 12px;max-width:80ch}
.sec .note{font-size:12.5px;color:var(--faint);margin:12px 0 0;max-width:80ch;line-height:1.55}
-.sec .lead em,.sub em{color:var(--ink-2);font-style:italic}
+.sec .lead em,.gate p em{color:var(--ink-2);font-style:italic}
.reach{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:12px}
.rcell{border:1px solid var(--ridge);border-radius:12px;padding:16px 18px;background:var(--surface);min-width:0}
@@ -337,7 +391,8 @@ button.primary{background:var(--accent);color:var(--on-accent);border:0;border-r
.dcell span{display:block;margin-top:5px;font-family:var(--mono);font-size:10.5px;letter-spacing:.03em;color:var(--muted);line-height:1.4}
.dcell em{display:block;margin-top:4px;font-style:normal;font-family:var(--mono);font-size:10px;color:var(--faint)}
-.todo,.tl{border:1px solid var(--ridge);border-radius:12px;background:var(--surface);overflow:hidden;margin-top:12px}
+/* long lists scroll inside their card, never the page (the no-scroll contract) */
+.todo,.tl{border:1px solid var(--ridge);border-radius:12px;background:var(--surface);overflow:hidden;margin-top:12px;max-height:52vh;overflow-y:auto}
.todo-row{display:flex;gap:14px;align-items:baseline;padding:13px 16px;border-bottom:1px solid var(--ridge)}
.todo-row:last-child{border-bottom:none}
.todo-row .age{font-family:var(--mono);font-size:11.5px;color:var(--bad);white-space:nowrap;min-width:74px}
@@ -382,7 +437,7 @@ table.adm tr:last-child td{border-bottom:none}
table.adm td.num{font-family:var(--mono)}
.gaps{border:1px dashed var(--ridge);border-radius:12px;background:var(--bg-2);padding:6px 20px;margin-top:12px}
-.gaps li{list-style:none;padding:13px 0;border-bottom:1px solid var(--ridge);font-size:13.5px;color:var(--ink-2);line-height:1.55}
+.gaps li{list-style:none;padding:10px 0;border-bottom:1px solid var(--ridge);font-size:13.5px;color:var(--ink-2);line-height:1.55}
.gaps li:last-child{border-bottom:none}
.gaps li b{color:var(--ink);font-weight:500}
.gaps li .fix{display:block;margin-top:4px;font-family:var(--mono);font-size:11.5px;color:var(--faint)}
@@ -393,7 +448,7 @@ table.adm td.num{font-family:var(--mono)}
.doors{font-size:14px;color:var(--ink-2);background:var(--surface);border:1px solid var(--ridge);border-radius:12px;padding:16px 20px;margin-top:12px}
.doors a{color:var(--accent-2)}
-.foot{margin-top:40px;padding-top:16px;border-top:1px solid var(--ridge);color:var(--faint);font-size:11.5px}
+.foot{margin-top:26px;padding-top:14px;border-top:1px solid var(--ridge);color:var(--faint);font-size:11.5px}
@media (max-width:880px){
.dstrip,.mom,.reach{grid-template-columns:1fr 1fr}
diff --git a/src/lib/admin-view.mjs b/src/lib/admin-view.mjs
index bc6dbb92..69260b19 100644
--- a/src/lib/admin-view.mjs
+++ b/src/lib/admin-view.mjs
@@ -15,6 +15,8 @@ import { esc, safeUrl, metric, ascend, momentum, daysAgo, agoLabel, shape, snaps
const TOKEN_STORE = 'ak-admin-token';
const BASE_STORE = 'ak-admin-baseline';
const BASE_PREV = 'ak-admin-baseline-prev';
+const TAB_STORE = 'ak-admin-tab';
+const TABS = ['overview', 'review', 'humans', 'activity', 'gaps'];
const FEED_STEP = 25;
const $ = (s) => document.querySelector(s);
@@ -23,6 +25,7 @@ const num = (v) => Number(v).toLocaleString();
let LAST = null;
let feedShown = FEED_STEP;
let autoTimer = null;
+let activeTab = 'overview';
// ── token bootstrap (FR-3): fragment → localStorage, then strip ────────────────
// The fragment is never sent to the server, never logged, never proxied. We lift
@@ -46,6 +49,44 @@ function readBaseline() {
try { return JSON.parse(localStorage.getItem(BASE_STORE) || 'null'); } catch { return null; }
}
+// ── tabs: the dashboard's segmented-control idiom (ADR-0005), amber-keyed ─────
+// Deep-link (#review) wins over the stored tab; the token fragment never
+// collides because bootToken() strips it before this runs.
+function positionThumb() {
+ const seg = $('[data-seg]');
+ const thumb = $('[data-seg-thumb]');
+ if (!seg || !thumb) return;
+ const btn = seg.querySelector('[data-tab="' + activeTab + '"]');
+ if (!btn) return;
+ thumb.style.left = btn.offsetLeft + 'px';
+ thumb.style.width = btn.offsetWidth + 'px';
+}
+
+function setTab(id, focus) {
+ if (TABS.indexOf(id) === -1) id = 'overview';
+ activeTab = id;
+ try { localStorage.setItem(TAB_STORE, id); } catch { /* private mode */ }
+ try { if (history.replaceState) history.replaceState(null, '', '#' + id); } catch { /* older browsers */ }
+ TABS.forEach((t) => {
+ const on = t === id;
+ const btn = document.querySelector('[data-tab="' + t + '"]');
+ const panel = document.querySelector('[data-panel="' + t + '"]');
+ if (btn) { btn.setAttribute('aria-selected', on ? 'true' : 'false'); btn.tabIndex = on ? 0 : -1; if (on && focus) btn.focus(); }
+ if (panel) panel.hidden = !on;
+ });
+ positionThumb();
+}
+
+// Attention never hides behind a tab: a count badge on the tab itself.
+function setBadge(tab, n, tone) {
+ const el = $('[data-badge="' + tab + '"]');
+ if (!el) return;
+ if (!n) { el.hidden = true; el.textContent = ''; el.removeAttribute('data-tone'); return; }
+ el.hidden = false;
+ el.textContent = String(n);
+ el.setAttribute('data-tone', tone || 'warn');
+}
+
// ── tiny presentation helpers (SVG sparkline, delta cell) ─────────────────────
function sparkline(series, stroke) {
if (!Array.isArray(series) || series.length < 2) return '';
@@ -302,6 +343,12 @@ function render(d) {
renderReferrers(d);
renderGaps(d);
renderDoors(d);
+ // Tab badges: open items are red (waiting on YOU); new-since-baseline is amber.
+ const newPeople = base ? s.people.filter((p) => base.people.indexOf(p.login) === -1).length : 0;
+ setBadge('review', s.openItems.length || (since.newIds.length + newPeople), s.openItems.length ? 'fail' : 'warn');
+ setBadge('humans', newPeople, 'warn');
+ setBadge('activity', since.newIds.length, 'warn');
+ positionThumb(); // first render unhides the dash; badges change segment widths
// First visit ONLY: seed the baseline so the NEXT visit has an honest diff.
// Never auto-advanced — that would consume the very deltas this page exists for.
if (!base) { try { localStorage.setItem(BASE_STORE, JSON.stringify(snapshot(d, s))); } catch { /* private mode */ } }
@@ -368,8 +415,31 @@ function wire() {
if (el.hasAttribute('data-feed-more')) { feedShown += FEED_STEP; if (LAST) render(LAST); }
if (el.hasAttribute('data-feed-less')) { feedShown = FEED_STEP; if (LAST) render(LAST); }
});
+
+ // Segmented control: click, arrow-key roving focus, resize re-measure.
+ const seg = $('[data-seg]');
+ if (seg) {
+ seg.addEventListener('click', (e) => {
+ const b = e.target.closest ? e.target.closest('[data-tab]') : null;
+ if (b) setTab(b.getAttribute('data-tab'));
+ });
+ seg.addEventListener('keydown', (e) => {
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
+ let i = TABS.indexOf(activeTab);
+ i = (i + (e.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length;
+ setTab(TABS[i], true);
+ e.preventDefault();
+ });
+ }
+ window.addEventListener('resize', positionThumb);
}
wire();
const boot = bootToken();
+// Initial tab: stored choice, then a #deep-link (bootToken already stripped any
+// #token=… fragment, so whatever hash survives here is a tab name or noise).
+try { const st = localStorage.getItem(TAB_STORE); if (st && TABS.indexOf(st) >= 0) activeTab = st; } catch { /* private mode */ }
+const deepLink = String(location.hash || '').slice(1);
+if (TABS.indexOf(deepLink) >= 0) activeTab = deepLink;
+setTab(activeTab);
if (boot) load(boot); else showGate();