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
designskill'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 item | Where it goes | Why |
|---|---|---|
| HERS 2 (it is cross-cutting, not one screen) | In, partly | This 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 2 | 63 sites, two portals, and the only live rule violation in the survey |
X3 (PageHeader truncates the subtitle) | In, step 5, DONE 2026-08-27 | A primitive every admin page and 7 merchant call sites consume |
| X4 (subtitles explain our implementation) | In, step 5 | Copy register in a component family both portals share |
| X7 (a merchant row opens a new tab) | In, step 6 | One 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 4 | Status vocabulary, and the same enum surfaces on more than one screen |
| A9 (em dash renders live on Services and CORS) | In, step 2 | An instance of X2 on a screen #320 does not own |
| W1 (emoji and text glyphs where the system uses Lucide) | In, step 3 | Three web sites plus 15 admin sites this pass found |
| W2 (three profile routes render one signed-out screen) | In, step 6 | One shared gate, three routes, and the return destination is lost |
| W3 (only public web screens were reachable) | In, step 1 | Not a finding, a coverage gap. The app half of "one product" is unmeasured |
| The other 20 | Out | Listed 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.envand~/.lantern-test-accounts.envare both present, mode 0600, written 2026-08-24. An earlier project note recorded them as missing; that note is stale.- The
browser-testskill owns the procedure. Never inline a secret; route to those files.
What this step produces. โ
- A
design-survey.mdin this project covering the signed-in web surfaces, in the same shape as the dashboards survey: measured withgetBoundingClientRectandgetComputedStyleon 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/admintoday. AGENTS.md forbids the em dash in anything we write, UI copy included, andlint:emdashpasses 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:144survives it: an unknown count must not render as 0.
Budget for a review pass, because this sweep is not mechanical. โ
- The
designskill 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.jsxis a shared component, andConfigServicesCors.jsxrenders 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:309renders an emoji in an empty state, andAdminSignup.jsxandProfileSettings.jsxrender checkmark and warning characters inline instead ofCheckandAlertTriangle. - 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, plusSetAdminPassword.jsxandReinitializeLanternModal.jsx. - The other six are
UserDetailPanel.jsx,Billing.jsx,MyAdminProfile.jsx,LanternChat.jsx,EventCreator.jsxandSelfHostedDocsEditor.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.loganddevLogstrings. There are 62 such lines inapps/adminalone 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_setupas 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 indecisions.mdquestion 5, live results inverification.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:119callswindow.open(..., '_blank', 'noopener,noreferrer')from arole="button"row with a chevron, which is the in-place affordance everywhere else in the portal.noopenermeans 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/privacyand#/profile/safetyeach 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.jsglobs../../apps/web/src/**and../../packages/ui/**. Nothing else.- So the two
*.stories.jsxfiles that exist underapps/admin/src(QueryResultTableandConsolePanel) are loaded by nothing. They are dead files as far as the portfolio is concerned. bundle-storybook-admin.mjsdoes 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 inpackages/ui, 2 inapps/adminthat 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, itsstyles.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.jsscansapps/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.cssor 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/sitehas its own plan row and its own issues. See question 2 in the README.