Skip to content

Surface design pass: what gets done, in order โ€‹

  • Ordered. Unsequenced items live in backlog.md, not here.
  • Nothing here is started. This is the scope, written 2026-08-24.
  • Every value comes from the tokens and Storybook. No hex value, no raw pixel, no invented spacing. That is the design skill's rule and it binds every step below.

What decides whether an item is in this pass? โ€‹

In: a defect that appears on more than one surface, lives in a primitive more than one surface consumes, or breaks a grammar the other surfaces follow. โ€‹

  • Those are the ones that make the three surfaces read as three products. Fixing them per screen is what produced the current state.

Out: a defect confined to one screen, and anything on a screen #320 is about to redesign. โ€‹

  • The dashboards project has her named rulings pending on the merchant Overview, the two analytics dashboards, the admin Dashboard, Ad Network and Offer review. Touching those screens now means doing the work twice.
  • This pass sets grammars; #320 applies them to the screens it redesigns. Where a grammar's most visible instance sits on a #320 screen, this pass owns the rule and #320 owns that instance.

The count: 10 of the survey's 30 items are in, 20 are out. โ€‹

Survey itemWhere it goesWhy
HERS 2 (it is cross-cutting, not one screen)In, partlyThis pass is the answer to it. It takes 4 of the 7 cross-cutting items and leaves 3 to #320
X2 (three glyphs mean "no value")In, step 263 sites, two portals, and the only live rule violation in the survey
X3 (PageHeader truncates the subtitle)In, step 5, DONE 2026-08-27A primitive every admin page and 7 merchant call sites consume
X4 (subtitles explain our implementation)In, step 5Copy register in a component family both portals share
X7 (a merchant row opens a new tab)In, step 6One call site, but the defect is that it disagrees with every other row in the portal
A7 (merchant status renders the raw enum)In, step 4Status vocabulary, and the same enum surfaces on more than one screen
A9 (em dash renders live on Services and CORS)In, step 2An instance of X2 on a screen #320 does not own
W1 (emoji and text glyphs where the system uses Lucide)In, step 3Three web sites plus 15 admin sites this pass found
W2 (three profile routes render one signed-out screen)In, step 6One shared gate, three routes, and the return destination is lost
W3 (only public web screens were reachable)In, step 1Not a finding, a coverage gap. The app half of "one product" is unmeasured
The other 20OutListed with reasons in backlog.md

Step 1: Survey the signed-in web surfaces โ€‹

The app half of this pass is unmeasured, and no scope built on it is trustworthy yet. โ€‹

  • The survey's W3 is explicit: only the public web routes rendered, so its three web items come from landing, login and signup. It says plainly it should not be read as a clean bill.
  • Everything a real user sees (dashboard, lantern flow, chat, frens, profile, safety) has never been through a design survey.

The credentials this needs exist on the VM now, so it is no longer blocked. โ€‹

  • ~/.lantern-agent-probe.env and ~/.lantern-test-accounts.env are both present, mode 0600, written 2026-08-24. An earlier project note recorded them as missing; that note is stale.
  • The browser-test skill owns the procedure. Never inline a secret; route to those files.

What this step produces. โ€‹

  • A design-survey.md in this project covering the signed-in web surfaces, in the same shape as the dashboards survey: measured with getBoundingClientRect and getComputedStyle on the live page, one capture per route, options rather than verdicts.
  • It does not re-survey the portals. That is done.

Step 2: One grammar for an empty value โ€‹

This is first among the build steps because it is the only live rule violation and it touches both portals. โ€‹

  • 39 em dash literals render in apps/admin today. AGENTS.md forbids the em dash in anything we write, UI copy included, and lint:emdash passes them because it ratchets from a grandfathered baseline.
  • 6 en dash and 18 bare hyphen sites carry the same meaning in two other vocabularies.
  • The visible cost the survey measured: on merchant Overview, one card row renders a dash for "Unique visitors" while the row beside it renders a zero, meaning different things, with nothing telling the reader which.

The decision this step is waiting on is hers, and it is question 1 in the README. โ€‹

  • One glyph, or two components because "withheld under k-anonymity" and "there is nothing here" are genuinely different statements.
  • Whatever wins, the reasoning already written into Overview.jsx:144 survives it: an unknown count must not render as 0.

Budget for a review pass, because this sweep is not mechanical. โ€‹

  • The design skill records an em dash sweep that deleted a span whose entire content WAS the placeholder, and a 57-replacement pass where 5 needed a human. That is a 9% correction rate on an operation that looked purely mechanical.
  • The shared sites go first, because they carry the most instances at once: SystemSection.jsx is a shared component, and ConfigServicesCors.jsx renders seven of them on one screen (the survey's A9).
  • Re-render every affected surface afterwards. None of the three sweeps in the skill's case history failed a test.

Step 3: Lucide only in chrome, on every surface โ€‹

The survey found this in the app. It is worse in the portal, and it is in a shared primitive. โ€‹

  • The app: FrensList.jsx:309 renders an emoji in an empty state, and AdminSignup.jsx and ProfileSettings.jsx render checkmark and warning characters inline instead of Check and AlertTriangle.
  • The portal, found by this scope and not in the survey: 15 check and cross dingbat glyphs across 9 files. Three are shared components: PageTabs.jsx, which renders a text checkmark in the tab options menu on every admin page that has one, plus SetAdminPassword.jsx and ReinitializeLanternModal.jsx.
  • The other six are UserDetailPanel.jsx, Billing.jsx, MyAdminProfile.jsx, LanternChat.jsx, EventCreator.jsx and SelfHostedDocsEditor.jsx.

The one real judgement in this step: a checkmark inside a sentence. โ€‹

  • The survey names it: a checkmark in prose may be punctuation rather than an icon, and swapping it for a Lucide component changes the line's rhythm.
  • Default taken, hers to overrule: a glyph that sits in chrome (a menu, a badge, a status line, an empty state) becomes a Lucide icon. A glyph inside a sentence of prose stays, and the exception gets written down so the next pass does not relitigate it.

What is deliberately not touched. โ€‹

  • Emoji inside console.log and devLog strings. There are 62 such lines in apps/admin alone and none of them render to a user. They are not chrome.

Step 4: One status vocabulary โ€‹

A raw enum is the reader's problem, not the database's. โ€‹

  • The admin merchants list renders pending_setup as a badge: snake_case, untranslated (the survey's A7).
  • The same statuses reach merchant-facing surfaces, so a label map living on one screen forks the moment the second screen needs it.

What this step produces. โ€‹

  • One <StatusBadge> in the admin shared layer that owns both the label and the tone, used by both portals, with the tone drawn from the semantic tokens (--success, --danger, --warning, --info) rather than a per-call-site colour.
  • The tone mapping is the design decision, not the label text. Which statuses are alarming and which are neutral is a judgement someone has to make once.

Step 5: What the reader is told, in the primitives both portals share โ€‹

X4: section subtitles currently explain our implementation to whoever is reading. โ€‹

  • Three measured examples, including one that names an internal job by name to whoever opens the page, and one that describes palette reuse as if the reader cared.
  • This matters more than it looks, because the merchant portal shares this component family. Copy written for us can reach a merchant.
  • This pass owns the rule: reader copy in the UI, rationale in the component's code comment or its story. It does not rewrite the three strings on the two analytics dashboards; #320 does that when it redesigns those panels, under this rule.

X3: PageHeader truncates its subtitle mid-word at a normal window size. DONE, 2026-08-27. โ€‹

  • Seen live at 1440px: the Ad Delivery subtitle stops mid-word and the rest is unreachable. The truncation is deliberate and its comment explains why; the copy outgrew it.
  • This was question 5 in the README and it had no obvious winner. All three options cost something: a lint nobody wrote, a two-line header that breaks the flush alignment the comment protects, or a block above the content on every screen.
  • Measured before choosing, at 1440px across 11 routes, then applied and re-measured across 15. The header is 68px and flush with the sidebar header by design, and it is consumed at 7 merchant call sites as well as every admin page.
  • What shipped: one line stays, title={subtitle} makes the clipped tail reachable. It mitigates a 65px overhang on the single route that clips; it does not solve truncation, and X4's copy rule is what actually fixes the cause. Reasoning in decisions.md question 5, live results in verification.md.
  • Left open: two routes pass a React fragment as their subtitle and so get no title. Neither clips. Covering them is a new fork, not an oversight.

The rendered-prose em dash sweep. DONE, 2026-08-27. โ€‹

  • Not in the original survey. It was named by step 2 as the thing it deliberately did not do, and absorbed here because step 5 already owns UI copy in the shared component family.
  • 16 strings a user reads on screen, each sentence rewritten rather than having the character swapped, plus 2 more found only by driving the browser (a gitignored build artifact the linter cannot see). Detail in decisions.md.

Step 6: Navigation that behaves the way it looks โ€‹

X7: a merchant row looks like in-place navigation and opens a new tab. โ€‹

  • MerchantsAll.jsx:119 calls window.open(..., '_blank', 'noopener,noreferrer') from a role="button" row with a chevron, which is the in-place affordance everywhere else in the portal.
  • noopener means there is no way back, so the admin accumulates tabs.
  • Default taken, hers to overrule: navigate in place, with the merchant switcher's existing "Back to Admin" as the exit. It matches every other row and it needs no new component.

W2: three profile routes render one identical signed-out screen. โ€‹

  • #/profile, #/profile/privacy and #/profile/safety each render the same 97 characters, and after signing in the user lands on the profile root rather than where they were going.
  • A user following a direct link to the safety page is the case that matters, because that link is the one we would send someone who is worried.
  • Default taken: keep the one shared gate, name the destination in it, and return to it after sign-in. Three separate messages is more copy to maintain for the same outcome.

Step 7: Give the portal surfaces a place in the design portfolio โ€‹

The one Storybook does not load a single admin or merchant component, so there is nowhere to see the three surfaces side by side. โ€‹

  • tooling/.storybook/main.js globs ../../apps/web/src/** and ../../packages/ui/**. Nothing else.
  • So the two *.stories.jsx files that exist under apps/admin/src (QueryResultTable and ConsolePanel) are loaded by nothing. They are dead files as far as the portfolio is concerned.
  • bundle-storybook-admin.mjs does not change this: it copies the built web Storybook into the admin app so it can be served behind admin auth. It is hosting, not coverage.
  • The counts: 77 story files in apps/web, 6 in packages/ui, 2 in apps/admin that render nowhere.

Why it belongs to this pass rather than to tooling. โ€‹

  • Every step above is a decision about how the three surfaces agree. Without a portfolio that shows the portal surfaces, the next agent has no way to check agreement except by driving the running app, which is exactly the gap that let 30 items accumulate.

This was found, and deliberately NOT fixed, because the fix is larger than it looks. โ€‹

  • Blast radius: adding apps/admin/src/** to the stories glob pulls admin's alias set, its styles.css, its Firebase bootstrap and its auth context into the Storybook build. The two existing admin stories have never been rendered by any config, so they are unproven.
  • A second, separate decision: lint.story-coverage.js scans apps/web/src/{components,screens,flows} only. Widening it to admin lands a large grandfathered baseline in one commit, which is a ratchet decision, not a config tweak.
  • Rule 18 asks for infrastructure findings to be fixed in the moment. This one is named, sized and left, because both halves need her ruling (question 4 in the README) and this session was scoped to write the scope.

What this pass will not do โ€‹

It will not touch a screen #320 is redesigning. โ€‹

  • The admin Dashboard, the two analytics dashboards, merchant Overview, Ad Network and Offer review. Twenty survey items sit on those screens and they are listed in backlog.md.

It will not migrate anything. โ€‹

  • No compatibility fallback, no feature flag, no parallel old-and-new path. When a grammar changes, the old one goes in the same commit.

It will not invent a value. โ€‹

  • Every colour, radius, space and font weight comes from @lantern/ui/theme.css or the app stylesheet that layers on it. If a value genuinely is not in the system, it gets asked, not guessed.

It will not restyle the public site. โ€‹

  • apps/site has its own plan row and its own issues. See question 2 in the README.

Built with VitePress