The admin Dashboard redesign โ
- Status: BUILT and live-verified on her running stack, 2026-08-27. Reversible: the shape was an executive decision, not her word.
- Replaces: the eleven-card grid at
/admin/dashboard, survey items A5 and A6. - Her complaint, verbatim: "Having cards to links doesn't do anyone anygood." What she asked for instead: links to releases, news, what we are, and similar.
- Code:
apps/admin/src/admin/dashboard/. - Screens:
screens/dashboard-redesign-1440.png(the page),screens/dashboard-redesign-1100-stacked.png(the rail stacked under the reading column). - Follow-up:
dashboard-rail-test-mock-gap.md. Putting the rail on the shell's index route brokenpm test -w apps/adminwith nine unhandled rejections while every test still passed. Fixed 2026-08-27.
PM DECISION: Shape 3, the split reading page โ
Decided 2026-08-27 under her autonomy grant. This was an agent's call, not hers, and it is hers to overturn on sight.
The survey drew three shapes and this picks the third. โ
- Shape 1, the situation board: live counts only. Answers "does anything need me", says nothing about releases or what we are.
- Shape 2, the changelog front page: releases and orientation only. Closest to her literal words, but nothing operational.
- Shape 3, the split: a narrow rail of live counts beside a dominant reading column. Carries both.
Why 3: her own words name both halves, at different times. โ
- She asked for releases and news, which is the reading column.
- Later, in the Alerts conversation on the System Health work, she said alerts "could potentially be on the dashboard as well". Her alerts are threshold and health state, which is the rail.
- Picking 1 or 2 answers half of what she has said she wants.
The survey named the risk, so the build was designed against it. โ
- The risk: "a split page can end up doing neither job well, which is close to what the current card grid already is."
- Mitigation, and it is structural rather than a preference: the rail is a FIXED 240px track and the reading column takes the rest. Measured live at a 1440px window: content 1034px, reading column 762px, rail 240px. The rail is 23% of the page and cannot grow into a second content column.
- This is a reading page with a status rail beside it, not two columns of equal weight.
What is hers to overturn, and what it would cost. โ
| If she rules | What changes |
|---|---|
| Shape 1 instead | Delete the reading column, keep StatusRail and useDashboardRail, widen the rail. The rail is already the whole of shape 1. |
| Shape 2 instead | Delete StatusRail and useDashboardRail, let the reading column take the full width. |
| Rail on the left | One line in DashboardHome.css: swap the grid track order. Put on the right so the page does not read as two rails against the nav sidebar. |
What is on the page? โ
The reading column, dominant, on the left. โ
- "What we are": three sentences of hand-written copy saying what Lantern is, then the launch plan's stage row, then four links out to the docs site.
- The stages are PARSED from
docs/business/launches/README.md, not restated. A restated target date is a date that goes stale silently. Rendered live: Prototype (Now, continuous, marked "We are here"), Alpha (December 2026), Beta (Summer 2027), 1.0 (Unscheduled). - Links: About Lantern, Launch plan, Business docs, Docs home. All four open the VitePress docs site.
- The stages are PARSED from
- "Releases": the six most recent release days out of
docs/changelogs/dev/, each entry showing its kind (Added, Fixed, Security), its title, its body and its pull request link. The day heading links to the full entry on the docs site.
The status rail, narrow, on the right. โ
Seven rows. Every count is one the portal ALREADY derives somewhere else, fetched through the same client the owning screen uses. Nothing new is computed and no endpoint was added.
| Row | Where the number comes from | Links to |
|---|---|---|
| Lanterns lit now | getRealtimeActivity().activeLanterns, analytics-api, same call the Venue Activity dashboard makes | /admin/analytics/dashboards |
| Waves pending | getRealtimeActivity().pendingWaves | /admin/analytics/dashboards |
| Venues | getSystemHealth().firebase.content.venues, analytics-api, same call System Health makes | /admin/venues |
| Users | getSystemHealth().firebase.users.total | /admin/users |
| New signups, 24h | getSystemHealth().firebase.users.recentSignups24h | /admin/users |
| Merchant applications | listMerchantApplications({ status: 'pending' }), auth-api, same call the Applications screen makes | /admin/merchants/applications |
| Offers to review | listOffersForReview({ status: 'pending_review' }), merchants-api, same call the Offer review queue makes | /admin/merchants/offer-review |
- The four calls are settled independently (
Promise.allSettled). One failing service greys its own rows and leaves the rest live. - The rail is built to take an alert row later. A row is a label, a value and a destination, nothing more. No alerting was built: there is no alerting backend, and that is separate work.
Deliberately NOT on the rail: open moderation cases. โ
- It is the most action-shaped count in the portal and it belongs here eventually.
subscribeToCasesis two unbounded Firestore listeners (reportsandmoderationCases, no limit) plus a name resolution per case. Attaching that to the landing page to render one integer is a defect worth avoiding.- What it needs first: a count endpoint, the same shape the other four rail sources already have.
How was the not-optional list satisfied? โ
The survey's section "What is common to all three, and is not optional", each item with the evidence.
1. The "Available" badge goes. โ
- Every one of the eleven cards carried a green
Availablebadge and every one said Available. - Gone with the cards. Live count on the rendered page:
0elements whose entire text is "Available",0elements carrying.badge,0carrying.feature-card. - A regression test pins it:
StatusRail.test.jsx, "carries no status badge of its own".
2. The one-card-per-sidebar-entry mapping goes. โ
- This is the substance of her complaint, and any shape that keeps it has not answered her.
- Nothing on the page is an element whose only content is a nav destination. The rail's links are counts that earned their place: a zero means do not click and a five means click now. The reading column's links leave for the docs site rather than pointing back into the nav.
- The one card without a sidebar twin was "Invite User", and nothing was lost by dropping it: Invite User is a launcher on the Users page itself (
UserManagement.jsx:221), reachable without the Dashboard.
3. Survey item A4, a zeroed dashboard reading as real data, is not reproduced. โ
- A4 is the Ad Delivery defect: four cards reading
0are indistinguishable from a broken page. - The rail keeps three states apart. Loading shows a placeholder. A real zero renders
0, muted but present, because it is data. An absent count renders "Not available" and never borrows a zero's rendering. - This was exercised live, not just written. On the local stack
listOffersForReviewreturns the SPA shell instead of JSON (see the finding below), and the rail said "Offers to review: Not available" beside a genuine "Merchant applications: 0". Two different states, two different renderings, in the same rail. - Pinned by three tests in
useDashboardRail.test.jsand two inStatusRail.test.jsx.
Where does the reading column's content come from? โ
The repo, at build time. No content system was invented. โ
import.meta.glob('.../docs/changelogs/dev/*.md', { query: '?raw' }), lazy on purpose. There are 66 files and about 380KB of them; eager-globbing would put the whole archive in the dashboard bundle to render six entries. Lazy makes each file its own chunk and only the rendered ones are fetched.- The launch plan is one small file, so it is eager.
- Why not the docs API: it exists and serves markdown, but it reads GitHub per request, needs an authenticated admin plus a server-side GitHub token, and would cost one round trip per release day behind a spinner. The build-time read is instant, works offline, and refreshes on every deploy, which is exactly the cadence a release feed wants.
The parser is small and its shape is pinned by tests. โ
- Files are
dev-MM.DD.YYYY-v{version}.md; the date is the sort key. - Entries are
- **Title**: Body ([#N](url)) \label`. A day can carry several## [Unreleased]/## Update` blocks; they are flattened by kind, because the day is the unit a reader cares about and the block boundaries are an artifact of when the changelog step ran. - The GitHub triage labels are parsed but deliberately not rendered: they are routing metadata for the label workflow, and on a reading page they would be chrome with nothing to say.
What did the build turn up that the survey did not? โ
system-health is a per-vendor envelope, and reading the wrong level renders every count as "Not available" against a live 200. โ
- The payload is
{ timestamp, firebase: { status, users, content, activity }, monitoring, cloudflare, ... }. The counts live underfirebase, which answersstatus: 'error'when its own reads fail. - The first live run read the top level and produced three "Not available" rows against a perfectly healthy 200 response. Caught only by driving it.
- Now pinned by
useDashboardRail.test.js, "reads the counts out of the system-health firebase section".
FINDING, not fixed: /api/admin/offers has no local dev proxy, so the Offer review queue silently reads empty on localhost:3001. โ
VITE_MERCHANTS_API_URL=/api, solistOffersForReviewcalls/api/admin/offers.apps/admin/vite.config.mjsproxies/api/merchantsand/api/offerstoMERCHANTS_API_ORIGINbut not/api/admin, so the request falls through to the SPA and comes back as200 text/html.- The screen shows no error.
OfferReviewQueuedoesresult.offers || [], so HTML becomes an empty queue that reads as "no offers awaiting review". - This may be why the survey recorded the queue as empty. What could not be seen says "No offers awaiting review" on the live stack and reads the populated layout from source. That is the exact symptom of this proxy gap.
- Deliberately NOT fixed here. The fix is one proxy entry, but editing
vite.config.mjsmakes Vite restart her running dev server, and this session was told not to restart her servers. Deployed admin is unaffected:VITE_MERCHANTS_API_URLthere is the Cloud Run origin, so/admin/offersresolves.
The docs site has no directory index pages, so "All changelogs" 404ed. โ
/changelogs/,/changelogs/dev/,/worklog/and/business/launches/all return VitePress's 404. Only explicit files resolve, which is why the launch plan is linked as/business/launches/README.- Fixed by adding
docs/changelogs/README.md, following the README-per-directory conventiondocs/business/,docs/planning/anddocs/fireside/already use. It says what the files are, how they are named and who writes them, and it does not enumerate, so it cannot rot.
Removing the card grid took the admin shell's a11y warnings from 27 to 5. โ
- The eleven cards were
divs withonClickand no keyboard handling: twojsx-a11ywarnings each.AdminShell.jsxwent from 28 eslint problems to 6, and the one remaining error is pre-existing, in the sidebar nav, which this session did not touch.
How was it verified? โ
On her running stack: admin portal at http://localhost:3001/admin/dashboard (HERS, untouched), APIs on 8080 to 8086 (HERS), docs site on 5174 (HERS). Nothing was restarted, rebuilt or killed. Headless Playwright, signed in as the Agent Probe test admin, viewport 1440x950.
Routes loaded in the browser. โ
/admin/dashboard(the page itself, at 1440x950 and at 1100x950).- Every rail destination, each confirmed to land on a real screen:
/admin/analytics/dashboards(Venue Activity),/admin/venues(Venues),/admin/users(User Management),/admin/merchants/applications(Applications),/admin/merchants/offer-review(Offer review). - Every docs-site link, each confirmed by page title:
/ABOUT(About Lantern),/business/launches/README(Launch Plan),/business/README(Lantern Business Documentation),/(Lantern),/changelogs/README(Changelogs), and all six release-day pages (Changelog - Dev - 08.24.2026through08.17.2026). - Every link on the page resolves. No 404s.
What the live page showed. โ
- Rail: Lanterns lit now
0, Waves pending0, Venues22,636, Users35, New signups 24h0, Merchant applications0, Offers to reviewNot available. - Reading column: 6 release days, 15 entries, stages parsed with Prototype marked current.
0feature cards,0"Available" badges,0em dashes in the rendered text.
Layout, measured rather than eyeballed. โ
- 1440px window: sidebar 310, content 1130,
.admin-page__bodyinner 1034, reading column 762, rail 240, gap 32. Prose caps at 612px (68ch), so it wraps rather than widening. - 1100px window: the split collapses to one column, the rail moves under the reading column and spreads into a strip. Both 694px wide, rail below.
- The rail is
position: sticky; after scrolling 1400px it was still attop: 92, on screen.
Accessibility and contrast. โ
- axe on
.admin-page: zero violations. Full-document axe finds only two, both pre-existing shell chrome (sidebar-section-labelcontrast,sidebar-navscrollable-region-focusable, the latter already tracked as #922). - axe reported 60
incompletecolor-contrast nodes, which is a gate passing without measuring, so it was chased down rather than accepted: the reason is "partially obscured by another element", and it is a below-the-fold artifact of a 3019px page. Re-running with the whole page inside the viewport returns0incomplete. - Every text style measured against its real composited background: worst ratio on the page is 5.76:1, best 17.95:1, all above the 4.5:1 bar. Two token choices came out of that measurement and are commented in the CSS:
--muted-dark-2is 2.49:1 on--surfaceand was replaced, and admin's--accent-50override is a near-white orange that cannot sit behind amber text, so the current-stage tint uses thergba(245, 158, 11, 0.08)wash the rest of the admin already uses.
Checks run. โ
npx vitest run --config apps/admin/vitest.config.js src/admin/dashboard: 15 passed.npx eslint apps/admin/src/admin/dashboard: clean.npm run lint:stories: passes.npm run lint:admin-zones: no dashboard entries (its failures are pre-existing, inapps/admin/src/admin/financials/).npm run lint:emdash: its one failure isdocs/projects/surface-design-pass/harness/survey-web.mjs, already committed on this branch by other work.npx prettier --checkon the new files: clean.- Full
npm run validatewas NOT run, per this session's instruction.
What is still open? โ
- The shape is reversible and she has not seen it. That is the first thing to put in front of her.
- Open moderation cases has no rail row until there is a count endpoint.
- Alerts. The rail is shaped to take a threshold row; the backend does not exist. Her Alerts page side quest is where that lands.
/api/admin/offerslocal dev proxy, above. One line, needs a moment when restarting the admin dev server is acceptable.- Ad Network and Offer review, her other two named redesigns, are untouched by this session.