Decisions โ
- What this is: the five questions
README.mdrecorded as "what has to be decided before this starts", now answered, plus every judgement call the build made after them. - Who decided: four by the PM session on 2026-08-27 under the operator's autonomy grant, one by the builder session the same day. None of these is her word. Any of them is reversible on sight; the reasoning is written down so overturning one is a sentence, not an investigation.
- What is NOT decided here: anything on a
#320screen, and the three defaultsplanning.mdalready marks "hers to overrule" in steps 3 and 6.
The five that gated the pass โ
1. Which glyph means "no value", and is one glyph enough? TWO glyphs. (PM, 2026-08-27) โ
- The decision: two components and two characters, because there are genuinely two meanings. The em dash is neither of them and it goes.
- Why two: one meaning is "the server withheld this under k-anonymity" and the other is "there is nothing here". Collapsing them destroys information a merchant needs, and one of the two facts is reassuring: "too quiet to report" is not "nobody came".
- Where the fork was already visible:
Overview.jsxdistinguishes unknown from zero deliberately, with a comment explaining that rendering 0 would be a fabricated number.
The two characters, chosen by the builder from what the system already uses. โ
| Meaning | Glyph | Already the live convention in |
|---|---|---|
UNAVAILABLE: we have it and are not showing it (k-anonymity, a failed source, not resolved yet) | En dash, U+2013 | merchant Overview, Venue Activity, Ad Delivery, packages/shared/ads/delivery.js |
NOT_SET: the field is genuinely empty | Hyphen, U+002D | Offer review, Merchant applications, Enforcement, Venues, Merchants |
- Neither character is new, and that is the argument for them. Both were already carrying exactly these two meanings on live screens. What did not exist was a NAME either could be referred to by, so every new site picked a glyph by eye. Changing the characters would have churned the surfaces that were already right in order to fix the ones that were wrong.
- The em dash was the odd one out in every sense: it is banned project-wide, it had no consistent meaning (it appeared in both roles), and it was the largest group.
The finding that settled it: the rule already existed and was unreachable. โ
packages/shared/ads/delivery.jscarried the whole ruling in a comment, "Empty-value glyph for delivery metrics (en dash: the em dash is banned project-wide, including in generated UI copy)", on a privateconst. Nothing outside that one file could import it.- So the drift was not carelessness. A correct rule that no call site can reach is a rule that gets re-decided at every call site.
- That is why the vocabulary now lives in
packages/shared/format/emptyValue.js, whichdelivery.jsitself imports, rather than in the admin app.
The React components carry the part a glyph cannot. โ
- An en dash and a hyphen are hard to tell apart by eye, and a screen reader announces either as nothing useful. Two glyphs alone therefore do NOT deliver the distinction the decision is about.
- So
EmptyValue.jsxpaints the glypharia-hiddenbeside atitleand ansr-onlyphrase.<Unavailable reason="..." />takes the surface-specific explanation, which is the whole reason it is a separate component from<NotSet />.
2. Does the public site come with this pass? NO. (PM, 2026-08-27) โ
- Different audience, different codebase, and
planning.mdalready said so.apps/sitehas its own plan row and its own issues (#848,#593). - Nothing found in step 1 or step 2 argues for moving the boundary.
3. Is the #293 versus #320 boundary the one written here? YES, exactly as written. (PM, 2026-08-27) โ
- This pass sets grammars; #320 applies them to the screens it redesigns. The boundary was already the working one and it is not the builder's to move.
- Step 2 honoured it: merchant Overview keeps its own literal rather than adopting the component, because it already renders the correct glyph and it is a #320 screen (see the deferrals below).
4. Does the admin portal get into Storybook, and does the coverage gate widen to it? YES to Storybook, NO to widening the gate in this change. (PM, 2026-08-27) โ
#972is a real blind spot: two story files underapps/admin/srcare loaded by nothing, so a greenlint:storiessays nothing at all about admin.- Getting admin INTO Storybook closes it. Widening the coverage RATCHET is a separate lever that would fail the build on every admin component without a story.
- This is step 7 and it was not done in this session. The decision tells step 7 what to build; it does not authorize building it early.
5. How does PageHeader stop truncating its subtitle? It keeps ONE line, and the truncated text becomes reachable. (Builder, 2026-08-27) โ
- Measured live at 1440px across 7 admin routes and 4 merchant routes on
http://localhost:3001. The bar is 1130x68 on every one of them.
| Route | Subtitle client / scroll width | Clipped |
|---|---|---|
/admin/analytics/bigquery/export | 448 / 513 | yes, by 65px |
/admin/venues | 379 / 379 | no |
/admin/system | 375 / 375 | no |
/admin/merchants/all | 342 / 342 | no |
/admin/config/services | 330 / 330 | no |
/admin/financials | 323 / 323 | no |
/merchant/<id>/settings | 318 / 318 | no |
/merchant/<id>/overview | 295 / 295 | no |
/merchant/<id>/offers | 272 / 272 | no |
/admin/moderation | 257 / 257 | no |
/merchant/<id>/venues | 247 / 247 | no |
What the numbers say: this is a copy problem at the margin, not a layout failure. โ
- One route in eleven clips, and it clips by 65px on a 1130px bar. Ten fit with room.
- The subtitle sits on ONE baseline beside the title (
.page-header-bar__titleblockisflex-direction: row; align-items: baseline), and it carriesflex-shrink: 999specifically so it absorbs the squeeze before the title gives up a character. It clips when the ACTIONS block is wide, not when the copy is long in the absolute.
Why not the two options that change the layout. โ
- Two lines breaks the 68px flush alignment with the sidebar header, which the CSS comment protects on purpose, and it relitigates two decisions already fought in that block: the title-versus-subtitle shrink order, and the
#826narrow-screen wrap. It would land on every admin page and 7 merchant call sites to fix one route. - Moving the sentence into the page body adds a second block of chrome above the content on every screen, and then has to answer the
designskill's rule 4 about sizing content to its content.
The choice: keep one line, add title={subtitle} so the clipped text is reachable, and let the copy rule fix the cause. โ
- The survey's own wording of the harm is "the rest is unreachable".
titleanswers exactly that, costs one attribute, and behaves identically on both portals because it is in the shared primitive. - It composes with X4 rather than competing with it. X4 (step 5) already owns the rule that a subtitle says what the page IS rather than how it is implemented. Shorter reader-facing copy is what actually gets subtitles under the budget;
titlecovers the ones that stay long. - Named honestly:
titleis not keyboard-reachable and screen readers treat it inconsistently. It is a mitigation for a 65px overhang, not an accessibility fix. The durable fix is the copy.
DECIDED, NOT BUILT. โ
- The session was scoped to steps 1 and 2, stated twice.
PageHeaderis step 5. The change is two lines inPageHeader.jsxand it is waiting on the word to apply it.
What did step 2 decide on its own? โ
The design skill puts a sweep like this at a 9% human-correction rate, so every site was classified by hand. The ones that were not obvious:
A loading or unresolved value is UNAVAILABLE, not NOT_SET. โ
- BigQuery Workspace, Saved Queries and the Schema Browser all rendered a placeholder while data was in flight. A count that has not arrived is not a count of nothing.
- Two of those were rendering a HYPHEN for it, which under this grammar said the opposite of what was true.
An absent timestamp is NOT_SET, but an unparseable one is UNAVAILABLE. โ
- Both branches sit three lines apart in
EventCreator.jsx'sformatHistoryTimestamp.!tsmeans nothing was recorded;Number.isNaN(date.getTime())means something WAS recorded and we cannot show it. - This is the clearest case for two glyphs rather than one, because a single glyph would have merged a missing field with a data defect.
"No comparison period" is UNAVAILABLE; "no subscription line" is NOT_SET. โ
- They sit two lines apart in
VendorSummaryStrip.jsxand both were the same em dash.
A minus paired with a plus is not a placeholder. โ
AdminShell.jsxrendersexpanded ? '-' : '+'as a collapse indicator, twice. It matches every grep for a bare hyphen and it is deliberately untouched.- This is exactly the failure the
designskill records from a previous sweep (a span whose entire content WAS the placeholder, deleted). Sweep the meanings, not the characters.
A negative sign is not a placeholder either. โ
const sign = num < 0 ? '-' : ''inInvoicesTable.jsx. Same grep, different job.
What did step 2 deliberately NOT do? โ
It did not touch merchant Overview. โ
Overview.jsxholds the last two raw en dash literals inapps/admin. It is a #320 screen (decision 3), it already renders the CORRECT glyph, and another session had it open at the time.- What would close it: whoever next touches Overview swaps the two literals for the component. Zero visual change; it gains the screen-reader phrase.
It did not sweep em dashes out of rendered PROSE. โ
- Roughly 19 rendered UI strings in
apps/adminstill contain an em dash inside a sentence (ClientSdkPage,LanternChat,UserDetailPanel,ConsolePanel,PerPlacementPage,DesignTab,SetMerchantPassword, and others). They are a live violation of the em dash rule, but they are not the empty-value grammar, and sweeping them would have doubled this change's footprint across two other sessions' files. - What would close it: its own pass, or absorption into step 5, which already owns UI copy in the shared component family.
It did not widen the em dash baseline beyond its own work. โ
AdminShell.jsxlost an em dash during this session from another session's edit. Its baseline row was put back to 7 by hand so this commit ratchets only what it removed.
What did step 5's first three items decide? โ
Three changes landed on 2026-08-27, in that order, each its own commit. Two of them applied a decision already recorded above. The third is a scope crossing and it is written down as one.
Decision 5 was APPLIED, unchanged, and it behaves as measured. โ
title={subtitle}is inPageHeader.jsx. Live results on 15 routes are inverification.md.- One thing the decision did not anticipate: two admin routes pass a React fragment as their subtitle rather than a string, so they get no
title. Neither clips. Covering a node subtitle is a NEW fork (a second prop, or reading text off a ref), so it was left rather than invented. Detail and the measurements inverification.md.
A PM EXCEPTION was taken to touch merchant Overview, a #320 screen. (PM, 2026-08-27) โ
- The boundary is decision 3, which hands every dashboard screen to
#320and keeps the grammars here. Step 2 honoured it and deliberately left Overview alone. - The exception, authorized under the operator's autonomy grant, with the reasoning stated so the boundary stays a boundary: the change is two lines, it has ZERO visual change, and it makes a glyph that is currently ambiguous say what it means. A reader could not tell "too quiet to report" from "nobody came", and those are different facts with one of them reassuring.
- What kept the exception narrow: one value, one meaning.
statValueand its other seven call sites are untouched, and nothing else on the screen was restyled, relaid out, or fixed. - This is not a precedent for the rest of #320's screens. It is a single ambiguous value in a grammar this pass owns, on a screen this pass does not. Anything larger goes back to the boundary.
The prose sweep found 16, not the 19 the step-2 note estimated, and the difference is instructive. โ
- The estimate came from a file-level grep. The real count came from the linter's own classifier, which tracks comment and string state character by character, so it separates a sentence a user reads from a JSDoc block that merely contains the character.
- Three of the named files were partly comments, and
BillingReports's two prose-looking hits turned out to be inside JSX comment blocks. Sweeping by grep would have edited them. - Every sentence was REWRITTEN rather than having the character swapped, because a bare comma where a dash joined two clauses usually reads worse than the original.
- Two lost the character with no replacement.
ClientSdkPage's Returns and Throws rows prefixeddescriptionHtml, which arrives wrapped in a paragraph, so the glyph was landing on its own line above the text rather than joining the type to it. The row's existing 8px flex gap does the separating, which is already how the params table and the Methods list render the same field.
A gitignored build artifact was rendering two more, and only the browser could see them. โ
apps/admin/src/generated/client-sdk.jsonandapps/admin/public/client-sdk/index.htmlare both gitignored, so the ratchet linter (which lists TRACKED files) has never scanned either.- The live probe found two on
/admin/client-sdk. They are authored ingenerate-client-sdk-docs.mjs'sCATEGORY_DESCRIPTIONS, which IS tracked, so they were fixed at source and regenerated. - What is left there, named rather than swept: the same generator emits the LEGACY static page at
/admin/client-sdk/legacy, whose template still contains the character in its own chrome and in a type-then-description prefix that mirrors the one just removed from the React page. That page renders through different CSS, so removing the prefix there is a layout change that needs its own look. It was not touched.
The baseline was tightened only for files this work emptied. โ
- 4 rows dropped to zero and removed, 7 lowered, plus the generator lowered from 17 to 15. The linter's unclassified count across UI files fell from 47 to 31, which is exactly the 16 rewritten strings.
AdminShell.jsxwas left at 7 on purpose, again. It sits at 6, so the win is real, but it belongs to another lane's commit and the row is theirs to claim. The linter reports it as a non-blocking "lock in the win" notice.