From fa7facd1860059eb2b648522f3a1e78d8589ab7d Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Fri, 24 Jul 2026 11:55:18 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20adopts=20the=20dashboard's=20se?= =?UTF-8?q?gmented=20tabs=20=E2=80=94=20five=20views,=20no=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the dashboard's sticky pilled segmented control (ADR-0005) into ak x admin, keeping admin's amber editorial identity. The nine stacked sections become five tabs — Overview (reach + momentum), Review (since you last looked + waiting on you), Humans, Activity (feed + referrers), Gaps — each fitting a viewport with no page scroll; long lists (feed, todo) scroll inside their own card. Attention never hides behind a tab: Review/Humans/Activity carry count badges (red = open items waiting on you, amber = new since baseline). Arrow-key roving focus, #tab deep links (safe beside the #token= bootstrap, which strips first), and the last tab remembered in localStorage. --- src/lib/admin-server.mjs | 169 ++++++++++++++++++++++++++------------- src/lib/admin-view.mjs | 70 ++++++++++++++++ 2 files changed, 182 insertions(+), 57 deletions(-) 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").

-
+ -
-

Where they arrived from rolling 14 days

-
-

+ -
-

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();