@boost33/ui 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +2973 -0
  2. package/dist/components/AccountMenu/AccountMenu.js +66 -0
  3. package/dist/components/AccountMenu/AccountMenu.js.map +1 -0
  4. package/dist/components/AccountMenu/AccountMenu.module.css.js +42 -0
  5. package/dist/components/AccountMenu/AccountMenu.module.css.js.map +1 -0
  6. package/dist/components/AccountMenu/icons.js +32 -0
  7. package/dist/components/AccountMenu/icons.js.map +1 -0
  8. package/dist/components/AccountMenu/labels.js +14 -0
  9. package/dist/components/AccountMenu/labels.js.map +1 -0
  10. package/dist/components/CommandPalette/CommandPalette.js +65 -0
  11. package/dist/components/CommandPalette/CommandPalette.js.map +1 -0
  12. package/dist/components/CommandPalette/CommandPalette.module.css.js +34 -0
  13. package/dist/components/CommandPalette/CommandPalette.module.css.js.map +1 -0
  14. package/dist/components/CommandPalette/icons.js +23 -0
  15. package/dist/components/CommandPalette/icons.js.map +1 -0
  16. package/dist/components/CommandPalette/labels.js +19 -0
  17. package/dist/components/CommandPalette/labels.js.map +1 -0
  18. package/dist/components/NotificationCenter/NotificationCenter.js +67 -0
  19. package/dist/components/NotificationCenter/NotificationCenter.js.map +1 -0
  20. package/dist/components/NotificationCenter/NotificationCenter.module.css.js +54 -0
  21. package/dist/components/NotificationCenter/NotificationCenter.module.css.js.map +1 -0
  22. package/dist/components/NotificationCenter/icons.js +45 -0
  23. package/dist/components/NotificationCenter/icons.js.map +1 -0
  24. package/dist/components/NotificationCenter/labels.js +26 -0
  25. package/dist/components/NotificationCenter/labels.js.map +1 -0
  26. package/dist/components/ProductMark/ProductMark.d.ts +66 -76
  27. package/dist/components/ProductMark/ProductMark.js +218 -64
  28. package/dist/components/ProductMark/ProductMark.js.map +1 -1
  29. package/dist/components/SettingsFrame/SettingsFrame.d.ts +91 -0
  30. package/dist/components/SettingsFrame/SettingsFrame.js +109 -0
  31. package/dist/components/SettingsFrame/SettingsFrame.js.map +1 -0
  32. package/dist/components/SettingsFrame/SettingsFrame.module.css.js +74 -0
  33. package/dist/components/SettingsFrame/SettingsFrame.module.css.js.map +1 -0
  34. package/dist/components/SettingsFrame/icons.d.ts +10 -0
  35. package/dist/components/SettingsFrame/icons.js +37 -0
  36. package/dist/components/SettingsFrame/icons.js.map +1 -0
  37. package/dist/components/SettingsFrame/labels.d.ts +36 -0
  38. package/dist/components/SettingsFrame/labels.js +18 -0
  39. package/dist/components/SettingsFrame/labels.js.map +1 -0
  40. package/dist/index.d.ts +17 -0
  41. package/dist/index.js +45 -29
  42. package/dist/index.js.map +1 -1
  43. package/dist/style.css +1 -1
  44. package/package.json +3 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,2973 @@
1
+ # Changelog
2
+
3
+ Changes to the published surface of `@boost33/ui` — what a consuming product
4
+ sees when it takes a version bump.
5
+
6
+ **This file is the only notice a consumer gets.** Consumers upgrade by hand and
7
+ nothing watches this package for them (`CLAUDE.md`, "Versioning"): no bot opens a
8
+ PR naming the version, and nobody reads a diff of `dist/`. Somebody decides to
9
+ run `npm install @boost33/ui@<version>`, and what they read first to judge
10
+ whether to is this file. A change that passes every automated gate and still
11
+ alters behaviour has no other way to reach them. That is what belongs here.
12
+
13
+ That makes an entry a **per-PR obligation** rather than a release chore. A change
14
+ merged without one is not announced late, it is not announced at all — the
15
+ release cut dates the section, it does not write it.
16
+
17
+ **Not everything that lands belongs here.** `package.json` ships `dist/` and this
18
+ file, and `dist/` is built from `src/index.ts` alone, so the specimen app, the scripts,
19
+ the tests and anything in `src/` not reachable from `index.ts` never reach a
20
+ consumer. Those changes are the repo's business, not the changelog's. Each
21
+ release below names them separately, briefly, so the omission reads as a decision
22
+ rather than a gap.
23
+
24
+ Versions stay `0.x` until the Wallet and Score both render from this package.
25
+ Under `0.x` a behaviour change can ship as a patch or a minor, which is precisely
26
+ why the entry matters more than the number.
27
+
28
+ ## Unreleased
29
+
30
+ Three components are built to Stage A on main and are not releasable until
31
+ each passes its fidelity gate. Their entries wait here for the release that
32
+ ships them.
33
+
34
+ ### Changed — `CHANGELOG.md` now ships inside the package
35
+
36
+ `package.json` `files` adds `CHANGELOG.md` next to `dist`, so this file is in
37
+ the tarball at `node_modules/@boost33/ui/CHANGELOG.md`. Until now it reached a
38
+ consumer only through the repository, which is a strange place to keep the one
39
+ notice a consumer gets. Nothing in `dist/` changes. 0.5.0 and earlier were
40
+ published without it, and a published version cannot be amended, so the first
41
+ release to carry it is the one after 0.5.0. Repo-only otherwise: no import,
42
+ prop or rendering changes.
43
+
44
+ ### Added — `CommandPalette`, Stage A only. **Not releasable: it has not passed its fidelity gate** (B33-495)
45
+
46
+ **Do not include this component in a release.** `CommandPalette` is exported from
47
+ `src/index.ts` and will appear in `dist/`, and a consumer who finds it there and
48
+ renders it is rendering an unapproved view. It is Stage A under `CLAUDE.md`'s
49
+ presentational-first rule: static markup, reproduced from Paper's
50
+ "CommandPalette — search" artboard, with **no state, no handlers and no data**.
51
+ The Wallet never rendered a palette, so there is no captured referent and no
52
+ approved measurement anywhere — `CLAUDE.md` is explicit that before the gate
53
+ nothing is authoritative and Paper is only a starting drawing. It becomes
54
+ releasable when a person opens the specimen route, approves it, captures the
55
+ first *designed* baseline themselves, and writes the measurements they approved
56
+ into the spec document in the same session. Until all three have happened, cut
57
+ this out of the release or hold the release.
58
+
59
+ What exists today, and what does not:
60
+
61
+ | | |
62
+ | --- | --- |
63
+ | Draws | the search row, three result groups with their headings, the footer legend, and an empty state |
64
+ | Takes | `groups`, `query`, `activeResultId`, `labels`, `linkComponent` |
65
+ | Does **not** | open, close, trap focus, mark anything `inert`, register `⌘K`, dismiss on Escape or a backdrop click, or move a highlight |
66
+
67
+ **It takes grouped results as props and owns no search index.** Pages, Products
68
+ and Actions are all the host's, decided on B33-495 on 2026-10-06. There is no
69
+ cross-suite index in any repo and this component does not imply one: a palette
70
+ over one product's pages is a smaller promise than the artboard's description
71
+ ("across the whole suite") makes, and that is accepted so the component is not
72
+ held behind an architectural gap. Taking results as props is scope-agnostic —
73
+ four products' results render exactly as one product's do — so a real index later
74
+ changes what is passed in, not what is here.
75
+
76
+ A result that navigates renders through `linkComponent` (ADR-0011); one with no
77
+ `href` is an action and stays a `<button>`, with no handler until Stage B. Each
78
+ result's glyph is the host's `icon`, because the groups are the host's and
79
+ nothing here can map one to a glyph without learning what a host's group means.
80
+ The markup is the combobox-over-listbox ADR-0010 settled for this component —
81
+ `role="combobox"` on the input naming the highlighted row through
82
+ `aria-activedescendant`, `role="listbox"` on the list, `role="option"` on each
83
+ result, `role="group"` on each group — and that is markup, not traversal; what
84
+ the arrow keys do is Stage B's to implement.
85
+
86
+ Every user-facing string is a prop with an English default
87
+ (`defaultCommandPaletteLabels`, exported and frozen), resolved through the same
88
+ `Partial` + `undefined`-skipping merge `WalletChip` uses, so one missed
89
+ translation key falls back to English rather than leaving the panel's only
90
+ control unnamed (B33-873, B33-877, and the contract B33-1007 asks the package to
91
+ converge on).
92
+
93
+ **New token: `--container-command-palette: 560px`**, the panel's width, read off
94
+ the artboard and recorded in `data/paper-tokens.json` as **provisional** — it is
95
+ the drawing's number, not an approved measurement, and the person at the gate may
96
+ move it. It is the only token this change adds; everything else draws on tokens
97
+ that already existed.
98
+
99
+ `TopBar` is unchanged. There is still no `⌘K` badge in the bar and still no
100
+ `aria-haspopup` on the search button — both arrive with the panel, in Stage B.
101
+
102
+ Not in `dist/` and not a consumer's business: the specimen route showing the four
103
+ states, their `SPECIMEN_ONLY` records (none of them is screenshot-gated, because
104
+ a *designed* baseline is the gate's to capture and never an agent's), and
105
+ `tests/command-palette.test.tsx`.
106
+ ### Added — `AccountMenu`, Stage A only, **not yet through its fidelity gate** (B33-493)
107
+
108
+ > [!danger] Do not include this component in a release until a human has approved
109
+ > it. `AccountMenu` has **not passed its fidelity gate.** Nobody has yet looked at
110
+ > it beside a reference and said yes, no baseline exists for it, and no measurement
111
+ > it draws with has been written into the spec. `CLAUDE.md` is explicit about what
112
+ > that means for the three components the Wallet never rendered: "Before the gate,
113
+ > nothing is authoritative", and the gate is where the referent is created rather
114
+ > than checked. Until that session has happened — a person opening the specimen
115
+ > route, judging the component, capturing the first baseline themselves, and
116
+ > recording the approved measurements in the spec in the same pass — every number
117
+ > this component renders is provisional and may change in a way that moves pixels
118
+ > in your app. **If you are cutting a release, either hold this entry back or get
119
+ > the gate done first.**
120
+
121
+ The twelfth component. The account menu's **panel**: the signed-in person, the
122
+ organisation they are acting in, and the rows into Settings and out of the
123
+ session. Exported as `AccountMenu`, with `AccountMenuProps`,
124
+ `AccountMenuPerson`, `AccountMenuOrg`, `AccountMenuGroup`, `AccountMenuItem`,
125
+ `AccountMenuIcon`, `AccountMenuLabels` and `defaultAccountMenuLabels`.
126
+
127
+ **It is the panel and nothing else — no trigger, no open state, no dismissal.**
128
+ That is not an unfinished half of trigger-plus-panel; it is the part of this
129
+ component the two reference drawings agree about. Paper's 004 Components artboard
130
+ and its origin on 103 Identity are identical in every property of the panel, and
131
+ they disagree about the anchor above it and about where the panel sits relative to
132
+ that anchor. Choosing between them is the gate-holder's, so the component takes no
133
+ anchor, does no positioning, and leaves `TopBar` untouched. Nothing in this release
134
+ changes `TopBar`.
135
+
136
+ **What a consumer does with it today: nothing.** Render it and you get a static
137
+ panel. It holds no state, so it cannot be opened or closed, and it wires no
138
+ handlers, so `Sign out` is a `<button>` that does nothing when pressed. Wiring it
139
+ to `useAnchoredOverlay`, giving the anchor its `aria-expanded`, and deciding what
140
+ each row does are Stage B, after the gate (`CLAUDE.md`, "Presentational-first").
141
+
142
+ **The shape, so it is legible from here:**
143
+
144
+ - `person` is `{ name, email, avatarUrl? }`. With no `avatarUrl` the circle draws
145
+ bare, which is what the drawings show; with one, the image is decorative and
146
+ takes an empty `alt`, because the person's name is on the line beside it.
147
+ - `org` is `{ name, initials, role? }`. The initials are decorative; omitting
148
+ `role` draws no pill rather than an empty one.
149
+ - `groups` is a list of `{ id, items }`, and **a hairline is drawn above every
150
+ group** — which is what separates the identity blocks from the first group as
151
+ well as each group from the last. Grouped rather than an `items` array plus a
152
+ named `footer`, because "footer" would be this package inventing a meaning for
153
+ the second group.
154
+ - an item is `{ id, label, description?, href?, icon? }`. **Whether a row
155
+ navigates is decided by `href` and nothing else** (ADR-0011): with an `href` the
156
+ row is a link and renders through `linkComponent`; without one it is an action
157
+ and stays a `<button type="button">`. So the package never has to know which of
158
+ Profile, Settings, Switch account and Sign out your product treats as a
159
+ navigation. `icon` names one of four glyphs the package draws
160
+ (`'person' | 'settings' | 'switch' | 'signOut'`); omit it and the glyph lane
161
+ stays, empty, so the row labels still line up in one column.
162
+ - `linkComponent` is the ADR-0011 escape hatch, unchanged from `NavItem` and
163
+ `AppSwitcher`: an `ElementType` defaulting to `'a'`, receiving `href`,
164
+ `className` and the row's `data-*`/`aria-*` through unchanged.
165
+ - `labels` is `Partial<AccountMenuLabels>` and holds **one** field, `panel`,
166
+ default `"Account"` — the panel's accessible name, which a disclosure panel has
167
+ no other way to get (ADR-0010). Everything else a person reads is row data you
168
+ supply, and therefore translates by construction.
169
+
170
+ **The label contract is the `WalletChip`'s, deliberately and in full.** `labels`
171
+ is a `Partial`, a field supplied as `undefined` (what a missed next-intl key looks
172
+ like) falls back to English rather than blanking the panel's name, and
173
+ `defaultAccountMenuLabels` is frozen with no type annotation so the freeze
174
+ survives into the emitted `.d.ts`. That is three of this repo's own fixed bugs
175
+ (B33-873, B33-877, B33-916) taken as the starting point rather than repeated.
176
+ B33-1007 records `AppSwitcher` still diverging on the last of the three; this
177
+ component does not add a fourth dialect, and the switcher's remaining divergence
178
+ is unchanged here and still that ticket's.
179
+
180
+ **A disclosure, not a menu** (ADR-0010): no widget role on the panel, no role on
181
+ the rows, every row the real `<a>` or `<button>` it already is, so Tab walks them
182
+ in the document's own order.
183
+
184
+ **Three new tokens, all three read off an unapproved drawing.** They reach your
185
+ app through `./style.css` and `./theme.css` like every other token, and the
186
+ derivation comment on each says in as many words that the value is not settled:
187
+
188
+ - `--container-account-menu: 312px` — the panel's width.
189
+ - `--spacing-3_5: 14px` — the person block's block padding.
190
+ - `--spacing-9_5: 38px` — the person block's avatar, the largest in the package.
191
+
192
+ Nothing else in the package reads any of the three, so no existing rendering
193
+ moves, and **no baseline moved**: all 27 gated states come back unchanged, because
194
+ every token here is additive and no shared value was touched.
195
+
196
+ **Four places where this does not reproduce its drawing**, each named at its rule
197
+ in `AccountMenu.module.css`, and each because the drawn value is not a step this
198
+ package's token set holds and the nearest step is within about a pixel — the trade
199
+ `PageHeader` already records for `--leading-tight` against Paper's 30px. The row's
200
+ glyph-to-text gap (11px drawn, `--spacing-2_5` here); the person block's
201
+ name-to-email gap (3px drawn, `--spacing-0_5`, which is what the rows draw for the
202
+ same relationship); two 10px type sizes (`--text-2xs`); and every line-height,
203
+ because the drawing sets them in px and this package holds only percentages.
204
+
205
+ **One open design question, recorded rather than decided.** This panel picks a row
206
+ out with `--color-neutral-50`, where every other popover in the package uses
207
+ `--color-surface-muted`, one neutral step darker. Reproduced as drawn and raised
208
+ at the gate — the drawing was cloned from an Identity screen that predates the
209
+ shared token pass, which is why it is the only popover in its own file still bound
210
+ to literals.
211
+
212
+ #### Not in `dist/`, and so not your concern
213
+
214
+ The specimen gains an `AccountMenu` route with two sections, neither screenshot-gated
215
+ and both recorded in `SPECIMEN_ONLY` with reasons: the panel, whose first baseline
216
+ is a *designed* baseline only the person at the fidelity gate may capture, and a
217
+ row rendered through a stand-in link component, which draws identically to the
218
+ default by design. `tests/account-menu.test.tsx` adds 28 cases covering the
219
+ structure, the row elements, the hairline-per-group rule, the label fallbacks and
220
+ the ADR-0011 pass-through — none of them asserting geometry, which jsdom cannot
221
+ see.
222
+
223
+ ### Added — `NotificationCenter`, Stage A only, **not releasable yet** (B33-494)
224
+
225
+ > [!danger] This component has not passed its fidelity gate and must not be
226
+ > included in a release until it has.
227
+ > `NotificationCenter` is exported from `src/index.ts`, which means it would be
228
+ > published by any version cut from this state. Do not cut one. A human has not
229
+ > yet opened the specimen route, judged the component, captured its first
230
+ > baseline or written its measurements into the spec — and until all three happen
231
+ > in one session, there is no referent for this component at all (`CLAUDE.md`,
232
+ > "The three components the Wallet never rendered"). Nothing it draws is settled,
233
+ > including the panel's 360px width and every space inside it.
234
+ >
235
+ > It is also only half built. Stage A is static: the panel renders and nothing
236
+ > responds. The bell does not open it, Escape does not close it, the filters do
237
+ > not filter and "Mark all as read" marks nothing. That is Stage B, and it lands
238
+ > after the approval, not before.
239
+
240
+ The panel behind the top bar's bell: everything the four products did, in one
241
+ list, with the unread ones marked. Both states the design draws — populated and
242
+ empty — ship from this package and appear in the specimen app.
243
+
244
+ **It is presentational and it owns no feed.** The notifications arrive as a prop
245
+ and this package neither fetches, stores nor subscribes to anything. There is no
246
+ cross-product event source in any repo today, and a component that implied one
247
+ would make the shared shell the place four services integrate — the opposite of
248
+ the dependency direction every other component here has, and a straight
249
+ contradiction of `CLAUDE.md`'s "it renders what it is given". That was decided on
250
+ the ticket on 2026-10-06; the feed itself is separate work, outside 1.0.
251
+
252
+ ```tsx
253
+ <NotificationCenter
254
+ items={[
255
+ {
256
+ id: 'n1',
257
+ slug: 'wallet', // optional: selects the ProductMark
258
+ title: 'Your granted tokens expire in 3 days',
259
+ source: 'Wallet', // the host's words, not a slug
260
+ time: '12 minutes ago', // preformatted by the host
261
+ dateTime: '2026-10-06T09:48:00Z', // optional: the <time> element's datetime
262
+ href: '/wallet/grants', // optional: a row with nowhere to go is not a link
263
+ unread: true, // defaults to false
264
+ },
265
+ ]}
266
+ viewAllHref="/notifications"
267
+ />
268
+ ```
269
+
270
+ **The time arrives formatted, and that is a decision rather than an omission.**
271
+ The obvious move is the one `WalletChip` makes for numbers — take a `locale` and
272
+ format from a `Date` — and three things separate the two cases. A number is a
273
+ pure function of its value and a locale; a *relative* time needs a `now`, so the
274
+ component would read a clock and then either re-read it on a timer or go stale on
275
+ screen, which is state in a component whose contract is that it has none. It
276
+ would also make this component's own fidelity gate unreproducible: a designed
277
+ baseline showing "12 minutes ago" from `Date.now()` is a different image an hour
278
+ later. And the hard part is editorial — where a product cuts over from "12
279
+ minutes ago" to "Yesterday" to a date is a policy each product may differ on, and
280
+ `Intl.RelativeTimeFormat` does not decide it. The optional `dateTime` is how the
281
+ machine-readable instant still reaches the page without this package deciding how
282
+ to say it.
283
+
284
+ **Every string is a prop with an English default**, in the same shape
285
+ `WalletChipLabels` uses: `labels` is a `Partial`, a missing key falls back to
286
+ English rather than blanking the UI (B33-873), `defaultNotificationCenterLabels`
287
+ is frozen in the B33-916 spelling so writing to it is a compile error, and the
288
+ one value-carrying string — the row's `{source} · {time}` line — is a template or
289
+ a function, so a locale owns the word order and the separator. `AppSwitcher`'s
290
+ contract still differs on the freeze; B33-1007 closes that by bringing the
291
+ switcher to this shape, not by adding a fourth.
292
+
293
+ The panel is a disclosure, not a menu and not an ARIA tab set (ADR-0010): no
294
+ widget role, real links and buttons, and Tab is the document's own order, so the
295
+ header action, both filters, every row and the footer link are each their own tab
296
+ stop. Rows that navigate render through `linkComponent` (ADR-0011). The unread
297
+ state is announced in words as well as drawn as a dot, because a colour reaches
298
+ nobody on a screen reader.
299
+
300
+ Three further things it deliberately does not do: it does not sort, filter, page
301
+ or truncate `items`, because each is a decision about data it does not own; it
302
+ draws no "0" on the unread filter, because a zero pill reads as a state; and the
303
+ empty rendering drops the filters, the header action and the footer rather than
304
+ offering three controls over an empty list. The empty state is not an edge case —
305
+ a host with no feed wired yet has nothing to pass, which makes it the rendering
306
+ most consumers meet first.
307
+
308
+ **`TopBar` is unchanged.** Its bell's `unread` prop still defaults to `false`, it
309
+ still gains no `aria-haspopup`, and it still opens nothing. Connecting the two is
310
+ Stage B.
311
+
312
+ ## 0.7.0 — not yet published
313
+
314
+ **The redrawn marks' baselines were approved by Jamal on 29 October 2026**
315
+ (`tests/baselines/README.md`, "The 29 October redraw"), so nothing holds this
316
+ release on that account. Date the heading when it is published.
317
+
318
+ **Not breaking. Nothing to do on upgrade, and every product mark looks
319
+ different.** A minor: no export, prop or type changes. `ProductMark` draws new
320
+ artwork for the four slugs it drew before, draws a mark for six slugs it drew as
321
+ a bare tile before, and `hasProductMark` answers `true` for those six.
322
+
323
+ ### Changed — every product mark carries one orange accent, and all ten products have one (B33-1670)
324
+
325
+ **What you will see.** Every `ProductMark`, in the switcher and wherever else you
326
+ render one, is a new drawing: the near-black tile, a white glyph and **one
327
+ orange accent** (`--color-primary`) on one element of it. This reverses the
328
+ 9 September 2026 rule that product marks carry no orange at all (Jamal,
329
+ 29 October 2026, B33-1669). Products are still told apart by shape: the accent
330
+ is the same orange in every mark.
331
+
332
+ | Slug | Mark | Before |
333
+ | --- | --- | --- |
334
+ | `wallet` | a wallet, an orange clasp | a card with a stripe |
335
+ | `boostscore` | a target, an orange bull's-eye | a gauge |
336
+ | `boostvoice` | the Content pencil, an orange line under it | a soundwave |
337
+ | `boostoutbound` | a paper plane, its near wing orange | an arrow leaving top-right |
338
+ | `brokers` | two people, the one behind in orange | bare tile |
339
+ | `boosttrade` | a key, its bow orange | bare tile |
340
+ | `boostfunding` | a banknote and coin, an orange note behind | bare tile |
341
+ | `boostresults` | a pulse running into an orange dot | bare tile |
342
+ | `passport` | a passport, an orange globe | bare tile |
343
+ | `pipe` | a pipeline in three stages, the last orange | bare tile |
344
+
345
+ The glyphs are lifted verbatim from `boostledger` at `ad4146f`, the merge that
346
+ redrew them there, so the Wallet and this package draw the same marks.
347
+ `boostvoice` keeps its slug and draws the Content pencil, because the product is
348
+ being renamed Boost Content; the display name moves on Identity's side
349
+ (B33-1672), and nothing changes for you here when it does.
350
+
351
+ **A muted mark spends no orange.** `muted` (the switcher's unowned and unbuilt
352
+ rows) still greys the tile to `--color-neutral-300`, and now also turns the
353
+ accent white. Outbound gains a fold line between its wings and Passport's globe
354
+ turns grey with white lines, so both stay readable without the accent. Every ink
355
+ on a muted tile is still white, so ADR-0012's contrast reasoning, and its rule
356
+ against combining `muted` with `title`, is unchanged.
357
+
358
+ **A mark's orange is not your screen's orange moment.** The one-orange-moment
359
+ rule (`--color-primary`: the primary action or the threshold marker, never both)
360
+ is about emphasis. A product mark's accent is identity, as the Boost33
361
+ monogram's orange is, and does not count against it (Jamal, 29 October 2026;
362
+ ADR-0015). A switcher showing ten marks does not stop a screen having its primary
363
+ button.
364
+
365
+ **Passport drops two lines below 24px.** At the 20px default its globe's
366
+ meridian and equator would fill the globe in, so they are drawn from 24px up.
367
+ If you render Passport at 24px or larger you get them.
368
+
369
+ **`hasProductMark` answers `true` for all ten slugs Identity serves.** If a test
370
+ of yours asserted `hasProductMark('brokers') === false`, or that a Brokers row
371
+ draws a bare tile, it now fails, correctly. Nothing else in your code needs to
372
+ change: a slug you do not recognise still draws the bare tile, as ADR-0003
373
+ requires.
374
+
375
+ #### Not in `dist/`
376
+
377
+ The specimen's ProductMark route (ten marks per row, a stand-in slug for the
378
+ bare tile, Passport added to the sizes row), a new specimen route
379
+ `AppSwitcherRegistrySpecimen` showing the switcher with all ten products,
380
+ `tests/product-mark.test.tsx`'s new palette, muted and Passport cases and its
381
+ re-pinned Identity transcription (ten slugs, re-read 29 October 2026),
382
+ `tests/app-switcher.test.tsx`'s registry cases, ADR-0015, a note on ADR-0012, and
383
+ `tests/baselines/README.md`'s record of the redraw. **Thirteen baselines changed,
384
+ approved by Jamal**: seven replaced (`productmark-wallet`, `-boostscore`,
385
+ `-boostvoice`, `-boostoutbound`, `appswitcher-closed`, `-open`, `-rail`) and six
386
+ added (`productmark-brokers`, `-boosttrade`, `-boostfunding`, `-boostresults`,
387
+ `-passport`, `-pipe`), which joined `STATES` and the border census. Note for
388
+ anyone relying on the diff: a 20px mark is too small a share of its crop to reach
389
+ the 5% threshold, so the diff would pass a full redraw of any one mark.
390
+
391
+ ## 0.6.0 — 2026-10-26
392
+
393
+ **Not breaking. Nothing to do on upgrade unless you adopt the new component.**
394
+ A minor: one new export, `SettingsFrame`, and three new tokens on `:root`.
395
+ Nothing that 0.5.1 renders changes.
396
+
397
+ ### Added — `SettingsFrame`, the settings frame the hub and every product share (B33-1529)
398
+
399
+ The settings frame the hub and every product draw their settings pages in, so
400
+ a person moving from the hub's settings to a product's sees one settings area
401
+ rather than two apps. It follows the estate decision of 22 Oct 2026: settings
402
+ live in the hub's menu, and each product hosts its own settings pages
403
+ (boost33-voice ADR-0008).
404
+
405
+ **Approved at its fidelity gate on 26 Oct 2026.** Its referent is the hub's
406
+ settings chrome, from which every value is copied. Jamal judged it on the
407
+ specimen route, captured its three designed baselines and approved its
408
+ measurements into the spec ("SettingsFrame, recorded at its gate"). Those
409
+ measurements and baselines now bind; a drawing that disagrees is out of date.
410
+
411
+ | | |
412
+ | --- | --- |
413
+ | Draws | the full-page takeover: a 264px sidebar (back link, title, groups, other accounts, sign out), a 48px header (breadcrumb, help links, the host's account menu), the page heading, and the page itself. Below 768px of its own width, the sidebar becomes one row of links that scrolls inside itself |
414
+ | Takes | `back`, `groups`, `otherAccounts`, `signOutHref`, `helpLinks`, `breadcrumb`, `heading`, `accountMenu`, `children`, `labels`, `linkComponent` |
415
+ | Does **not** | know which products, sections or roles exist; decide which item is active; fetch anything; sign anyone out (sign-out is a link to Identity) |
416
+
417
+ **The groups are data.** The host decides which groups exist, what each is
418
+ called, where each entry goes and which one is `active`. The frame names no
419
+ product.
420
+
421
+ **A link to another origin is a plain full-page link, decided by its href.** An
422
+ href with a scheme (`https:`, `mailto:`) or starting `//` renders as a plain
423
+ `<a>`; a path renders through `linkComponent` (ADR-0011). So the hub passes a
424
+ product's pages as absolute URLs and its own as paths, and a product does the
425
+ reverse. Writing one of your own pages as an absolute URL costs a full page
426
+ load and is otherwise correct.
427
+
428
+ **An item's `icon` is your glyph, inked by the frame.** The frame sets
429
+ `color` on the 16px slot (muted at rest, foreground when active), so draw the
430
+ glyph with `stroke="currentColor"` and mark it `aria-hidden`.
431
+
432
+ **Set the font hooks if your faces load under generated names.** The frame
433
+ draws in `--font-sans` and `--font-display`, which read `--boost33-font-sans`
434
+ and `--boost33-font-display` (B33-848). `next/font` loads Inter and Outfit
435
+ under hashed family names, so a Next host that sets neither hook gets the
436
+ system fallback in the frame while its own pages draw in Inter. The hub needs
437
+ this, once, in its global CSS:
438
+
439
+ ```css
440
+ :root {
441
+ --boost33-font-sans: var(--font-inter);
442
+ --boost33-font-display: var(--font-outfit);
443
+ }
444
+ ```
445
+
446
+ **The narrow layout is a container query, not a media query.** The frame
447
+ switches at 767px of its own width. Rendered full-width, which is how a host
448
+ renders it, that is the viewport's width less any classic scrollbar, so it
449
+ matches the hub's media query everywhere except within a scrollbar's width of
450
+ the breakpoint on a desktop browser.
451
+
452
+ **Three component-private tokens** reach `:root` in `style.css` and mint no
453
+ Tailwind utility (ADR-0013): `--text-settings-group-label` (10px),
454
+ `--tracking-settings-title` (-0.01em) and `--spacing-settings-crumb` (7px), each
455
+ copied from the hub's chrome.
456
+
457
+ Exported: `SettingsFrame`, `defaultSettingsFrameLabels`, and the types
458
+ `SettingsFrameProps`, `SettingsFrameGroup`, `SettingsFrameItem`,
459
+ `SettingsFrameAccount`, `SettingsFrameLink`, `SettingsFrameCrumb`,
460
+ `SettingsFrameHeading` and `SettingsFrameLabels`.
461
+
462
+ #### Not in `dist/`
463
+
464
+ The specimen route (`specimen/routes/SettingsFrameSpecimen.tsx`), its three
465
+ gated states and their border census, the three baselines,
466
+ `tests/settings-frame.test.tsx`, and the private-token pins in
467
+ `tests/theme-drift.test.ts`.
468
+
469
+ ## 0.5.1 — 2026-10-15
470
+
471
+ **Not breaking. Nothing to do on upgrade, and something you may now delete.** A
472
+ patch by the owner's decision: every 0.5.0 call still compiles and still runs.
473
+ It changes what the launch screen looks like, which is why this entry is long.
474
+
475
+ ### Changed — `LaunchLoader` paints one Boost33 background for every product (B33-1401)
476
+
477
+ **What you will see.** The per-product mesh is gone. Every launch screen is now
478
+ the same: black (`--color-neutral-1000`), with one large blurred ellipse of
479
+ brand orange (`--color-orange-500` at its heart through `-600` and `-700`, fading
480
+ out) whose upper rim sits on the screen's horizontal midline. A hop from the hub
481
+ to Score and a hop to the Wallet look identical apart from the caption. The
482
+ dots, the bar, the caption, their blends and sizes and the caption's 80% are
483
+ all unchanged.
484
+
485
+ Approved by the owner in Paper on 15 October 2026 (Boost Products › 004
486
+ Components › "LaunchLoader — brand background", 1440×900). LaunchLoader's
487
+ referent was created at its fidelity gate on 14 October, so this is that
488
+ referent being re-approved by the person who set it, not a refactor. He
489
+ re-captured the three `launchloader-*` screenshot baselines himself the same
490
+ day; the diff reads 31 of 31.
491
+
492
+ **`stops` is ignored and deprecated.** `LaunchLoaderProps.stops` is now
493
+ optional and marked `@deprecated`; passing it renders exactly what omitting it
494
+ renders. `launchBackdropCss(stops?, selector = 'html')` ignores its first
495
+ argument; `launchBackdropCss()` is the call to write now, and
496
+ `launchBackdropCss(stops)` and `launchBackdropCss(stops, selector)` still work.
497
+ `LaunchGradientStops` is still exported, also deprecated, so a file that names
498
+ it keeps compiling. **You may drop your colour maps**: the hub's `launchStops`
499
+ lookup and the Wallet's `WALLET_LAUNCH_STOPS` (and the luminance test guarding
500
+ them) no longer reach the screen. Nothing breaks if you leave them for now.
501
+
502
+ `launchBackdropCss()` now returns
503
+ `html{min-height:100%;background-color:var(--color-neutral-1000,black)}` and the
504
+ same black under `(prefers-contrast)`. It no longer throws on a stop it would
505
+ once have refused, because it writes no stop.
506
+
507
+ **What went.**
508
+
509
+ - `launchGround(stops)` and `launchSolid(stops)` are no longer exported. They
510
+ returned the mesh's ground and darkest stop; there is no mesh. Neither the hub
511
+ nor the Wallet imports them. If you do, the ground is now
512
+ `--color-neutral-1000` in every mode.
513
+ - The `--dur-drift` motion token (5000ms) is removed. Only the mesh's drift used
514
+ it, and nothing moves in the background now.
515
+ - The drift itself: the background is still in every mode, so reduced motion
516
+ only parks the bar, as it did.
517
+
518
+ **Contrast, measured in Chromium** (`npm run launch-contrast-check`, six
519
+ viewports from 1920×1080 to 360×640). The caption and bar now sit on the glow's
520
+ dark-orange shoulder on landscape screens, and on black on portrait ones, where
521
+ the rim falls below the content.
522
+
523
+ | | worst | where |
524
+ | --- | --- | --- |
525
+ | Caption, white at 80% | **6.34:1** | 1280×720 (13.06:1 and up on portrait) |
526
+ | Bar fill against its track | **10.89:1** | 1920×1080 and 1280×720 |
527
+ | Dots against the ground (decorative, exempt) | 1.00:1 | portrait, on black |
528
+
529
+ **The dots and the track vanish on portrait screens.** Both are white
530
+ soft-lit, and soft-light onto pure black draws nothing. On landscape they
531
+ measure about 2:1, as before. The dots are `aria-hidden` and the fill still
532
+ carries the bar at 21:1 on black, so WCAG holds, but on a phone the screen shows
533
+ a caption and a moving white bar on black, with the glow below. **Known, and
534
+ out of scope by the owner's decision of 15 October 2026 — not a defect.**
535
+
536
+ `prefers-contrast` still drops the glow and any `backdrop`, leaving flat black,
537
+ and still draws the dots and track in full white with no blend.
538
+
539
+ **Repo-only, not in the package:** the specimen's three LaunchLoader frames no
540
+ longer pass `stops` (the Wallet frame keeps its test id and caption, on the same
541
+ background); `specimen/routes/launchStops.ts` is gone; the contrast check
542
+ measures one frame at six viewports and reads the bar at every viewport rather
543
+ than only 1440×900; its drift and seam sections are gone with the drift.
544
+
545
+ ## 0.5.0 — 2026-10-14
546
+
547
+ **Breaking. Read the checklist before you bump.** 0.x puts breaking changes in
548
+ the minor, so neither consumer's range (`^0.4.1`, `0.4.1`) picks this up on its
549
+ own. Every item below has its own entry further down with the detail.
550
+
551
+ **What a type check will catch:**
552
+
553
+ - `AppShellProps.product` is now `label`, and `navLabel` is new (B33-1272).
554
+ - `SidebarProps.label` is now `landmarkLabel` (B33-1271).
555
+ - `AppShellProps.pageHeader` is gone; render your `PageHeader` inside `children`
556
+ (B33-1267).
557
+ - `WalletChipProps` is a union on `unknown`. An unknown balance is still
558
+ `unknown: true`, now with no figures at all; annotate the helper that builds
559
+ it `: WalletChipProps`, or TypeScript widens the literal and neither member
560
+ matches (B33-483).
561
+ - A label can no longer be a function; templates only (B33-1278).
562
+ - `PRODUCT_SLUGS` and `ProductSlug` are gone; use `hasProductMark()` (B33-1269).
563
+ - The eight `NAV_*_PX` constants and `measureNavHeight` are no longer exported
564
+ (B33-483).
565
+
566
+ **What a type check will NOT catch** — check these by looking:
567
+
568
+ - **No `onTopUp`, no Top-up button.** `WalletChip` draws it only when a
569
+ handler is passed. A consumer passing none loses the button in every state,
570
+ and past the threshold the chip gets shorter by it (B33-1270).
571
+ - **No `account.onSelect`, no account control.** The switcher's account row
572
+ still shows the account but is no longer focusable or clickable (B33-1270).
573
+ - **Translated label templates use `%name%`, not `{name}`.** An old template
574
+ renders its braces on screen (B33-1277).
575
+ - **`AppShell` now renders a `<main>`.** If your page body draws its own,
576
+ remove it, or the page has two (B33-1265).
577
+
578
+ **Added:** `LaunchLoader`, `DotMatrixLoader`, `launchBackdropCss`,
579
+ `launchGround`, `launchSolid` (B33-1389), approved at its fidelity gate.
580
+
581
+ **Not in this release**, though on main: `CommandPalette`, `AccountMenu` and
582
+ `NotificationCenter`. They have not passed their fidelity gates, so they are not
583
+ exported; their entries stay under Unreleased above.
584
+
585
+ ### Changed — **breaking**: `AppShellProps.product` is now `label`, and `navLabel` is new (B33-1272)
586
+
587
+ **Rename the prop at your one call site.** It is required, so a missed rename
588
+ fails the typecheck rather than going quiet:
589
+
590
+ ```tsx
591
+ // before
592
+ <AppShell product="Wallet" nav={nav} topBar={topBar}>
593
+
594
+ // after
595
+ <AppShell label="Wallet" nav={nav} topBar={topBar}>
596
+ ```
597
+
598
+ - The Wallet: `boostledger/src/app/(wallet)/WalletShell.tsx` — `product="Wallet"`
599
+ becomes `label="Wallet"`.
600
+ - Score: `boostscore/components/app-shell/index.tsx` — `product={PRODUCT_NAME}`
601
+ becomes `label={PRODUCT_NAME}`.
602
+
603
+ **Then consider passing `navLabel`, because `label` alone still names two
604
+ landmarks the same.** The sidebar is `<aside aria-label={label}>` and the nav
605
+ inside it is `<nav aria-label={navLabel ?? label}>`. With no `navLabel` a
606
+ screen-reader user pulling up a landmark list hears "Wallet complementary" then
607
+ "Wallet navigation" — the name doing none of the distinguishing that is the one
608
+ job a landmark label has. Pass a second string and they come apart:
609
+
610
+ ```tsx
611
+ <AppShell label="Wallet" navLabel="Wallet sections" nav={nav} topBar={topBar}>
612
+ ```
613
+
614
+ Something like "Wallet sections" or "Wallet pages", in your product's language.
615
+
616
+ **`navLabel` defaults to `label`, so this release changes no assistive output on
617
+ its own, and the duplicate is not fixed until you pass one.** Said plainly
618
+ because the alternative reads better and is worse: a default of
619
+ `` `${label} navigation` `` would put a hardcoded English word into the
620
+ accessible name of a landmark in every consumer — including the two that default
621
+ to French — and this package hardcodes no user-facing English
622
+ (`tests/no-hardcoded-strings.test.ts`). The word that distinguishes these two
623
+ landmarks is a word only you can write, so the fallback is the honest default
624
+ and the fix is half API and half this paragraph.
625
+
626
+ **Why `product` was the wrong name.** It was used in exactly two places, both
627
+ `aria-label`s. Nothing branched on it, nothing was looked up with it, and no
628
+ artwork or copy changed because of it — yet its doc comment called it "the whole
629
+ of the shell's product-awareness", which sent readers looking for a mechanism
630
+ that was not there. The shell is product-aware through `nav`, `switcher`,
631
+ `wallet` and `footer`. It also sat next to `AppSwitcher.currentSlug`, which takes
632
+ Identity's slug (`"wallet"`), while this takes a display string (`"Wallet"`) —
633
+ two names for one product with nothing in the types connecting them. `label` says
634
+ what it is, and now matches `NavSection.label` and `AppShellNavItem.label`, which
635
+ are also names a person reads.
636
+
637
+ **Nothing visual changed.** No markup moved and no rendered text moved; all 27
638
+ gated screenshot states came back at the ratios they held before.
639
+
640
+ **Check your app if** it renders `AppShell` — both consumers do, and this is the
641
+ one line each has to change.
642
+
643
+ ### Changed — **breaking**: `SidebarProps.label` is now `landmarkLabel` (B33-1271)
644
+
645
+ **If you render `Sidebar` directly, rename the prop.** It is required, so a
646
+ missed rename is a failed typecheck rather than a silent one:
647
+
648
+ ```tsx
649
+ // before
650
+ <Sidebar label="Wallet">…</Sidebar>
651
+
652
+ // after
653
+ <Sidebar landmarkLabel="Wallet">…</Sidebar>
654
+ ```
655
+
656
+ **Nothing else changes.** The value still reaches `aria-label` on the `<aside>`
657
+ and is still never rendered as text. No markup moved, no rendered text moved,
658
+ and all 27 gated screenshot states came back at the ratios they held before.
659
+
660
+ **Why the rename.** `label` meant two opposite things on two components the
661
+ shell composes a few lines apart. `Sidebar`'s was an accessible name only —
662
+ announced, never seen. `NavSection`'s is visible text, the small uppercase
663
+ header above the rows. So reading `label` in this package told you nothing about
664
+ whether the string would appear on screen, and a consumer who learned the
665
+ convention from one component and applied it to the other either shipped a
666
+ visible string into an `aria-label` where nobody could read it, or put a
667
+ screen-reader-only string where a person would. `NavSection.label` keeps the
668
+ visible meaning — the one a reader's intuition matches — and the invisible one
669
+ gets the longer, more specific name.
670
+
671
+ **Check your app if** it imports `Sidebar`. Neither consumer does: both reach it
672
+ through `AppShell`, which passed `label` for you and now passes `landmarkLabel`.
673
+ The expected consumer-side churn for this entry alone is zero — but read the
674
+ `AppShell` entry above, which is where your call site actually changes.
675
+
676
+ ### Removed — seven component-private measurements no longer mint Tailwind utility classes (B33-1274)
677
+
678
+ **Read this if you import `@boost33/ui/theme.css`.** Ten class names stop
679
+ existing, the custom properties behind them are unchanged, and the failure mode
680
+ if you were typing one is silent: an unknown Tailwind class does not error, it
681
+ compiles to nothing and the element renders without that declaration.
682
+
683
+ **The class names that stop existing.** Measured through `@tailwindcss/cli` on
684
+ `tailwindcss@4.3.3`:
685
+
686
+ ```
687
+ bg-grant text-grant bg-purchased text-3xs text-nav-label
688
+ p-topup-reserve m-topup-reserve w-topup-reserve
689
+ w-wallet-segment-min rounded-product-tile
690
+ ```
691
+
692
+ Those are the ones probed. The rule is wider: **every** utility Tailwind derives
693
+ from `--color-grant`, `--color-purchased`, `--text-3xs`, `--text-nav-label`,
694
+ `--spacing-topup-reserve`, `--spacing-wallet-segment-min` and
695
+ `--radius-product-tile` is gone, so `border-purchased`, `bg-grant/50`,
696
+ `gap-topup-reserve` and the rest go with them.
697
+
698
+ **The custom properties are unchanged and still readable.** All seven still land
699
+ on `:root` through `@boost33/ui/style.css`, at exactly the values they have in
700
+ 0.4.0 — `var(--color-sand-600)`, `var(--color-neutral-950)`, `9px`, `14px`,
701
+ `68px`, `3px`, `25%`. `var(--spacing-topup-reserve)` reads the same number it
702
+ read before. Verified on the same build: a consumer importing both files declares
703
+ 133 custom properties before this change and 133 after, none absent and none
704
+ moved. No system utility moves either — `bg-orange-500`, `text-sm`, `rounded-xl`,
705
+ `p-4`, `w-sidebar` and the rest of the scale compile identically.
706
+
707
+ **If you typed one of them, replace the class with an arbitrary value reading the
708
+ property**, which keeps the package authoritative for the number and holds no
709
+ value of your own (ADR-0011):
710
+
711
+ ```diff
712
+ - <span className="rounded-full bg-purchased" />
713
+ + <span className="rounded-full bg-[var(--color-purchased)]" />
714
+ ```
715
+
716
+ `p-topup-reserve` becomes `p-[var(--spacing-topup-reserve)]`,
717
+ `rounded-product-tile` becomes `rounded-[var(--radius-product-tile)]`, and so on.
718
+ Where a system token means the same thing, prefer it — `--color-purchased`
719
+ resolves to `var(--color-neutral-950)`, so `bg-foreground` paints the identical
720
+ ink and stays a first-class utility.
721
+
722
+ **One known binding exists today.** `boostscore/demo/src/Shell.tsx` writes
723
+ `bg-purchased` at `:327` and `:335`; on the version bump that demo's purchased
724
+ bar segment and its legend dot render with no background until the two lines
725
+ above are applied. Nothing else in any consumer binds one of the seven. Score's
726
+ `w-sidebar`, in `components/curator-shell.tsx` and nine `export-jsx/` files, is a
727
+ system token and is untouched.
728
+
729
+ **Why.** Each of the seven is a measurement of one component, hand-added only
730
+ because a literal in a component is forbidden, and each is a number a fidelity
731
+ gate is expected to move — `--spacing-topup-reserve`'s own comment ends by saying
732
+ the real fix "restructures an approved view and needs a human". A minted class
733
+ name is a published API that cannot be withdrawn after 1.0 without a major bump,
734
+ so the package would have been choosing between that bump and a design change
735
+ that is otherwise free. Unminting now costs the two lines above. ADR-0013 has the
736
+ line between a system token and a component-private one, and amends ADR-0012.
737
+
738
+ No rendering of any component in this package changes: `src/tokens.css`'s
739
+ declarations are byte-identical, and all 27 screenshot baselines match at their
740
+ existing ratios.
741
+
742
+ ### Removed — **breaking**: `AppShellProps.pageHeader` is gone (B33-1267)
743
+
744
+ **If you pass `pageHeader`, your build now fails.** Move the header into the
745
+ page body you already hand the shell as `children`:
746
+
747
+ ```tsx
748
+ // before
749
+ <AppShell product="Wallet" nav={nav} topBar={topBar}
750
+ pageHeader={{ title: 'Buy tokens', subtitle: 'Top up your balance.' }}>
751
+ <BuyTokens />
752
+ </AppShell>
753
+
754
+ // after
755
+ <AppShell product="Wallet" nav={nav} topBar={topBar}>
756
+ <PageHeader title="Buy tokens" subtitle="Top up your balance." />
757
+ <BuyTokens />
758
+ </AppShell>
759
+ ```
760
+
761
+ `PageHeader` is unchanged and still exported. The shell renders `children`
762
+ inside the page's `<main>`, so nothing needs a wrapper.
763
+
764
+ **The header now scrolls, where the prop kept it pinned. Read this before you
765
+ move one.** `.content` — the box the shell drops `children` into — is the
766
+ shell's only `overflow-y: auto` element. A header passed through the prop sat
767
+ *outside* it and stayed under the top bar while the page scrolled; a header
768
+ rendered in `children` sits *inside* it and goes up with the content. At rest
769
+ the two are pixel-identical and no screenshot can tell them apart, which is why
770
+ this is spelled out here: a consumer moving a header across will see no
771
+ difference until someone scrolls a long page, and then it will look like a
772
+ regression rather than the documented consequence it is. If a pinned header is
773
+ what your product wants, say so on B33-1267 — pinning it from `children` needs
774
+ a layout change to the shell and a fidelity re-approval, not a prop.
775
+
776
+ **Why it is going now rather than ever.** Nobody passed it. The Wallet renders a
777
+ standalone `PageHeader` from eight page files and has never used the prop;
778
+ Score forbids using it, with a test
779
+ (`components/app-shell/heading-hierarchy.test.ts`), because the page below owns
780
+ the sole `h1` and a second heading with the same name inverts the outline and
781
+ reads the title twice. So the slot existed only because the Wallet's shell was
782
+ once a single file that drew its own header, and the package inherited the
783
+ shape. That is exactly what B33-483's second criterion covers: a prop that only
784
+ exists because of how the Wallet happened to be built gets fixed while breaking
785
+ changes are still free. Keeping it would have cost nothing today and a
786
+ correctness trap forever, since the scroll difference above was documented
787
+ nowhere.
788
+
789
+ **Check your app if** it passes `pageHeader` — neither consumer does, so the
790
+ expected blast radius of this one is zero, and the typecheck will say so either
791
+ way.
792
+ ### Changed — breaking — two controls no longer render when nothing arms them (B33-1270)
793
+
794
+ **If you want the chip's Top-up button, pass `onTopUp`. If you want the
795
+ switcher's account row to be a control, pass `account.onSelect`.** Both used to
796
+ render whether or not a handler came with them, so a consumer that passed
797
+ neither got a visible control that did nothing and could not be removed —
798
+ `.topUp` and `.accountRow` are CSS-Modules-hashed, so there is no selector to
799
+ reach either one from outside this package. Both consumers are affected and both
800
+ had already written the defect down in their own source.
801
+
802
+ #### What disappears if you pass nothing
803
+
804
+ - **No `onTopUp`: the chip draws no Top-up button at all**, in every state. Past
805
+ the threshold that button was `position: static` — a full-width block in flow —
806
+ so the chip is shorter by that block and the runway line becomes the last thing
807
+ in it. Resting it was absolutely positioned at `opacity: 0` until hover, so
808
+ nothing else moves.
809
+ - **No `account.onSelect`: the switcher's account row stops being a control** but
810
+ keeps saying who the account is. The organisation's name and its workspace
811
+ count still render; the chevron, the hover step and the tab stop go, and the
812
+ row is a `<div>` rather than a `<button>`.
813
+
814
+ #### Who this changes today
815
+
816
+ - **The Wallet loses the Top-up button it was already shipping dead.**
817
+ `boostledger/src/app/(wallet)/WalletShell.tsx` passes no `onTopUp`, so the
818
+ button it draws today swallows the click. Pass a handler to keep it. Nothing
819
+ about how it draws has changed.
820
+ - **Score keeps its button and needs no change to keep it.**
821
+ `boostscore/components/app-shell/wallet-chip-top-up.tsx` exists precisely to
822
+ arm it across a Server Component boundary, and it is the file whose doc comment
823
+ described this defect most exactly: "it renders that button whenever the chip is
824
+ not in its `unknown` state — the render is gated on `!unknown`, not on whether a
825
+ handler was supplied". That sentence is now out of date in the best way. **Keep
826
+ the wrapper** — it is still what supplies the callback — and note that its
827
+ "Upstream" paragraph is now half true: B33-1047 would still delete it, and the
828
+ unarmed-render half it describes is fixed here.
829
+ - **Both consumers' account rows become non-interactive, which is what they
830
+ already were.** `boostscore/components/app-shell/product-switcher.tsx` says so
831
+ in a comment on the prop — "No `onSelect`. The row opens a workspace panel that
832
+ does not exist yet — the Wallet's migration left the same row inert for the same
833
+ reason" — and the Wallet's `WalletShell.tsx` says the same. Neither needs a
834
+ change; the row stops lying. Pass `onSelect` when B33-493's workspace panel
835
+ exists and the button and its chevron come back.
836
+
837
+ #### Why absence is not `disabled`, and why the row is not removed
838
+
839
+ A `disabled` button is still a control the consumer cannot take out, and ADR-0010
840
+ settles that unavailable is not the same as disabled — so neither control grows a
841
+ `disabled` or `aria-disabled` attribute in its unarmed shape.
842
+
843
+ The two absences are deliberately different shapes, because the two controls
844
+ carry different things. The Top-up button carries only its own affordance, so
845
+ with nothing to press there is nothing to say and it renders nothing. The account
846
+ row carries information — the organisation and its workspace count are facts
847
+ about the account whether or not a panel opens — and ADR-0010's reasoning in "Not
848
+ entitled is not disabled" is that a row carrying information is not the
849
+ component's to withhold. So the row stays and only its interactivity goes.
850
+
851
+ #### This is not B33-1047
852
+
853
+ B33-1047 asks for a `topUpHref`, which would arm the button for a consumer that
854
+ cannot pass a callback. It would not have fixed this: a consumer passing neither
855
+ prop would still get the dead button, and it does nothing at all for the account
856
+ row. The two are complementary and B33-1047 is still open.
857
+
858
+ #### No baseline moved
859
+
860
+ `walletchip-resting`, `walletchip-open`, `walletchip-threshold`,
861
+ `walletchip-rail`, `walletchip-unknown`, `appswitcher-closed`, `appswitcher-open`
862
+ and `appswitcher-rail` all come back at the ratio they have on `main`,
863
+ `appswitcher-open` included at 0.000%. The gated specimen states now pass
864
+ handlers, which is what they should always have passed: those baselines were
865
+ captured from the Wallet's own shell, where topping up was a live affordance and
866
+ the account row was a button with a chevron on it, so the armed shape is what
867
+ they are photographs of. The unarmed shape is demonstrated on two new ungated
868
+ specimen routes instead.
869
+ ### Removed — breaking — a label can no longer be a function; templates only (B33-1278)
870
+
871
+ Five label fields were `string | ((…) => string)`. The function half is gone:
872
+
873
+ ```
874
+ WalletChipLabels.grantExpiry string | ((date) => string) -> string
875
+ WalletChipLabels.railFigure string | ((figure) => string) -> string
876
+ WalletChipLabels.railBalance string | ((balance) => string) -> string
877
+ WalletChipLabels.bar string | ((g, p, r) => string) -> string
878
+ NotificationCenterLabels.meta string | ((source, time) => string) -> string
879
+ ```
880
+
881
+ **What to pass instead: a `%placeholder%` template, which is what the defaults
882
+ already are.**
883
+
884
+ ```tsx
885
+ // before, from a Client Component only
886
+ labels={{ bar: (g, p, r) => `${r} restants : ${g} et ${p}` }}
887
+ // now, from anywhere
888
+ labels={{ bar: '%remaining% restants : %granted% et %purchased%' }}
889
+ ```
890
+
891
+ The template is filled with the same preformatted figures the function was handed,
892
+ in the same order, so the rendered string is identical. Word order is still
893
+ yours — that is what the placeholders are for.
894
+
895
+ **Why the hatch is closed rather than documented harder.** `WalletChip` is a client
896
+ module and `AppShell.wallet` is a `ReactNode` slot, so it is the consumer's own
897
+ page that constructs the element — and under the App Router that page is a Server
898
+ Component by default, which is how both consumers render it, because reading the
899
+ balance is a server read. A function cannot cross that boundary: React's Flight
900
+ serializer walks the whole prop graph and throws on any function it finds, nested
901
+ inside `labels` exactly as at the top level, in production as well as in
902
+ development. So the function half was type-legal and fatal in production for the
903
+ only way anyone uses this component, and nothing in the type said so. `tsc`
904
+ accepted it, the dev server accepted it, and it threw at serialisation time. Score
905
+ had to cover the hole from outside, with a runtime `typeof raw === "string"` guard
906
+ and five tests walking the prop graph, because — in its own words — neither `tsc`
907
+ nor the dev server will. `npm run typecheck` does now.
908
+
909
+ **On plurals, plainly.** The function form was documented as the escape hatch for a
910
+ sentence a template cannot express — ICU plurals, an `Intl.ListFormat` call — and
911
+ this removes it. **No default label in this package needed it**, so nothing is
912
+ degraded by the removal:
913
+
914
+ - None of the five value-carrying labels is count-dependent. They interpolate a
915
+ preformatted date, an abbreviated balance, and three token figures; the
916
+ surrounding words do not change with the number.
917
+ - The notification centre's unread count is rendered as a bare number beside the
918
+ `unread` tab, never interpolated into a sentence, so it needs no plural rule.
919
+ - The one English-grammar wart is `unit`, which renders "1 tokens" at a balance of
920
+ one. It is pre-existing and untouched: `unit` has been a plain `string` since
921
+ B33-849 and never had a function form, so this change takes nothing away there.
922
+ Fixing it means a count-aware field, which is a decision for a human and not
923
+ something invented here.
924
+
925
+ **If your locale does need plural selection**, do it where ICU already lives: select
926
+ the form in your own catalogue and pass this package the finished string. That is
927
+ what both consumers already do with next-intl, which is why the capability moves
928
+ rather than disappears. No new mechanism was added here, and none should be added
929
+ without deciding its shape first.
930
+
931
+ **This also settles B33-1029** ("`defaultWalletChipLabels` being all-strings is a
932
+ runtime fact pinned by a test, not a compile-time guarantee"). With the union gone
933
+ the defaults are all-strings by construction: a function default is a type error at
934
+ the `satisfies`, and `tests/wallet-chip-labels.type-test.ts` now asserts the
935
+ interface's all-strings shape so re-widening one field fails typecheck rather than
936
+ waiting for a runtime assertion to notice.
937
+
938
+ ### Changed — breaking — label templates are delimited `%name%`, not `{name}` (B33-1277)
939
+
940
+ **If you have translated `grantExpiry`, `railFigure`, `railBalance`, `bar` or
941
+ `meta`, your catalogue has to be edited to match.** Nothing else tells you: the
942
+ placeholder text lives in your translation files, not in a type, so neither `tsc`
943
+ nor a lint will find it. A template still carrying the old spelling renders the
944
+ old spelling — `Granted expires {date}` reaches the screen with the braces in it
945
+ and no date, because an unrecognised placeholder is left exactly as written rather
946
+ than silently blanked.
947
+
948
+ One real template, before and after:
949
+
950
+ ```diff
951
+ - grantExpiry: 'Granted expires {date} · burns first'
952
+ + grantExpiry: 'Granted expires %date% · burns first'
953
+ ```
954
+
955
+ The five fields and their placeholders:
956
+
957
+ | component | field | new default |
958
+ | --- | --- | --- |
959
+ | `WalletChip` | `grantExpiry` | `Granted expires %date% · burns first` |
960
+ | `WalletChip` | `railFigure` | `%figure%k` |
961
+ | `WalletChip` | `railBalance` | `Wallet balance %balance%` |
962
+ | `WalletChip` | `bar` | `%granted% granted and %purchased% purchased of %remaining% remaining` |
963
+ | `NotificationCenter` | `meta` | `%source% · %time%` |
964
+
965
+ `AppSwitcherLabels`, `AccountMenuLabels` and `CommandPaletteLabels` have no
966
+ value-carrying field, so they have no placeholder to rename. Each now records the
967
+ spelling the first one will use, so the next field added does not re-decide this.
968
+
969
+ **Why, in a consumer's own words.** `{\w+}` is *exactly* ICU MessageFormat
970
+ argument syntax — what next-intl, FormatJS and every other ICU-backed library in
971
+ the Next.js ecosystem parses. So a consumer that stores these defaults in the
972
+ message catalogue it already has could not read them back through the formatter it
973
+ already uses. Score's `wallet-chip.tsx` says what happens if you try: ICU *"treats
974
+ `{date}` as an argument it was not given and renders an error string in place of
975
+ the sentence."*
976
+
977
+ **So `%name%` means a consumer reads these strings through its normal formatter.**
978
+ `chip('grantExpiry')` is now the whole of it, and the second accessor Score had to
979
+ write beside it can be deleted:
980
+
981
+ ```ts
982
+ // boostscore/components/app-shell/wallet-chip.tsx — delete this
983
+ const template = (key: string) => {
984
+ if (!chip.has(key)) return undefined;
985
+ const raw: unknown = chip.raw(key);
986
+ return typeof raw === "string" ? raw : undefined;
987
+ };
988
+ ```
989
+
990
+ Two accessors for one label bag, split on which fields happen to carry a value and
991
+ with the wrong choice rendering formatter output in the sidebar, was the cost of
992
+ the old delimiter. It is gone.
993
+
994
+ **Why `%name%` and not the two spellings the ticket suggested.** Both keep a brace
995
+ and so keep the bug. `%{date}` still contains `{date}` — ICU does not read `%` as
996
+ an escape, parses the argument, and fails identically. `{{date}}` is worse: `{` is
997
+ not a legal first character of an ICU argument name, so such a catalogue fails to
998
+ parse at all rather than spoiling one sentence. A brace-free delimiter also clears
999
+ i18next, Mustache, Handlebars and Vue's, Angular's and Svelte's template syntax,
1000
+ every one of which claims the double brace; and `%name%` is delimited at both
1001
+ ends, so it reads as one unit in a translator's editor where gettext's `%(name)s`
1002
+ leaves a bare `s` against the next word.
1003
+
1004
+ **A `{braced}` name in a template is now ordinary text and is passed through
1005
+ untouched** — which is what lets the same string survive a round-trip through your
1006
+ own ICU formatter. `tests/wallet-labels-serializable.test.tsx` pins that directly,
1007
+ so the collision cannot come back by someone widening the filler's pattern.
1008
+
1009
+ **Nothing renders differently** for a consumer that passes no `labels`, or passes
1010
+ the exported defaults: the new templates produce character-for-character what the
1011
+ old ones produced, and no screenshot baseline moves.
1012
+
1013
+ ### Changed — breaking — `WalletChipProps` no longer demands figures no product can supply (B33-483)
1014
+
1015
+ **This makes the chip's threshold state reachable for the first time in either
1016
+ product.** It has never fired in the Wallet, and in Score it fires off a number
1017
+ Score keeps locally and says in its own comment it should not be the authority
1018
+ for. Two of the chip's three designed behaviours — the burn pill and the grown
1019
+ Top-up button — have been dead in the reference implementation since the component
1020
+ shipped. Nothing about how the threshold state *draws* changed; what changed is
1021
+ that a consumer can now get into it.
1022
+
1023
+ **Why it was unreachable.** `grantCycle` was a required denominator, and the chip
1024
+ derived `burned = (grantCycle − granted) / grantCycle` from it. `grantCycle` is the
1025
+ Ledger's `grant_tokens_per_cycle`, and it cannot be reconstructed from a balance
1026
+ read: `cycleUsedPct` counts purchased-bucket spend too, it is not
1027
+ `(cycle total − remaining)` once a credit, refund or void has touched the account,
1028
+ and it is not bounded at 100. So the Wallet passed `grantCycle = granted`
1029
+ (`boostledger/src/app/(wallet)/layout.tsx`, commented "on purpose"), which pins
1030
+ `burned` at 0 forever, and Score passed `PLAN_MONTHLY_STOCK[plan]` instead
1031
+ (`boostscore/lib/pricing/tokens.ts`), which is its belief about the plan rather
1032
+ than the account's provisioned figure.
1033
+
1034
+ The component was contradicting its own stated principle. `burnNote` is a prop
1035
+ precisely because "the burn estimate is the product's to make, not the chip's",
1036
+ while the burn *arithmetic* sat inside the component fed by a figure no product
1037
+ could pass. It is now the same shape as `burnNote`: **the consumer measures, the
1038
+ package decides what 80% means and draws it.**
1039
+
1040
+ #### What changed
1041
+
1042
+ - **`burnedFraction?: number` is new**, and is the threshold input: `0.88` for 88%.
1043
+ Supplied, it wins. Absent, the chip derives the burn from `grantCycle` exactly as
1044
+ before. Absent with no `grantCycle` either, nothing is claimed and the chip rests.
1045
+ It is the figure the pill rounds, so it is a fraction and not a
1046
+ `threshold?: boolean` — a boolean would leave the pill with nothing to print.
1047
+ - **`THRESHOLD_BURN` stays at 0.8 inside the package** and is deliberately not a
1048
+ prop. Four products trip at the same place and cannot drift to four different
1049
+ numbers. You supply the measurement; the policy is not yours to set.
1050
+ `burnedFraction` is **not clamped**: a percentage passed where a fraction belongs
1051
+ renders an absurd pill rather than a plausible "100%".
1052
+ - **`grantCycle` is now optional.** It remains the bar's denominator — what the
1053
+ cycle started with, so the burned part of the grant draws as empty track.
1054
+ Omitted, the denominator falls back to `granted + purchased`, the bar fills, and
1055
+ nothing is claimed about what burned.
1056
+ - **The props are a discriminated union on `unknown`.** With `unknown: true` the
1057
+ figures are not merely optional, they are **forbidden** (`?: never`), along with
1058
+ `grantExpires` and `burnNote`, none of which that state renders. An explicit
1059
+ `undefined` is still accepted, so a consumer that builds one props object rather
1060
+ than branching can write `granted: undefined`.
1061
+ - **`WalletChipKnownProps` and `WalletChipUnknownProps` are now exported** beside
1062
+ `WalletChipProps`, which is their union. See the `Omit` note below for why you
1063
+ need them.
1064
+
1065
+ #### What you need to change
1066
+
1067
+ **The Wallet** — `boostledger/src/app/(wallet)/layout.tsx`:
1068
+
1069
+ 1. Delete `grantCycle: Number(balance.grantBalance)` and the paragraph of comment
1070
+ explaining why it is `granted`. Pass `burnedFraction` from your own burn read,
1071
+ or pass a real `grantCycle` and let the chip derive: `getBalance` has returned
1072
+ `grantTokensPerCycle` since B33-1183 (ledger ADR 0013) and that function simply
1073
+ never got wired to it. Either path reaches the threshold state; the fiction does
1074
+ not.
1075
+ 2. `if (!balance) return { granted: 0, purchased: 0, grantCycle: 0, unknown: true }`
1076
+ becomes `return { unknown: true }`. The comment above it — "Zeroes behind
1077
+ `unknown: true` are never rendered … but they must not be mistaken for a
1078
+ balance" — can go with them.
1079
+ 3. Annotate that helper's return type `: WalletChipProps`. Without it TypeScript
1080
+ widens `unknown: true` in an object literal to `boolean`, which matches neither
1081
+ member of the union, and the spread at the call site stops compiling.
1082
+
1083
+ **Score** — `boostscore/components/app-shell/wallet-chip.tsx`:
1084
+
1085
+ 1. Delete `grantCycleFor()` and its import of `PLAN_MONTHLY_STOCK`, and pass
1086
+ `burnedFraction` from your own burn read. Score's own comment already names this
1087
+ as the right outcome: `PLAN_MONTHLY_STOCK` is "Score's *belief* about the plan's
1088
+ stock" and "the authority is the Ledger's `grant_tokens_per_cycle`". The
1089
+ `granted` floor that guarded against grant rollover goes with it — there is no
1090
+ invented denominator left to floor. `PLAN_MONTHLY_STOCK` itself stays in
1091
+ `lib/pricing/tokens.ts`; it is in `PRICED_CONSTANTS` and the version lock.
1092
+ 2. Drop `granted={0} purchased={0} grantCycle={0}` from the `read.status ===
1093
+ "unknown"` branch, and the comment apologising for them.
1094
+ 3. Same again in `boostscore/components/app-shell/wallet-slot.tsx`, in the
1095
+ `Suspense` fallback's pending chip.
1096
+ 4. `boostscore/components/app-shell/wallet-chip-top-up.tsx` types itself
1097
+ `Omit<WalletChipProps, "onTopUp">`, and **`Omit` over a union is not what it
1098
+ looks like**: it flattens the union into one object and loses the discriminant,
1099
+ so the spread back into `<WalletChip>` stops compiling. Omit from the members
1100
+ instead:
1101
+
1102
+ ```ts
1103
+ type Props = (Omit<WalletChipKnownProps, 'onTopUp'> | Omit<WalletChipUnknownProps, 'onTopUp'>) & {
1104
+ topUpHref: string
1105
+ }
1106
+ ```
1107
+
1108
+ That is the one piece of friction the union adds, and it is the reason both
1109
+ members are exported.
1110
+ 5. `components/app-shell/wallet-bar.ts` — the dead record of B33-590's first fix —
1111
+ can be deleted in the same pass.
1112
+
1113
+ **Neither consumer can pass a real `burnedFraction` from the published
1114
+ `@boost33/ledger-client` yet.** The figure is on the wire (the route returns
1115
+ `getBalance` whole), but `BalanceSuccess` in
1116
+ `packages/ledger-client/src/types.ts` does not declare `grantTokensPerCycle`, so a
1117
+ typed consumer cannot read it. Until that ships, both products can take this
1118
+ version, drop their zeroes and their invented denominators, and render the resting
1119
+ chip they were already rendering — with no fiction in the props. That is strictly
1120
+ better than today and it is not a regression: `burned` resolved to 0 for the Wallet
1121
+ already.
1122
+
1123
+ #### What did not change
1124
+
1125
+ No class name, no markup, no accessible label, and none of the chip's visible
1126
+ states. **No fidelity baseline moved**: all five `walletchip-*` states, and every
1127
+ other gated state, come back at the identical pixel count they had before this
1128
+ change — not merely inside the tolerance, the same number. The specimen's threshold
1129
+ row keeps its `grantCycle={2400}` for the bar and now passes `burnedFraction={0.88}`
1130
+ alongside it, which is what that grant derives to exactly; the unknown row dropped
1131
+ its three zeroes, which the chip never rendered.
1132
+
1133
+ Both defensive guards stay: B33-590's `max()` floor under a non-zero segment, and
1134
+ the `cycleTotal > 0` zero-division guard, which now also covers a chip with no
1135
+ `grantCycle` and a zero balance. B33-875's lazy `bar` label building stays too —
1136
+ the chip still defaults the absent figures to 0 internally, so a consumer's `bar`
1137
+ must still not be called on the degraded path.
1138
+
1139
+ ### Removed — the nav-budget constants and `measureNavHeight` are no longer exported (B33-483)
1140
+
1141
+ **This is a breaking change to the published surface, and the expected migration
1142
+ is nothing.** Nine names are gone from the package entry point:
1143
+
1144
+ ```
1145
+ NAV_HEIGHT_BUDGET_PX NAV_SHELL_FIXED_PX
1146
+ NAV_ITEM_PX NAV_SHELL_PACKAGE_FIXED_PX
1147
+ NAV_SECTION_HEADER_PX NAV_SHELL_CONSUMER_SLOTS_PX
1148
+ NAV_SECTION_GAP_PX measureNavHeight()
1149
+ NAV_FOCUS_GUTTER_PX
1150
+ ```
1151
+
1152
+ A grep of both consuming repos — `boostledger` and `boostscore`, the only two
1153
+ that depend on this package — found no importer of any of them outside
1154
+ `node_modules`. Between them they import `AppShell`, `AppSwitcher`, `WalletChip`,
1155
+ `PageHeader`, `Boost33Mark` and four types, and nothing from the nav budget. The
1156
+ Wallet's own duplicate of this arithmetic (`src/ui/shell/nav.ts`) was deleted
1157
+ whole when it migrated, so there is nothing left there to point at these either.
1158
+ If you are importing one, you are the first.
1159
+
1160
+ **Everything useful is still published, and takes the same values it took
1161
+ before.** `navContentHeight()`, `navBudgetForViewport()` and
1162
+ `checkNavHeightBudget()` remain, with `NavHeightBudget`, `NavRowCosts` and
1163
+ `NavSectionShape`. Each function applies the withdrawn constants as its defaults,
1164
+ so the answers do not change by a pixel:
1165
+
1166
+ - `navContentHeight(sections)` charges 33px a row, 30px a section header, 16px
1167
+ between sections and 8px for the nav's scroll gutter.
1168
+ - `navBudgetForViewport(900)` is still 629. `navBudgetForViewport(viewport,
1169
+ consumerSlotsPx)` still takes 185px of package furniture off, and defaults
1170
+ `consumerSlotsPx` to the 86px specimen allowance.
1171
+ - `checkNavHeightBudget(usedPx)` still defaults its budget to 629.
1172
+
1173
+ Overriding the two font-dependent costs is what `NavRowCosts` is for and it is
1174
+ untouched: `navContentHeight(sections, { itemPx: 35, sectionHeaderPx: 32 })`. So
1175
+ no caller needs to read a raw constant to use any of this — which is why
1176
+ withdrawing them costs nothing.
1177
+
1178
+ **`measureNavHeight(nav)` was `return nav.scrollHeight`.** Read `scrollHeight`
1179
+ yourself.
1180
+
1181
+ **Why they are withdrawn rather than left alone.** These are provisional numbers
1182
+ that were published as if settled, and 1.0 would freeze them:
1183
+
1184
+ - **They have already moved silently.** B33-763 is four of these constants
1185
+ changing value in a merged PR with no release note. Published, that is a
1186
+ consumer's arithmetic shifting under them with no notice; internal, it is a
1187
+ default inside a function whose behaviour change this file has to describe.
1188
+ - **The row costs are documented as unmeasured in the one direction that
1189
+ matters.** Half of each row cost is the line box the *consumer's* font draws —
1190
+ this package ships no face. Every family that could be measured on macOS draws
1191
+ shorter than Inter, so the model over-counts, the safe direction. `Segoe UI`
1192
+ sits in the fallback stack, is reported to draw a ~19px line box at 14px, and
1193
+ would make the model *under*-count — a nav that scrolls reported as a fit. It
1194
+ could not be measured because the pass ran on macOS, and B33-1009 is open for a
1195
+ human on Windows to take it. When that measurement lands, `NAV_ITEM_PX` and
1196
+ `NAV_SECTION_HEADER_PX` may move. As internal defaults they can; as nine frozen
1197
+ exports each would have wanted a major bump.
1198
+
1199
+ Pre-1.0 is when this is free, which is the whole reason it is happening now
1200
+ rather than after the Score migration.
1201
+
1202
+ **Nothing else moved.** No component, prop, class name, token or rendered pixel:
1203
+ all baselines come back at their previous numbers, and
1204
+ `npm run nav-height-budget-check` reports the same eight passes against the same
1205
+ measured render. The constants are still exported from
1206
+ `src/nav-height-budget.ts`, which is where this repo's own tests, the browser
1207
+ check and `Sidebar`'s development-mode warning read them from — none of those go
1208
+ through the package entry point.
1209
+ ### Added — four tokens the notification panel needed (B33-494)
1210
+
1211
+ The tokens ship in this release; the panel they were added for does not (see
1212
+ Unreleased above).
1213
+
1214
+ `--container-notification-panel` (360px), `--spacing-3_5` (14px), `--spacing-11`
1215
+ (44px) and `--spacing-0_25` (1px), each with its derivation in
1216
+ `data/paper-tokens.json`. **None of the four is a settled value**: they are read
1217
+ off the drawing and they move if the person at the fidelity gate moves them,
1218
+ which is exactly why they are tokens rather than literals. `--spacing-7` (28px)
1219
+ has a user again — the empty state's bell — after the compact page header stopped
1220
+ using it.
1221
+ ### Removed — `PRODUCT_SLUGS` and the `ProductSlug` type, replaced by `hasProductMark()` (B33-1269)
1222
+
1223
+ **A breaking change to the published surface, and the expected migration is
1224
+ nothing.** Two names are gone from the package entry point and one has arrived:
1225
+
1226
+ ```
1227
+ - PRODUCT_SLUGS the four slugs this package draws a mark for
1228
+ - ProductSlug the union derived from them
1229
+ + hasProductMark(slug: string): boolean does this package draw a mark for this slug?
1230
+ ```
1231
+
1232
+ **A grep of both consuming repos found no importer of either name.**
1233
+ `boostledger` and `boostscore` are the only two that depend on this package, and
1234
+ neither references `PRODUCT_SLUGS` or `ProductSlug` in any file outside
1235
+ `node_modules`. Every hit in either repo is prose — comments explaining the 0.4.0
1236
+ rekey, none of them code.
1237
+
1238
+ There was a real importer until 5 October, and what it was doing is worth reading,
1239
+ because it is the shape of the only call `hasProductMark` has to replace. The
1240
+ Wallet's copy of the B33-1043 slug workaround was tested by
1241
+ `boostledger/tests/product-artwork.test.ts`, which imported the list to check
1242
+ that the adapter only ever produced a key the package would draw:
1243
+
1244
+ ```ts
1245
+ const drawable: readonly string[] = PRODUCT_SLUGS;
1246
+ expect(drawable).toContain(artworkKeyFor(slug)); // then
1247
+ expect(hasProductMark(artworkKeyFor(slug))).toBe(true); // now
1248
+ ```
1249
+
1250
+ Its own comment said why it imported the list rather than copying it: *"so it
1251
+ tracks whatever version is installed instead of a list that went stale the day it
1252
+ was typed."* That is precisely the question `hasProductMark` answers, and the
1253
+ predicate is the better way to ask it — the widening cast on the first line is
1254
+ only there because the published type was narrower than the strings being tested.
1255
+ That file and the workaround it tested were both deleted when the Wallet took
1256
+ 0.4.0, as the 0.4.0 entry below instructed; Score's twin workaround went the same
1257
+ day and never imported the list at all. Two unmerged Wallet feature branches still
1258
+ carry the deleted test and will lose it when they take `main`. If you are
1259
+ importing either name today, you are the first.
1260
+
1261
+ **The migration, line by line:**
1262
+
1263
+ | On 0.4.0 | Now |
1264
+ | -- | -- |
1265
+ | `(PRODUCT_SLUGS as readonly string[]).includes(slug)` | `hasProductMark(slug)` |
1266
+ | `PRODUCT_SLUGS.includes(slug as ProductSlug)` | `hasProductMark(slug)` |
1267
+ | `[...PRODUCT_SLUGS]`, to list what can be drawn | `served.filter((p) => hasProductMark(p.slug))` — from the registry you were served, never from a list in your app |
1268
+ | `slug: ProductSlug` on a type of your own | `slug: string`, which is what every slug prop in this package already is |
1269
+ | `Record<ProductSlug, T>` | key it on the slugs your app itself handles, or `Record<string, T>` |
1270
+ | an exhaustive `switch (slug)` over the union | give it a `default:` — Identity can serve a slug your build has never heard of |
1271
+
1272
+ `hasProductMark` is the whole replacement, and it answers for the version you
1273
+ have installed rather than the one you compiled against.
1274
+
1275
+ **Why the union had to go before 1.0.** It was an artwork inventory wearing the
1276
+ clothes of a vocabulary. The four strings in `PRODUCT_SLUGS` are the slugs this
1277
+ package happens to hold a *drawing* for; which products exist is Identity's to
1278
+ say, and ADR-0003 settled that — the registry is served, and the package renders
1279
+ what it is served. Publishing the inventory as a `const` assertion handed a
1280
+ consumer a type to build on (`Record<ProductSlug, …>`, an exhaustive switch), and
1281
+ that made **adding a drawing a breaking change**: Identity serves five slugs
1282
+ today and this package draws four (`brokers`, B33-1158 above), so shipping the
1283
+ Brokers mark — the most ordinary thing this package can do — would have widened a
1284
+ published union and wanted a major bump. Frozen at 1.0 that is permanent. The
1285
+ asymmetry is what gives it away: **no exported prop was ever typed
1286
+ `ProductSlug`.** `ProductMark`'s `slug` and `AppSwitcherProduct.slug` are both
1287
+ `string`, on purpose, precisely so a registry running ahead of a mark release is
1288
+ not a type error. So the union's only real job was answering "does this slug have
1289
+ artwork", which is a question, not a type — and it is now a function.
1290
+
1291
+ That asymmetry has already cost twice, both recorded in this file. B33-1043
1292
+ shipped as "breaking, and it breaks quietly" and needed a workaround file in each
1293
+ of the two consumer repos; the Score copy's header said it outright — *"Two copies
1294
+ of a package workaround is the cost of the package being wrong."* B33-1158 then
1295
+ had to explain in a release note that the exported union was deliberately
1296
+ narrower than the registry it claimed to describe.
1297
+
1298
+ **What `hasProductMark` is for, and what it is not.** It is for a caller that
1299
+ needs to *know*: a consumer test asserting its own hardcoded fixtures still draw
1300
+ something, or a report of which served slugs have artwork yet. A `false` is not
1301
+ an error and needs no guard around it — `ProductMark` renders a slug it has no
1302
+ glyph for as the bare tile rather than throwing (ADR-0003's safe degrade), and in
1303
+ a switcher that row is still named, still linked and still clickable, with only
1304
+ the glyph missing. Do not use it to filter a served registry, to hide a product's
1305
+ row, or to substitute a drawing of your own: each of those turns a missing glyph
1306
+ into a missing product, and the registry running ahead of the artwork is the
1307
+ supported state, not a fault to route around.
1308
+
1309
+ **Nothing rendered differently.** No component, prop, class name, token or pixel
1310
+ moved: the four `productmark-*` baselines come back at the ratios they had on
1311
+ 0.4.0, and so does every other gated state. `PRODUCT_SLUGS` and `ProductSlug`
1312
+ still exist inside `src/components/ProductMark/ProductMark.tsx`, where `GLYPHS` is
1313
+ typed `Record<ProductSlug, ReactNode>` — so a slug added without a drawing still
1314
+ stops the build, which is the one part of this that must not go wrong quietly.
1315
+ That check is the reason the list stays a `const` assertion now that nobody
1316
+ outside the module can see it.
1317
+
1318
+ **What it buys.** The Brokers mark (B33-1264) is now an ordinary addition: lift
1319
+ the glyph, add the slug, have a human approve `productmark-brokers.png` at a
1320
+ fidelity gate, and `hasProductMark('brokers')` flips from `false` to `true`. No
1321
+ published type widens, and no consumer has anything to do but take the version.
1322
+
1323
+ ### Fixed — a `WalletChip` segment holding tokens can no longer render below a visible width (B33-590)
1324
+
1325
+ **This corrects a rendering defect present in every published version, 0.4.0
1326
+ included, and a seller who has bought tokens is looking at it now.** The bar
1327
+ sized each segment as a raw percentage of the cycle with no floor under it. Once
1328
+ purchased tokens sit beside the grant, the grant is a small share of the total,
1329
+ and a small share of the chip's 240px bar is a fraction of a pixel. Measured in
1330
+ Chromium against the package's own tokens:
1331
+
1332
+ ```
1333
+ before after
1334
+ 3,000 granted of a 1,000,000 cycle (0.3%) 0.719px 3.000px
1335
+ 200 granted beside 100,000 purchased (0.2%) 0.469px 3.000px
1336
+ 10 purchased on a spent 3,000 grant (0.33%) 0.797px 3.000px
1337
+ zero granted 0.000px 0.000px
1338
+ 3,400 granted of 4,600 (73.9%) 177.391px 177.391px
1339
+ ```
1340
+
1341
+ Every one of the first three painted nothing on screen while the ledger still
1342
+ held those tokens. On the 56px rail, where the bar is about 40px, the same three
1343
+ painted 0.109px, 0.078px and 0.125px.
1344
+
1345
+ A non-zero segment is now floored with `max()` against a new token,
1346
+ `--spacing-wallet-segment-min: 3px`, so the honest percentage still wins wherever
1347
+ the share clears the floor — the last row above is byte-identical, and so is
1348
+ every other width the chip has ever drawn above 1.25% of its bar.
1349
+
1350
+ **A zero segment still collapses to `0%` and draws nothing.** That is the half a
1351
+ `min-width` in the stylesheet could not have delivered: no CSS length can tell 0%
1352
+ from 0.3%, so a `min-width` would have grown a phantom 3px sliver on a wallet
1353
+ that holds none of that kind of token — the same lie pointing the other way. The
1354
+ floor is therefore composed in the component, where the amount is known.
1355
+
1356
+ **Both segments are floored, not only the grant.** Purchased burns second, so it
1357
+ drains to zero through every small number on the way; 10 purchased tokens beside
1358
+ a fully burned 3,000 grant is 0.33% of the cycle and is the seller's entire
1359
+ remaining balance.
1360
+
1361
+ **What this changes for you.** Nothing in the API: no export, prop, class name or
1362
+ markup moved, and the bar's accessible label is untouched. Two things to know
1363
+ before you take it:
1364
+
1365
+ - A segment that was painting under 3px now paints 3px, which by construction
1366
+ over-reports its share — at 240px, 3px claims 1.25%, and on the rail's ~40px bar
1367
+ it claims 7.5%. Taken on purpose: the alternative is a segment claiming 0%.
1368
+ - The component now writes `width: max(0.3%, var(--spacing-wallet-segment-min))`
1369
+ where it wrote `width: 0.3%`. A consumer test asserting the inline `style`
1370
+ string of `[data-testid="segment-granted"]` or `segment-purchased` needs
1371
+ updating; one asserting rendered geometry does not.
1372
+
1373
+ **It resolves through the stylesheet you already import.** The token is declared
1374
+ on `:root` in `./style.css` and in the Tailwind `@theme` entry `./theme.css`, and
1375
+ both ship it. An app rendering the chip without importing either has no bar to
1376
+ floor in the first place.
1377
+
1378
+ Also fixed in passing: a known balance of zero on a cycle that granted zero
1379
+ (`granted={0} purchased={0} grantCycle={0}` without `unknown`) divided by zero and
1380
+ emitted `width: NaN%`, which browsers drop. It emits `0%`.
1381
+
1382
+ **This is the second time the rendering has been reported, which is the part
1383
+ worth reading.** BoostScore raised it as B33-590 against its own wallet markup and
1384
+ fixed it there; B33-482 then replaced that markup with this component, which
1385
+ carried no floor, and the behaviour came straight back. Score's written record of
1386
+ the first fix is still on disk in that repo as dead code, at
1387
+ `components/app-shell/wallet-bar.ts` — it can be deleted once Score takes this
1388
+ version.
1389
+
1390
+ **No baseline moved, and the screenshot gate could never have caught this.** No
1391
+ gated state has a segment under the floor — the resting chip's are 73.9%/26.1%,
1392
+ the threshold chip's 11.0%/8.1%, the rail's 60.6%/11.8% — so `max()` resolves to
1393
+ the percentage in all 27 states and each comes back at its previous number. The
1394
+ regression is pinned by behaviour tests instead, in both directions: the two
1395
+ reported ratios, and the zero cases that a naive floor would have broken.
1396
+
1397
+ ### Fixed — `AppShell` renders the page body in a `<main>` landmark, so every route of every consumer has one (B33-1265 is the Wallet's half)
1398
+
1399
+ **Read this one before you upgrade even if you read nothing else here: if your app
1400
+ already wraps `children` in its own `<main>`, taking this version gives you two,
1401
+ and two main landmarks is worse than none.** Removing yours is part of the
1402
+ upgrade, not a follow-up.
1403
+
1404
+ `AppShell` put `children` into a plain `<div className={content}>`. It gives the
1405
+ sidebar a `<nav>`, names it after the product, and gives the page an `<h1>`
1406
+ through `PageHeader` — and then stopped, so **no route of any consumer had a main
1407
+ landmark unless that consumer remembered a wrapper of its own.** What the absence
1408
+ costs is the first move a screen-reader user makes on an unfamiliar screen: jump
1409
+ to the main region and start reading. Without one they land at the top of the
1410
+ document and walk the wordmark, the product switcher, every nav row, the wallet
1411
+ chip, the footer links, the sidebar toggle, the search box and the avatar before
1412
+ the first word of the page — on every navigation, because the shell is a layout
1413
+ and does not remount.
1414
+
1415
+ It is now `<main className={content}>`. The landmark is the page body alone, one
1416
+ level inside the product column: a `<main>` drawn around the column would satisfy
1417
+ any "has a landmark" check and buy the reader nothing, because jumping to it would
1418
+ land them back on the breadcrumb.
1419
+
1420
+ **What each consumer has to do.**
1421
+
1422
+ - **The Wallet has a wrapper and must drop it.**
1423
+ `boostledger/src/app/(wallet)/layout.tsx` wraps `children` in its own `<main>`
1424
+ — it patched this downstream first, and recorded in a comment there exactly what
1425
+ the absence cost. **Until that wrapper is removed the Wallet has two `<main>`
1426
+ elements on every route**, one from the package around one of its own. Drop it
1427
+ in the same commit as `npm install`, and keep `FixtureBanner` where it is
1428
+ relative to `children`. **B33-1265** carries this, including the prose in
1429
+ `boostledger/tests/wallet-a11y.test.tsx` that says the package does not supply
1430
+ the landmark. That test's assertions all still hold and should stay — its
1431
+ `expect(…querySelectorAll("main")).toHaveLength(1)` is what fails the moment the
1432
+ new package is installed with the wrapper still in place, so the upgrade cannot
1433
+ land quietly and leave two.
1434
+ - **BoostScore has no wrapper and gains the landmark outright.** It passes
1435
+ `children` straight through `components/app-shell/index.tsx`, and none of the
1436
+ seventeen pages that render that shell draws a `<main>` of its own — checked,
1437
+ not assumed. So all seventeen go from no main landmark to one with nothing to
1438
+ change, and the thing to avoid is adding a wrapper now that the shell owns it.
1439
+ - **Any other consumer: search your shell routes for `<main`.** If you find one
1440
+ inside `AppShell`, it is now the second.
1441
+
1442
+ **Nothing else moved.** No export, prop, class name or style changed: `.content`
1443
+ keeps its class and every declaration behind it, and `main` and `div` are both
1444
+ `display: block`, so one is substituted for the other and the box is identical.
1445
+ All 27 gated screenshot states came back at the same pixel counts and the same
1446
+ ratios they held before the change, and no baseline was touched.
1447
+
1448
+ **A consumer test asserting the element is what will notice.** A query for
1449
+ `div.content`, or a snapshot of the shell's markup, needs updating; a query for
1450
+ `getByRole('main')` starts passing where it used to fail.
1451
+
1452
+ ### Added — `LaunchLoader`, the screen a product shows while another one opens (B33-1389)
1453
+
1454
+ New exports, all additive. **Nothing to do on upgrade**: no existing component,
1455
+ token value or baseline changes, and a consumer that does not import these
1456
+ renders exactly what it rendered on 0.4.0.
1457
+
1458
+ - **`LaunchLoader`** — a full-screen launch screen: a dot-matrix loader, a
1459
+ progress bar and a caption sitting straight on a blurred mesh in the
1460
+ destination product's colours. The mesh drifts once — out from its
1461
+ composition and back into it — over 5 seconds, then holds. Presentational: it
1462
+ fetches nothing, knows no product and owns no timing. The page passes
1463
+ `caption` (the whole sentence — word order is the language's), `stops` (four
1464
+ colours, **darkest first**), `progress` (0–100, or `null` while
1465
+ indeterminate), and optionally `dotColor` (default white), `busy` (drives
1466
+ `aria-busy`, default `true`), `backdrop` and `className`. No `"use client"`:
1467
+ it renders, its mesh paints and its dots animate from server HTML.
1468
+ - **`launchBackdropCss(stops, selector = 'html')`** — the screen's ground colour
1469
+ as a raw CSS string, to inline in a `<style>` block in the first HTML chunk
1470
+ with no stylesheet request. **The mesh itself is markup, not CSS**: its shapes
1471
+ are styled inline, so it paints in the same chunk as the component on both
1472
+ sides of a hop, from the same props — that is what keeps the hop seamless.
1473
+ This string covers the instant before that markup parses. Also exported:
1474
+ `launchGround(stops)` and `launchSolid(stops)`. A stop that is not a plain CSS
1475
+ colour — anything carrying `;`, `{`, `}` or `<` — throws rather than being
1476
+ inlined.
1477
+ - **`DotMatrixLoader`** — the 3×3 pulsing grid on its own, pure CSS, 0 KB of
1478
+ animation runtime. `label` defaults to `"Loading"` (an indeterminate
1479
+ progressbar with that name); `label={null}` hides it from the accessibility
1480
+ tree entirely, which is what `LaunchLoader` passes.
1481
+ - **`LaunchGradientStops`**, `LaunchLoaderProps`, `DotMatrixLoaderProps` — types.
1482
+
1483
+ Three new motion tokens in `motion-tokens.css`, beside the Hub's set and named
1484
+ in its scheme: `--dur-dots` (1000ms, one cycle of the dot wave),
1485
+ `--ease-in-out-quint` (`cubic-bezier(0.83, 0, 0.17, 1)`) and `--dur-drift`
1486
+ (5000ms, the mesh's single drift — a WCAG 2.2.2 ceiling, not a taste). None of
1487
+ the three names exists in the Hub, Tailwind's default theme or the Wallet, so
1488
+ none collides.
1489
+
1490
+ **What to know before you render it.**
1491
+
1492
+ - **It is `position: fixed; inset: 0`.** It covers the viewport, so the hub can
1493
+ paint it over the page it is leaving. Stacking order against your own layers
1494
+ is yours — pass a `className` with a `z-index`. To frame it instead (a
1495
+ preview, a story), give an ancestor `contain: layout`.
1496
+ - **Accessibility is built in; do not add to it.** The caption is the single
1497
+ source of the words: it carries `aria-live="polite"` and names the progress
1498
+ bar, and the bar sits outside it so value ticks never re-announce the
1499
+ caption. Rewrite `caption` to announce a new phase. Set `busy={false}` the
1500
+ moment real content lands. Do not wrap it in another `role="status"` — that
1501
+ is the double announcement this markup exists to remove.
1502
+ - **Your stops carry the caption's contrast. Read this before choosing them.**
1503
+ There is no plate behind the words. The mesh is painted in six tones derived
1504
+ from `stops[0..2]` — `stops[3]` is not painted — and its dark channel runs
1505
+ through the centre, built from `stops[0]`, `stops[1]` and the mixes between
1506
+ them. So **`stops[0]` and `stops[1]` must each be at or under relative
1507
+ luminance 0.183**. Measured in Chromium at six viewports from 1920×1080 to
1508
+ 360×640, at rest and at every half second of the drift, on both provisional
1509
+ palettes: the caption (white at 80%) is **8.41:1** at worst, and white over
1510
+ the bar and dots **7.32:1** at worst. `prefers-contrast` (the boolean form)
1511
+ drops the mesh for the flat `stops[0]`, drops `backdrop`, and draws the dots
1512
+ and track in full white with no blend.
1513
+ - **The progress bar meets WCAG 1.4.11.** Its track blends into the mesh
1514
+ (soft-light, 75%); its fill is solid white outside that blend, at **11.95:1**
1515
+ against the track on Score and **10.93:1** on Wallet.
1516
+ - **The dots are quiet on purpose.** They blend soft-light at full strength and
1517
+ measure 2.37:1 against the mesh on Score, 2.36:1 on Wallet — soft-light's
1518
+ ceiling, which no opacity raises — and in Wallet's greens they nearly vanish.
1519
+ They are decorative and `aria-hidden`, so WCAG 1.4.11 does not apply, and the
1520
+ owner kept the blend on both palettes on 14 October 2026 with those figures in
1521
+ hand. Not a defect; do not file it as one.
1522
+ - **If you built a stand-in against a pre-release description:** `launchGradient`
1523
+ never shipped, and the seam no longer rests on a shared CSS string. Render
1524
+ `<LaunchLoader>` server-side on the arriving page so its inline-styled mesh is
1525
+ in the first HTML chunk on both sides, and inline `launchBackdropCss(stops)`
1526
+ for the instant before it parses.
1527
+ - **`caption` can be one line.** The first consumers pass a single static
1528
+ caption ("Opening Boost Score") for the whole launch. `aria-live` stays on it:
1529
+ inert while the text holds still, and what announces a change if you do
1530
+ rewrite it. If your stand-in removed `aria-live` with a comment saying why,
1531
+ delete the comment at swap time; do not reinstate the removal.
1532
+ - **The drift never breaks the seamless hop.** It starts and ends on the
1533
+ composition — measured at 1/255 at most — so a launch's first frame is the
1534
+ same on both sides and its last is the designed one. It moves the blurred
1535
+ mesh as one layer, a compositor transform, rather than re-blurring it each
1536
+ frame.
1537
+ - **Reduced motion** keeps the mesh and stops the drift, and leaves nine solid
1538
+ dots and a parked bar segment, all in CSS — there is no JavaScript preference
1539
+ read to stub in tests.
1540
+ - **The `backdrop` slot is optional and must stay so.** It is where an animated
1541
+ layer goes; the screen is complete without it. The drifting WebGL gradient is
1542
+ **deferred out of v1** by the owner's decision of 14 October 2026, not
1543
+ missing.
1544
+
1545
+ **Approved at its fidelity gate on 14 October 2026.** No product had shown a
1546
+ launch screen, so there was no Wallet referent: the owner judged it on the
1547
+ specimen, captured its four designed baselines himself, and approved its
1548
+ measurements into the spec ("LaunchLoader, recorded at its gate"). Those are
1549
+ now binding; a change to them is a human re-approving, and its entry here will
1550
+ say what moved. The product colours are yours to pass and
1551
+ are themselves still provisional upstream. **A filter blur across the whole
1552
+ screen has a GPU cost that has not been measured on a budget phone.**
1553
+
1554
+ ### Internal — no consumer-visible change
1555
+
1556
+ - `tests/wallet-chip-props.type-test.tsx` is new. B33-483's fix lives entirely in
1557
+ the type — the chip never rendered those zeroes either way — so there is nothing
1558
+ for a behaviour test to observe and `tsc` is the check. It is not a `.test.tsx`
1559
+ by vitest's reckoning: `vitest.config.mts` includes only `tests/**/*.test.ts(x)`
1560
+ while `tsconfig.json` includes all of `tests`, the same arrangement
1561
+ `wallet-chip-labels.type-test.ts` already uses. Every `@ts-expect-error` in it
1562
+ fails typecheck with TS2578 if the props loosen back.
1563
+ - `tests/wallet-chip.test.tsx` gains fifteen cases: the threshold reached through
1564
+ `burnedFraction` with no denominator at all, precedence in both directions
1565
+ against a `grantCycle` that disagrees, the 0.8 trip point pinned on both sides,
1566
+ the bar's fallback denominator, and two cases asserting that the unknown chip's
1567
+ markup is byte-identical with and without the figures it used to demand — which
1568
+ is what holds `walletchip-unknown` still. Nine existing cases dropped the zeroes
1569
+ they were passing behind `unknown`.
1570
+ - `specimen/routes/WalletChipSpecimen.tsx` renders the unknown row as a bare
1571
+ `<WalletChip unknown />` and the threshold row with both `burnedFraction` and
1572
+ `grantCycle`, so the new prop path appears in the specimen rather than only in
1573
+ the tests (CLAUDE.md, build-once components).
1574
+ - `tests/app-shell.test.tsx` gains five cases on the landmark: that the body is
1575
+ inside it, that there is exactly one, that the nav, the sidebar and the top
1576
+ bar's controls are outside it, and that both a header-less screen and the rail
1577
+ still get one.
1578
+ - Every specimen route frame is a `<div>` rather than a `<main>`. The stacked
1579
+ specimen page put twelve route-level landmarks on one page and nested two of
1580
+ them once the shell drew its own, so the only `<main>` elements there are now
1581
+ the five the shells draw — what a consumer actually gets. Two new cases in
1582
+ `tests/specimen-coverage.test.tsx` hold that: nothing nested, and no `<main>` on
1583
+ the page containing chrome. `scripts/sidebar-overflow-check.mts` found the
1584
+ Sidebar route by taking `main` first, which was that route only because it
1585
+ renders first; it now reads a named `data-specimen-route="sidebar"` handle.
1586
+ `sectionKey` in the coverage test reads the section's parent rather than the
1587
+ nearest `<main>` for the same reason.
1588
+ - `tests/product-mark.test.tsx` gains a `hasProductMark` block held against the
1589
+ component's rendering rather than against the list it is implemented from: a
1590
+ `true` must mean a glyph draws, a `false` must mean the bare tile draws, and
1591
+ `toString` / `constructor` are pinned false because `GLYPHS` is a plain object
1592
+ literal and a predicate written as a property lookup would report both as
1593
+ artworked. The Identity pin from B33-1043 is untouched and now asks the same
1594
+ question through `hasProductMark` as well as through the internal list; it
1595
+ reads the list by deep import, as every test here reads `src/`.
1596
+ - `tests/product-mark-surface.type-test.ts` is new, in the shape of
1597
+ `wallet-chip-labels.type-test.ts`: a `@ts-expect-error` pin that fails
1598
+ `npm run typecheck` if either name returns to `src/index.ts`. The runtime test
1599
+ can only see the value, and a type cannot be absent from a runtime namespace —
1600
+ re-exporting `ProductSlug` is one line and would otherwise pass everything.
1601
+ - `specimen/routes/ProductMarkSpecimen.tsx` reads `PRODUCT_SLUGS` from the
1602
+ component module instead of the barrel. Same four frames, same testids, same
1603
+ baselines.
1604
+ - `tests/wallet-chip.test.tsx` gains seven cases around the segment floor. Four
1605
+ fail against the unfixed component and four fail against an unconditional
1606
+ `max()`, which is the shape a floor written as a bare `min-width` would have
1607
+ had. The existing `sizes segments against everything the cycle started with`
1608
+ case is updated for the new spelling of the same percentage.
1609
+ - `fillTemplate` moves out of `WalletChip/labels.ts` into `src/fill-template.ts`,
1610
+ flat at the root beside `anchored-overlay.ts` and unexported from the package.
1611
+ Byte-identical behaviour and no API change — the point is that the third labels
1612
+ module reuses it rather than copying it, which is how two of the five lines
1613
+ would have drifted the day one of them was fixed (B33-1007's shape, one level
1614
+ down).
1615
+ - The specimen gains a `NotificationCenter` route with both states, rendered last
1616
+ in `specimen/main.tsx` so no gated state moves down the page and re-rasterizes
1617
+ (B33-1012). Neither state is gated and both are recorded in `SPECIMEN_ONLY`
1618
+ with the reason: their baselines are *designed* ones, and only the person at
1619
+ the fidelity gate may capture them. `npm run screenshot-diff` reports all 27
1620
+ existing states unchanged, `appswitcher-open` included at 0.000%.
1621
+ - Both `AppShell` specimen routes compose the page header the way both consumers
1622
+ do — inside `children` — now that there is no other way (B33-1267). Only the
1623
+ stress route ever passed the prop, and it is ungated in a fixed-height stage,
1624
+ so nothing on the page moved: `npm run screenshot-diff` reports all 27 gated
1625
+ states at the ratios they held before.
1626
+ - The `SKIPPED_BASELINES` reasons for `appshell-expanded` and `appshell-rail` are
1627
+ rewritten for the same change, and the `SPECIMEN_ONLY` captions on the Wallet
1628
+ reproduction corrected from "diffed against appshell-expanded.png" — which
1629
+ stated an intent, not the present state — to "reproduces appshell-expanded.png,
1630
+ not yet diffed against it". B33-1267 predicted the two baselines had been
1631
+ captured through the prop path and would stop being reproducible; they had not,
1632
+ and they are. Both PNGs are the Wallet's overview, which renders no title at
1633
+ all, and the route carrying their testids never passed `pageHeader`. The
1634
+ re-capture they wait on is still a human's, and it now pins a shell whose page
1635
+ titles live in `children`. No baseline was written.
1636
+ - `tests/specimen-coverage.test.tsx`'s "every main on the page is a shell page
1637
+ body" guard identifies a route frame by the `<section>` it contains rather than
1638
+ by an `<h1>`. The `<h1>` was a proxy for "this is a frame, not a page body", and
1639
+ it stopped discriminating the moment a page's own header legitimately lives in
1640
+ `children` (B33-1267). The `aside` and `nav` halves are unchanged.
1641
+ - The specimen gains two ungated routes for the unarmed shapes B33-1270
1642
+ introduces — `WalletChipUnarmedSpecimen` (resting and threshold with no
1643
+ `onTopUp`) and `AppSwitcherUnarmedSpecimen` (the open panel with no
1644
+ `account.onSelect`) — rendered last in `specimen/main.tsx` so no gated state
1645
+ moves down the page (B33-1012). All three sections are recorded in
1646
+ `SPECIMEN_ONLY` with the reason: there is no captured referent for either shape,
1647
+ so a first picture of them is a *designed* baseline and a human's to take.
1648
+ - The gated states on `WalletChipSpecimen` and `AppSwitcherSpecimen` now pass
1649
+ handlers. That is what keeps their baselines still: the chip's button and the
1650
+ switcher's account-row chevron exist only where a handler arms them, and the
1651
+ baselines are photographs of the Wallet's shell where both were live. The
1652
+ "Stage A … no handlers" line at the top of each route is corrected, because it
1653
+ is no longer what the route does.
1654
+ `scripts/wallet-name-overflow-check.mts` depended on this from the other side:
1655
+ it reads `chip.querySelector('button')` on three of those states to measure a
1656
+ translated wallet name against the Top-up label, and throws on a chip with no
1657
+ button. All 30 of its combinations still pass.
1658
+ - `tests/wallet-chip.test.tsx` gains six cases and `tests/app-switcher.test.tsx`
1659
+ six: the control absent with no handler, absent for an explicitly `undefined`
1660
+ one, no `disabled` or `aria-disabled` left in its place, everything around it
1661
+ still rendered, and — in both files — the armed shape asserted in the same
1662
+ breath, so the gate cannot be satisfied by never drawing the control at all.
1663
+ The switcher's two real-Tab traversal walks now render the armed account row,
1664
+ because ADR-0010's "every row is its own tab stop" is a claim about rows that
1665
+ are real links and buttons; the unarmed row's absence from the tab order is its
1666
+ own case.
1667
+ - `tests/public-api.test.ts` and `tests/public-api.type-test.ts` are new, and they
1668
+ pin the published export surface — 24 value names with their runtime `typeof`,
1669
+ 37 type names — against two explicit sorted lists written in the test file
1670
+ (B33-1280). Nothing imported `src/index.ts` before: every other test imports a
1671
+ deep path and only the specimen routes import the barrel, so every name withdrawn
1672
+ or added above in this section passed every gate in the repo on its way through.
1673
+ Not a vitest snapshot, deliberately — a `.snap` is updated by a flag and nobody
1674
+ reads one in a diff, and the whole value of the lists is that they are visible in
1675
+ the pull request that changes them. The value half is read from the runtime module
1676
+ namespace and the type half from the barrel's own export specifiers, because a
1677
+ type-level check can prove a pinned name is still exported but can never report
1678
+ one that *appeared*; the two meet where the parser's value names are asserted
1679
+ equal to the namespace's keys. **What it changes for everyone else is the failure
1680
+ message**: a surface change now fails naming what appeared or vanished, that a
1681
+ removal is breaking, and that a deliberate change means updating the list *and*
1682
+ writing an entry here. No consumer-visible change — `files` is `["dist"]` and
1683
+ `dist/` is built from `src/index.ts` alone, so no test reaches a consumer.
1684
+ - `specimen/` gains a `LaunchLoader` route — Score's palette determinate and
1685
+ indeterminate, Wallet's greens, and the dot matrix alone — rendered last so
1686
+ it moves no gated state. Every row is recorded in `SPECIMEN_ONLY`. **No
1687
+ baseline was captured**: the first one is a human's to take at the fidelity
1688
+ gate.
1689
+ - `scripts/launch-contrast-check.mts` (`npm run launch-contrast-check`, a new
1690
+ step in the screenshot job) measures the brightest composited pixel behind
1691
+ the caption, bar and dots for both provisional palettes at six viewports and
1692
+ at every 500ms of the drift; judges the 80% caption by the mix it composites
1693
+ to; fails if the drift at rest differs from the composition by more than
1694
+ 4/255; and asserts the bar's fill against its track. Its arithmetic is
1695
+ `scripts/launch-contrast.ts`.
1696
+ - `tests/launch-loader.test.tsx` covers the ARIA wiring, the screen with no
1697
+ backdrop layer, the inline-styled mesh and its single blur, the
1698
+ reduced-motion and `prefers-contrast` CSS paths, the drift's bounds, the bar
1699
+ split, the content box staying out of the stacking order, the stops contract
1700
+ against both provisional palettes, the contrast arithmetic, and the inlinable
1701
+ CSS's refusal of unsafe stops. `tests/guards.test.ts` holds both new
1702
+ components to the server-safe hook rule.
1703
+
1704
+ ## 0.4.1 — 2026-10-14
1705
+
1706
+ ### Changed — the Hub's motion standard: new motion tokens, shared button, spinner and skeleton classes, a reduced-motion backstop, and a top bar shadow on scroll (B33-1379)
1707
+
1708
+ **Additive, with two things to check before you upgrade.** Cut from 0.4.0 with
1709
+ only this change; the unreleased breaking work on main is not in it. The old
1710
+ token names keep working for this release.
1711
+
1712
+ **New tokens on `:root`**, from the Hub's `globals.css` and at its values
1713
+ (`src/motion-tokens.css`, `src/elevation-tokens.css`):
1714
+
1715
+ | | |
1716
+ | --- | --- |
1717
+ | Durations | `--dur-instant` 80ms, `--dur-fast` 150ms, `--dur-base` 220ms, `--dur-slow` 320ms, `--dur-bold` 700ms, `--dur-hero` 900ms, `--dur-spin` 700ms, `--dur-pulse` 1600ms |
1718
+ | Easings | `--ease-out`, `--ease-in`, `--ease-spring` |
1719
+ | Distances | `--lift-sm` -1px, `--lift-md` -2px, `--rise` 8px, `--stagger` 40ms |
1720
+ | Shadows | `--shadow-lift`, `--shadow-scrolled` |
1721
+
1722
+ **Deprecated: `--duration-fast` and `--easing-standard`.** They are now aliases,
1723
+ `var(--dur-fast)` and `var(--ease-out)`, and are removed in the release after
1724
+ this one. They carry the new values, not 120ms and `cubic-bezier(0.2, 0, 0, 1)`:
1725
+ the decision was to adopt the Hub's motion, not keep both. No product reads
1726
+ either today. Every component in the package now reads the new names, so the
1727
+ nav item, the switcher chevron and the wallet chip move at 150ms on the Hub's
1728
+ ease-out instead of 120ms on the old curve. Under reduced motion nothing
1729
+ changes.
1730
+
1731
+ **New classes in `style.css`**, which you put on your own elements. Each owns
1732
+ colour, border and state (hover, press, disabled); you keep height, padding,
1733
+ radius and type, and `.btn-quiet`'s border, which it leaves to you as the Hub
1734
+ does:
1735
+
1736
+ - `.btn-primary`, `.btn-quiet`, `.btn-danger`
1737
+ - `.spinner`, with `.spinner--sm` and `.spinner--primary` for a light ground
1738
+ - `.sk` (a pulsing placeholder), `.sk-group` (holds a skeleton back 150ms so a
1739
+ fast read never shows one), `.reveal` (fades in the content that replaces it)
1740
+
1741
+ The Hub can delete its own copies of these: the names and behaviour match.
1742
+
1743
+ **The top bar casts a soft shadow once the shell's content has scrolled**,
1744
+ fading in over the first 40px. It is CSS only, with no prop and no markup
1745
+ change, and only inside `AppShell`; a `TopBar` you render on its own looks as
1746
+ before. It needs scroll-driven animations, `timeline-scope` and style queries;
1747
+ it was checked in Chromium, and a browser missing any of the three draws no
1748
+ shadow, as before.
1749
+
1750
+ **Check your app: the reduced-motion backstop is app-wide.** Under
1751
+ `prefers-reduced-motion: reduce`, `style.css` now makes every animation in
1752
+ your app 0.01ms and plays it once, and every transition 0s, with `!important`,
1753
+ not only the package's. (The Hub writes 0.01ms for transitions; 0s is
1754
+ deliberate, see ADR-0014.) That is the standard's rule. **The Wallet should look at this
1755
+ first**: its own reduced-motion design keeps opacity fades at half duration
1756
+ ("no travel, not no feedback"), and the backstop removes them. Only a layered
1757
+ `!important` rule of yours can override it.
1758
+
1759
+ **Check your app if you declare `--ease-out` yourself, or use Tailwind's
1760
+ `ease-out`.** Tailwind v4's default theme declares `--ease-out:
1761
+ cubic-bezier(0, 0, 0.2, 1)`, and the Wallet's `src/ui/tokens/motion.css`
1762
+ declares `--ease-out: cubic-bezier(0.2, 0, 0, 1)`. Both sit in a cascade layer;
1763
+ the package's declaration does not, so it wins in either import order and those
1764
+ uses switch to the Hub's `cubic-bezier(0.22, 1, 0.36, 1)`. In the Wallet that
1765
+ is `Sheet`'s scrim and panel, and the comment above its
1766
+ `@import '@boost33/ui/style.css'` saying there is no collision is no longer
1767
+ true. `--ease-in` is declared with the same value in all three places, so it
1768
+ changes nothing.
1769
+
1770
+ **To adopt it:**
1771
+
1772
+ 1. `npm install @boost33/ui@<version>`. No code change is needed to keep
1773
+ working.
1774
+ 2. Check the two items above against your app.
1775
+ 3. Replace any `var(--duration-fast)` / `var(--easing-standard)` of your own
1776
+ with `var(--dur-fast)` / `var(--ease-out)` before the aliases go.
1777
+ 4. Optionally, use the new classes and tokens instead of your own copies, and
1778
+ delete any local declaration of `--dur-*`, `--ease-*`, `--lift-*`,
1779
+ `--rise`, `--stagger` or `--shadow-lift`. A copy of a package token is a
1780
+ collision (ADR-0011).
1781
+
1782
+ See `docs/adr/0014-the-package-adopts-the-hubs-motion-standard.md`.
1783
+
1784
+ Repo-only: the specimen has a new, ungated "Motion classes" route at the
1785
+ bottom, recorded in `SPECIMEN_ONLY`, and `tests/motion.test.ts` builds the
1786
+ package and checks the classes reach `style.css`.
1787
+
1788
+ ## 0.4.0 — 2026-10-02
1789
+
1790
+ ### Added — `AppSwitcherProduct.live`, so an unbuilt product stops reading as an unbought one (B33-561)
1791
+
1792
+ `AppSwitcherProduct` takes an optional `live?: boolean`, defaulting to `true`.
1793
+ `false` dims the mark and puts a **"Soon"** tag in the row's tag slot, in place
1794
+ of "Not on plan". `AppSwitcherLabels` gains `soon`, default `"Soon"`, so the word
1795
+ translates like every other string the switcher renders.
1796
+
1797
+ **Nothing to do on upgrade.** `live` is optional and defaults to true, so a call
1798
+ site that does not mention it renders exactly what it rendered on 0.3.1 — the
1799
+ three `appswitcher-*` baselines come back at 0.000% against this version. This
1800
+ entry is here because it closes a defect you may be looking at, not because it
1801
+ asks anything of you.
1802
+
1803
+ **What it fixes.** Identity has served `live` on every registry row since
1804
+ `GET /api/v1/products` existed, and this interface had nowhere to put it, so both
1805
+ consumers read the field and dropped it — each with a comment in its registry
1806
+ client saying so and calling it worth a package ticket. The visible cost: a
1807
+ product that has not shipped rendered identically to one the account had not
1808
+ bought. A client looking at Boost Voice saw "Not on plan" against a product
1809
+ nobody is on, and the row invited them to go and ask about buying something with
1810
+ no code behind it. That confusion is the reason Identity carries two fields
1811
+ rather than one (`live` in `boost33-identity/src/lib/productRegistry.ts`).
1812
+
1813
+ **Why one tag slot and not two.** A product that has not shipped is held by
1814
+ nobody, so Identity serves every `live: false` row as `entitled: false` as well,
1815
+ and both conditions are true on the same row. The unbuilt word wins the slot:
1816
+ rendering both would stack two pills where there is room for one, and the second
1817
+ would answer a question the first has closed. `data-live="false"` is on the row
1818
+ alongside the existing `data-entitled="false"` if you need to style them apart.
1819
+
1820
+ **Still a rendering hint, never an authorization decision.** Same rule as
1821
+ `entitled` (ADR-0010, "Not entitled is not disabled"; Identity contract §1.5). An
1822
+ unbuilt product's row stays an ordinary focusable link, because Identity serves a
1823
+ `url` for every slug whether or not anything answers on it — `?app=<slug>` has to
1824
+ resolve for a campaign link to grant anything on sign-up. Do not branch on `live`
1825
+ to decide access.
1826
+
1827
+ **To wire it:** stop dropping the field. `switcherProducts` in both consumers
1828
+ maps the registry row into `AppSwitcherProduct` and is the single place it goes
1829
+ missing.
1830
+
1831
+ ### Known gap — Identity serves a fifth product and this package has no mark for it (B33-1158)
1832
+
1833
+ **No action, and nothing regressed. Read this if a switcher row draws a blank
1834
+ tile under the name "Boost Brokers".**
1835
+
1836
+ Identity's registry now serves five slugs: the four in `PRODUCT_SLUGS` plus
1837
+ `brokers`. This package has no `brokers` glyph, so `ProductMark` draws the bare
1838
+ tile through ADR-0003's safe degrade — the row is still named, still linked and
1839
+ still clickable, and only the artwork is missing. That is the same symptom
1840
+ B33-1043 describes above, with a different and much narrower cause: there, three
1841
+ of four rows were blank because the package had the slugs wrong. Here, one row is
1842
+ blank because the mark does not exist yet.
1843
+
1844
+ **`PRODUCT_SLUGS` is now deliberately narrower than Identity's registry**, and
1845
+ its doc comment says so rather than claiming to be the whole list. The exported
1846
+ `ProductSlug` union is unchanged from 0.3.1 — still the same four strings — so
1847
+ this is additive documentation, not a type change.
1848
+
1849
+ **Why the mark was not simply drawn.** The four existing glyphs were lifted
1850
+ verbatim out of `boostledger/src/ui/primitives/icons.tsx`, which types its own
1851
+ `GLYPHS` over exactly four display names and holds no Brokers mark. There is none
1852
+ in `boost33-brand-hub` either, and none here; the hub renders the product as a
1853
+ text quick-link with no mark at all. So there is nothing to transcribe, and
1854
+ drawing one from the name would be inventing the reference rather than matching
1855
+ it — which is the specific failure PR #4 already committed on all four marks, and
1856
+ which is worse for a fifth, because a human capturing the first baseline at the
1857
+ fidelity gate would make the invention the referent and it would look settled
1858
+ afterwards either way (`CLAUDE.md`).
1859
+
1860
+ **It is the mildest version of this problem.** Identity serves `brokers` as
1861
+ `live: false`, so with the `live` field above wired the row reads "Soon" beside
1862
+ its bare tile — a product that has not arrived, rather than a drawing that failed
1863
+ to load. That stops being true the day Brokers ships, which is the deadline on
1864
+ closing it.
1865
+
1866
+ **To close it:** get the mark drawn where the other four live, lift it here
1867
+ verbatim, add `brokers` to `PRODUCT_SLUGS`, and have a human approve
1868
+ `productmark-brokers.png` at a gate. `GLYPHS` is typed against `PRODUCT_SLUGS`,
1869
+ so adding the slug without the artwork stops the build rather than shipping a
1870
+ silent blank.
1871
+
1872
+ ### Not fixed here — a switcher showing only the product you are in is not this package
1873
+
1874
+ **Stated because it is the obvious thing to expect of a version that touches the
1875
+ switcher, and it would be wrong.** If your panel lists one row, naming the
1876
+ product you are already standing in, nothing in any version of `@boost33/ui`
1877
+ causes that and nothing in this one fixes it. `AppSwitcher` renders every row in
1878
+ the `products` array it is handed, and filters none.
1879
+
1880
+ What produces that symptom is an empty `products` array and the fallback each
1881
+ consumer applies to one: `switcherProducts` in both apps returns a single
1882
+ synthetic row for the current product when the registry comes back empty, which
1883
+ is the right degrade and is doing its job. The empty array is upstream of it. As
1884
+ of 2 October 2026 both consumers call `GET /api/v1/products` server-to-server
1885
+ with a service key and no `b33_sid` cookie, and that endpoint is a **browser**
1886
+ endpoint — Identity contract §4, and `boost33-identity`'s own test
1887
+ `is 401 for a caller with no session, never a registry`. So the call 401s on
1888
+ every render, for every account, and the fallback is all anybody ever sees. The
1889
+ fix is in the consumers' registry clients, not here.
1890
+
1891
+ ### Changed — `PRODUCT_SLUGS` now holds the slugs Identity actually serves (B33-1043)
1892
+
1893
+ **Breaking, and it breaks quietly.**
1894
+ `PRODUCT_SLUGS` was `['wallet', 'score', 'voice', 'outbound']`. It is now
1895
+ `['wallet', 'boostscore', 'boostvoice', 'boostoutbound']`, which is what
1896
+ `GET /api/v1/products` returns — `REGISTRY_SLUGS` in `boost33-identity`,
1897
+ `src/lib/productRegistry.ts`. `GLYPHS` is rekeyed to match, so the exported
1898
+ `ProductSlug` union changes with it.
1899
+
1900
+ Three of the four were wrong. The package dropped Identity's `boost` prefix on
1901
+ every product and kept only `wallet` by coincidence:
1902
+
1903
+ | Identity serves | 0.3.1 expected | Now |
1904
+ | -- | -- | -- |
1905
+ | `boostscore` | `score` | `boostscore` |
1906
+ | `boostvoice` | `voice` | `boostvoice` |
1907
+ | `boostoutbound` | `outbound` | `boostoutbound` |
1908
+ | `wallet` | `wallet` | `wallet` |
1909
+
1910
+ **What a consumer on 0.3.1 is seeing right now.** `ProductMark` draws the bare
1911
+ tile for a slug it has no glyph for rather than throwing (ADR-0003's safe
1912
+ degrade, and right in isolation). So an app that pipes the served registry
1913
+ straight into `AppSwitcher` — which is exactly what B33-472 and B33-479 intend —
1914
+ renders three of its four rows as blank tiles, with no exception, no console
1915
+ warning and no failing test. The rows are still named, still linked and still
1916
+ clickable; only the artwork is missing. If your switcher looks like that, this
1917
+ is why.
1918
+
1919
+ **What breaks on the upgrade.** Anything that hand-writes `score`, `voice` or
1920
+ `outbound` — a `slug` prop, a `currentSlug`, a products array built in the app
1921
+ rather than fetched. Those are correct on 0.3.1 and draw a blank tile on this
1922
+ version, and **the compiler will not tell you**: `ProductMark`'s `slug` prop and
1923
+ `AppSwitcherProduct.slug` are typed `string` rather than `ProductSlug`, on
1924
+ purpose, so that a registry running ahead of a mark release is not a type error.
1925
+ A `typecheck` that stays green is not evidence here. Grep your app for the three
1926
+ old strings before upgrading — the only case that does fail loudly is code that
1927
+ imports the `ProductSlug` type and annotates with it. A products array that
1928
+ comes from Identity needs no change and starts drawing its marks.
1929
+
1930
+ **The Wallet has a bridge to delete, not to update.** `boostledger` is on 0.3.1
1931
+ and carries `src/lib/product-artwork.ts`, which maps Identity's slug to the
1932
+ artwork key the old package expected (`boostscore` → `score`) at the single point
1933
+ where the prop is filled. Taking this version without deleting that file
1934
+ reinstates exactly the blank tiles it was written to prevent, because the mapping
1935
+ now translates a slug the package draws into one it does not. Its own test
1936
+ `only ever produces keys the installed package has artwork for` reads
1937
+ `PRODUCT_SLUGS` from the installed package at runtime, so it goes red on the
1938
+ upgrade and says so — the fix there is to delete the file, drop the
1939
+ `artworkKeyFor` call in `src/app/(wallet)/layout.tsx`, and delete the test with
1940
+ it. That is B33-481's work.
1941
+
1942
+ **Identity is not the side that moves.** Its slugs match the `?app=` keys that
1943
+ live campaign URLs already carry, so renaming one stops those links granting the
1944
+ product on sign-up. ADR-0003 in this repo records the shorter slugs as considered
1945
+ and rejected for that reason — this package shipped the rejected option and
1946
+ described it in a comment as the accepted one.
1947
+
1948
+ **Why it survived review.** `PRODUCT_SLUGS`'s doc comment asserted the list was
1949
+ Identity's, and nothing in either repository compared the two. A reader checking
1950
+ found the claim and stopped. The comment now names its source file and says it is
1951
+ a transcription, and `tests/product-mark.test.tsx` pins the list against a second
1952
+ transcription of the same source so the two repositories cannot part company in
1953
+ silence again.
1954
+
1955
+ ### Internal — no consumer-visible change
1956
+
1957
+ - The switcher specimen gains an ungated `open — unbuilt products` row showing
1958
+ the "Soon" tag, and the `ProductMark` specimen's degrade row now draws the real
1959
+ `brokers` slug instead of the stand-in `nonesuch` it used to. Both are recorded
1960
+ in `SPECIMEN_ONLY` in `scripts/screenshot-states.ts`: the Wallet never rendered
1961
+ a "Soon" tag — it had no registry to read `live` from — so there is no captured
1962
+ referent for it and the first baseline is a human's to take at a fidelity gate.
1963
+ **No baseline was captured or regenerated.** All 27 gated states diff at
1964
+ exactly the ratios they did on 0.3.1, `appswitcher-open` among them at 0.000%.
1965
+ - `tests/product-mark.test.tsx`'s Identity pin is re-transcribed against the
1966
+ five-slug registry and now pins the gap as well as the overlap: `brokers` is
1967
+ recorded as a served slug this package deliberately draws nothing for, and
1968
+ separate cases assert it still draws the bare tile and is still a slug Identity
1969
+ really serves. A sixth product, a rename, or artwork arriving for this one each
1970
+ fail a test rather than passing quietly.
1971
+ - The four `productmark-*` baselines are renamed to follow the slugs
1972
+ (`productmark-score.png` → `productmark-boostscore.png`, and the same for voice
1973
+ and outbound), with `scripts/screenshot-states.ts` and `scripts/border-proof.ts`
1974
+ following. **The files were renamed, never re-captured** — the PNG bytes are
1975
+ untouched, and all four still diff at 0.000% against the component. The
1976
+ switcher specimen's four rows carry the corrected slugs; `appswitcher-open` is
1977
+ also 0.000%, which is the rename proving it moved no pixels anywhere.
1978
+
1979
+ ## 0.3.1 — 2026-09-23
1980
+
1981
+ ### Fixed — the `AppSwitcher` panel is drawn at the Wallet's measurements (B33-910)
1982
+
1983
+ **This corrects a rendering defect that shipped in 0.3.0.** A consumer on 0.3.0
1984
+ has it, and it is visible. B33-479 corrected the marks and the type size before
1985
+ the component landed; the box around them was not corrected with them. In the
1986
+ open panel the product rows are 40px tall where the Wallet's are 36px on a 38px
1987
+ pitch, the row names are bold where the Wallet's are regular, the Hub's glyph
1988
+ and the account avatar are 28px where they are 20px and 24px, the rows are
1989
+ padded 10px and 12px where the Wallet pads them 8px, and the panel is 240×318
1990
+ where the Wallet's is 264×293. The visible consequence is that `Boost Outbound`
1991
+ truncates to `Boost O…` against its `Not on plan` tag — a product name a
1992
+ consumer supplies, cut off in a menu whose job is to name the products.
1993
+
1994
+ Nothing about the fix is new information. The three `appswitcher-*` baselines
1995
+ are photographs of the Wallet's own switcher from 9 September 2026, and they
1996
+ were in the diff and passing the whole time. A 5% pixel-ratio threshold is loose
1997
+ enough that a panel 25px too tall, with every row inside it in the wrong place,
1998
+ still scores 4.835% — and the 24px the panel was too narrow moved no countable
1999
+ pixel at all, because the edge it moved is drawn in `--color-border` against
2000
+ `--color-surface`, which is under pixelmatch's colour cutoff at this threshold
2001
+ (`scripts/screenshot-diff.mts`, `PIXELMATCH_COLOR_THRESHOLD`). Against those
2002
+ baselines:
2003
+
2004
+ ```
2005
+ before after
2006
+ appswitcher-closed 768/115200 0.667% 3/115200 0.003%
2007
+ appswitcher-open 20052/414720 4.835% 1031/414720 0.249%
2008
+ appswitcher-rail 1312/36608 3.584% 0/36608 0.000%
2009
+ ```
2010
+
2011
+ No baseline was touched: `tests/baselines/` is unchanged, and the other 24
2012
+ states come back bit-identical to their previous numbers.
2013
+
2014
+ **Where the numbers came from.** The spec document *The shared app shell*
2015
+ settles the trigger and only the trigger — a `--color-border-strong` hairline,
2016
+ 9px block and 8px inline padding, `--radius-lg`, a 2px gap beneath it before the
2017
+ panel, and 14px nav-adjacent labels. Every one of those is unchanged here. Three
2018
+ things on the trigger that the spec does *not* settle were wrong and are
2019
+ corrected from `appswitcher-closed`: the label sits 8px from the mark rather
2020
+ than 10, it is semibold rather than bold, and the chevron is 12px rather than
2021
+ 16. That state goes from 768 differing pixels to 3.
2022
+
2023
+ Everything below the trigger is silent in the spec too, so it is measured off
2024
+ the approved baselines rather than re-derived from Paper, which CLAUDE.md
2025
+ forbids for exactly the reason the nav radius was corrected twice in opposite
2026
+ directions. The measurements, and what each one is evidence of: rows 36px tall
2027
+ at a 38px pitch (the current row's fill is 36px and the four product marks sit
2028
+ 38px apart); 8px of block and inline row padding with 10px between mark and
2029
+ text (the mark's box starts 8px inside the row and the name's 10px after it);
2030
+ row names at 14px regular (`Boost Outbound` inks 105.5px — Inter's advance for
2031
+ it is 105.2px at 14/400 and 109.3px at 14/700); the Hub's second line at 12px
2032
+ (`All Boost33 products` inks 117.5px, against 117.6px at 12px); the account row
2033
+ one step down again at 13px over 11px, with 12px of inline padding and a 24px
2034
+ avatar; the `Not on plan` tag at 11px; the check at 16px and the trigger's
2035
+ chevron at 12px; and the panel 264px wide — the sidebar's full width, keeping
2036
+ the trigger's left edge and overhanging the sidebar's right, which is what the
2037
+ baseline shows, its right border landing flush against the frame.
2038
+
2039
+ **The rail residual 0.3.0 recorded is closed, rather than traded away.** That
2040
+ entry noted the rail's mark sitting 8px below where the baseline puts it,
2041
+ because the 8px of block padding that dropped it was also what kept the
2042
+ collapsed trigger a 24px target. The button's box is invisible in that state —
2043
+ no border, no fill — so the two are independent: the mark is now flush with the
2044
+ switcher's top edge where the Wallet draws it, and a `min-height` keeps the
2045
+ target at 24px below it. `appswitcher-rail` is exact.
2046
+
2047
+ > One residual, recorded rather than hidden. `appswitcher-open` lands at 1031
2048
+ > differing pixels rather than 0, and they are not spread over the panel: about
2049
+ > half are the Hub row's 2×2 glyph, which fills 17px of its 20px box where the
2050
+ > Wallet's fills 18px, and most of the rest are the current row's check and the
2051
+ > account row's chevron, each sitting about 1px off. Those are the proportions
2052
+ > of three drawings rather than measurements of the layout, and redrawing a
2053
+ > glyph to close a pixel is the kind of re-derivation this repo has been burned
2054
+ > by. Left as it is, and named here so the next person reading that number knows
2055
+ > it was looked at.
2056
+
2057
+ **One new published token, `--text-3xs: 9px`**, the size of the initials in the
2058
+ account row's avatar, which were drawn at `--text-xs`. No existing token value
2059
+ changed. Everything else here is `AppSwitcher.module.css`, so it reaches a
2060
+ consumer through the compiled `./style.css` on the version bump: no export
2061
+ changed, no prop changed, and no markup moved.
2062
+
2063
+ ### Internal — no consumer-visible change
2064
+
2065
+ - The switcher specimen lists its four products in Identity's registry order with
2066
+ the right one locked (Boost Score, Boost Outbound, Boost Voice, Wallet; Voice is
2067
+ the unbuilt product an account cannot hold). It previously had Voice and Outbound
2068
+ the other way round, so the specimen demonstrated a live product as locked and an
2069
+ unbuilt one as available. `appswitcher-open`'s baseline was re-approved by Jamal
2070
+ for the corrected rows. Specimen and baselines only -- nothing in `dist` changed,
2071
+ and the product list a consumer passes is Identity's, never this package's.
2072
+
2073
+ ## 0.3.0 — 2026-09-23
2074
+
2075
+ ### Fixed — a translated wallet name no longer paints under the Top up button (B33-913)
2076
+
2077
+ B33-849 made `WalletChip`'s header name consumer-supplied. B33-881 then guarded
2078
+ it with `min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space:
2079
+ nowrap`, which stopped a multi-word name wrapping and reflowing the chip — and
2080
+ did **not** stop the overlap that ticket was named for. Those declarations cap
2081
+ the name to the chip's content box, and `.topUp` is absolutely positioned
2082
+ *inside* that same box, reserving no width of its own. Measured at the sidebar's
2083
+ 240px chip: the header runs 210px wide, the button's left edge sits 170.4px into
2084
+ it. Everything in that last 39.6px was still painted over.
2085
+
2086
+ So a French chip shipped a visible defect. `Solde du portefeuille électronique`
2087
+ ran 23.2px underneath the button with no ellipsis to warn anyone, and a name long
2088
+ enough to truncate put the ellipsis itself under it — and because the button is
2089
+ `opacity: 0` until hover or focus, the collision appeared exactly when someone
2090
+ reached for the chip.
2091
+
2092
+ The header now gives up that strip. A new token, `--spacing-topup-reserve`
2093
+ (68px), is set as `padding-right` on the chip header in the states where the
2094
+ button floats over it, so the name re-clamps to the narrower row and B33-881's
2095
+ ellipsis fires where it always should have. 68px is the French `Recharger`
2096
+ (59.9px, wider than the English `Top up` at 39.6px) plus `--spacing-2` of
2097
+ clearance, and deliberately no more: it leaves the name 142px, which still holds
2098
+ `Portefeuille électronique` whole.
2099
+
2100
+ **Nothing renders differently for a consumer on the English defaults.** `Wallet`
2101
+ is 35.4px and was never near the strip; the five wallet screenshot baselines come
2102
+ back bit-identical, to the pixel, with and without this change. The rendering
2103
+ that changes is the one that was already wrong.
2104
+
2105
+ **Two things worth knowing if you translate the chip.** A `labels.name` longer
2106
+ than 142px at 12px now truncates with an ellipsis instead of running under the
2107
+ button — that is the fix, but it is a visible difference from what 0.2.0 drew.
2108
+ And the reserve is a fixed strip, so a `labels.topUp` wider than 60px (a phrase
2109
+ such as `Créditer le compte`, 107px, rather than a word) overruns it again. The
2110
+ only fix that holds for any label is putting the button in normal flow, which
2111
+ restructures a view approved at the fidelity gate and is a human's call.
2112
+
2113
+ `--spacing-topup-reserve` ships in `style.css` like every other token, so a
2114
+ consumer can read it, and `dist/` is otherwise unchanged: no new exports, no prop
2115
+ changes, no markup changes.
2116
+
2117
+ *Not in the published package:* `scripts/wallet-name-overflow-check.mts` and
2118
+ `scripts/name-clearance.ts`, a browser check (`npm run
2119
+ wallet-name-overflow-check`) that renders the chip with five translated names
2120
+ against two top-up labels in three states and fails if any name's painted box
2121
+ intersects the button's. It fails on 8 of those 30 renderings with this fix
2122
+ backed out, which is the thing the test it replaces could not do — that one read
2123
+ the stylesheet as text and stayed green throughout (B33-914).
2124
+
2125
+ ### Added — `AppSwitcher`, the switcher that actually switches (B33-479)
2126
+
2127
+ A new component, and seven new names on the published surface: `AppSwitcher` and
2128
+ `defaultAppSwitcherLabels`, plus the types `AppSwitcherProps`,
2129
+ `AppSwitcherProduct`, `AppSwitcherHub`, `AppSwitcherAccount` and
2130
+ `AppSwitcherLabels`. **Purely additive.** No existing export changed and no
2131
+ signature moved; `AppShellProps.switcher` was already a `ReactNode` slot in
2132
+ `0.2.0`, so this drops into a shell a product is already rendering:
2133
+
2134
+ ```tsx
2135
+ <AppShell
2136
+ switcher={
2137
+ <AppSwitcher
2138
+ products={products}
2139
+ currentSlug="outbound"
2140
+ hub={{ name: 'Hub', description: 'All Boost33 products', href: '/hub' }}
2141
+ account={{ name: 'Acme Coaching', initials: 'AC', description: 'Admin · 2 workspaces', onSelect: openWorkspaces }}
2142
+ />
2143
+ }
2144
+ {...rest}
2145
+ />
2146
+ ```
2147
+
2148
+ **It fetches nothing.** `products` is the list Identity serves and the consuming
2149
+ app passes down; this package hardcodes no product list (CLAUDE.md). Every
2150
+ product renders whether or not the account holds it — `entitled: false` dims the
2151
+ mark and adds a "Not on plan" tag, and the row stays an ordinary focusable link,
2152
+ because not entitled is not disabled (ADR-0010) and hiding an unowned product is
2153
+ how nobody learns the family exists.
2154
+
2155
+ **A disclosure, not a menu.** The panel carries no widget role, the rows are the
2156
+ links and buttons they already are, and Tab is the document's own tab order — so
2157
+ tabbing through the products does not close the panel on the first row.
2158
+ Dismissal, focus restoration and trigger ARIA come from the shared
2159
+ anchored-overlay contract (ADR-0009), and it never becomes a modal.
2160
+
2161
+ **It is a client module.** `AppSwitcher` ships with `"use client"`, as
2162
+ `WalletChip` does (ADR-0004) — it holds open/closed state. `AppShell` takes it
2163
+ through a slot rather than importing it, so a shell rendered without a switcher
2164
+ still ships no client bundle for one.
2165
+
2166
+ **Two things also arrive through `./style.css`**: the switcher's own rules, and
2167
+ one new published token, `--spacing-2_25: 9px` — the trigger's block padding,
2168
+ from the spec's 9 September recapture. No existing token value changed.
2169
+
2170
+ `labels` carries the two strings that are not row data: `panel`, the panel's
2171
+ accessible name (default "Boost33 products"), and `notOnPlan` (default "Not on
2172
+ plan"). Pass it and you supply both — the prop is typed `AppSwitcherLabels`, not
2173
+ `Partial<AppSwitcherLabels>`, and a supplied object replaces the defaults rather
2174
+ than merging onto them. Product names, the hub's name and blurb and the account
2175
+ row's lines are not labels; they are Identity's data, passed per row.
2176
+
2177
+ > One residual, recorded rather than hidden. On the 56px rail the trigger's mark
2178
+ > sits 8px lower than the fidelity baseline puts it: `.railTrigger` carries
2179
+ > `--spacing-2` of block padding, and dropping that to match the baseline leaves a
2180
+ > 20px-tall button, under WCAG 2.5.8's 24px target. The spec settles the expanded
2181
+ > trigger and says nothing about the rail one, so the trade is a decision for a
2182
+ > human rather than a measurement to re-derive.
2183
+
2184
+ ### Fixed — a long wallet name truncates instead of reflowing the chip (B33-881)
2185
+
2186
+ `WalletChip`'s `.name` now truncates on one line rather than wrapping, through
2187
+ `min-width: 0`, `overflow: hidden`, `text-overflow: ellipsis` and
2188
+ `white-space: nowrap` — the idiom `NavItem` already uses. It is four
2189
+ declarations in `WalletChip.module.css`, and they reach every consumer through
2190
+ the compiled `./style.css` on the version bump. No prop changed and no markup
2191
+ moved.
2192
+
2193
+ B33-849 made the name consumer-supplied, which dropped its guarantee of being a
2194
+ short English word. Unguarded, a longer name wrapped and pushed the chip off its
2195
+ approved 63px content box; and because the top-up button is absolutely positioned
2196
+ over the header's right strip and revealed on hover or focus, a long name painted
2197
+ underneath that button the moment someone hovered the chip. French runs 15–25%
2198
+ longer than English, so the first two consumers are precisely where this trips.
2199
+
2200
+ **A consumer that passes no `labels` sees nothing change** — the default is
2201
+ "Wallet", never long enough to overflow, and the wallet baselines do not move.
2202
+ **Check your app if** it passes a long or multi-word `labels.name` and expected a
2203
+ second line: it now ellipses.
2204
+
2205
+ The screenshot gate is blind here, because the specimen renders the English
2206
+ defaults (B33-878). The guard is asserted against the stylesheet source in
2207
+ `tests/wallet-name-overflow.test.ts` instead — the same file-as-text seam the
2208
+ token guards read at.
2209
+ ### Added — an optional Tailwind v4 entry point, `@boost33/ui/theme.css` (B33-871)
2210
+
2211
+ A Tailwind v4 consumer can now take this package's tokens as Tailwind theme
2212
+ names, holding **no token names and no token values of its own**:
2213
+
2214
+ ```css
2215
+ @import "tailwindcss";
2216
+ @import "@boost33/ui/style.css";
2217
+ @import "@boost33/ui/theme.css";
2218
+ ```
2219
+
2220
+ `bg-orange-500`, `rounded-xl`, `text-sm`, `w-sidebar`, `text-foreground-muted`
2221
+ and the rest now mean this package's values in that app, and keep meaning them
2222
+ across a version bump. Delete the `@theme` block you were maintaining; keep only
2223
+ your own additions, as aliases (`--color-chart-1: var(--color-orange-500)`).
2224
+
2225
+ **This is additive and entirely optional. If you do not import it, nothing
2226
+ changes for you** — `dist/style.css` is byte-for-byte what it was, and a
2227
+ consumer with no Tailwind, or on Tailwind v3, is unaffected. No Tailwind
2228
+ dependency or peer dependency is added to the package.
2229
+
2230
+ It is the same token set as `style.css`, not a second one: both files are
2231
+ generated from the same Paper dump by the same run, and a test fails the build
2232
+ if they ever disagree. Two notes worth knowing:
2233
+
2234
+ - Motion and elevation (`--duration-fast`, `--easing-standard`,
2235
+ `--shadow-overlay`) are **not** in it — they are hand-maintained rather than
2236
+ generated. They still arrive on `:root` via `style.css`; what you do not get
2237
+ is a `shadow-overlay` utility. Write `shadow-[var(--shadow-overlay)]`.
2238
+ - The font hooks work exactly as before. Keep declaring
2239
+ `--boost33-font-sans` and friends; this package still only ever reads them.
2240
+
2241
+ See `docs/adr/0012-an-optional-generated-tailwind-theme-entry-point.md`, which
2242
+ amends ADR-0002.
2243
+
2244
+ ### Fixed — the shell now renders under the Next.js App Router (B33-846)
2245
+
2246
+ The published package shipped no `"use client"` directive, so a Next App Router
2247
+ consumer that rendered `AppShell` from a server component — the ordinary way, and
2248
+ the way Score does it — failed the build the moment the shell's tree reached an
2249
+ interactive component. It reached one immediately: `AppShell` renders `Sidebar`,
2250
+ which ran an effect.
2251
+
2252
+ `WalletChip` and the sidebar's dev-mode nav-budget warning are now client
2253
+ modules; the rest of the shell (`AppShell`, `TopBar`, `PageHeader`, `NavSection`,
2254
+ `NavItem`, `Sidebar`, `Wordmark`) stays a Server Component, so a consumer ships
2255
+ no client bundle for the shell's static half. **Nothing renders differently** —
2256
+ same props, same markup, same behaviour, screenshot baselines unchanged.
2257
+
2258
+ The public surface is unchanged: `import { AppShell } from '@boost33/ui'` and
2259
+ `'@boost33/ui/style.css'` resolve exactly as before. Internally `dist/` is now
2260
+ many files rather than one so the directive can sit on the two modules that need
2261
+ it and no others — see ADR-0004. A consumer that only imports through the package
2262
+ name (as the `exports` map requires) sees no difference beyond the shell finally
2263
+ rendering.
2264
+
2265
+ ### Added — a consumer's own link element for the nav, so a Next.js app stops full-page-reloading (B33-847)
2266
+
2267
+ `NavItem` rendered its linked form as a literal `<a href>`, and `AppShell`
2268
+ threaded a row's `href` straight to it. In a Next.js App Router consumer that is
2269
+ a full document navigation on every sidebar click: the React tree is thrown away
2270
+ and rebuilt, in an app whose whole routing model exists to avoid exactly that.
2271
+ And the locale prefix goes with it, because the localised `Link` that writes the
2272
+ prefix into the path — next-intl's — never sees the click. For a consumer whose
2273
+ default locale is not English, and the first two are French-default, a bare
2274
+ `href` either 404s or drops the reader into the wrong language.
2275
+
2276
+ **A new optional prop, `linkComponent?: ElementType`, on both `NavItemProps` and
2277
+ `AppShellProps`.** On `NavItem` it is the element the linked form renders
2278
+ through in place of the literal `<a>`, receiving `href`, `className`, `onClick`
2279
+ and the row's `data-*`/`aria-*` attributes unchanged — so a consumer's `Link`
2280
+ gets exactly what the anchor got, and the rendered markup is identical. On
2281
+ `AppShell` it is set once and threaded to every `NavItem` the shell composes out
2282
+ of `nav`, because which element draws a row is the consumer's one routing
2283
+ choice, not a per-row datum: `AppShellNavItem` still carries only the `href`.
2284
+
2285
+ A Next.js consumer passes its own link once, on the shell:
2286
+
2287
+ ```tsx
2288
+ import Link from 'next/link' // or next-intl's localised Link, for the prefix
2289
+
2290
+ <AppShell product="Score" nav={nav} topBar={topBar} linkComponent={Link}>
2291
+ {children}
2292
+ </AppShell>
2293
+ ```
2294
+
2295
+ **Unset, the row renders a plain `<a>`, exactly as before.** The prop is
2296
+ additive and opt-in — no existing signature moved, and the default DOM is
2297
+ byte-identical, which is why the behaviour tests and the fidelity baselines
2298
+ stayed green unmodified. **Nothing breaks by taking this version.** The flip
2299
+ side is the reason this entry matters: **a consumer that passes nothing keeps
2300
+ the full page reload.** If your app owns a router, this is the version to wire
2301
+ it into.
2302
+
2303
+ **The prop is consulted only for the linked form.** A `NavItem` with no `href`
2304
+ is an action and stays a `<button>`; `linkComponent` on such a row is ignored
2305
+ rather than rendered.
2306
+
2307
+ **Type safety stops at the boundary, deliberately.** `ElementType` does not
2308
+ check that what you pass accepts an `href`, so a component that ignores one
2309
+ typechecks and renders a row that goes nowhere. The prop is documented as
2310
+ receiving what the anchor received; enforcing that in the type was judged not
2311
+ worth the machinery.
2312
+
2313
+ The name and the shape are settled for every link-bearing component this package
2314
+ grows, in
2315
+ `docs/adr/0011-a-linkcomponent-prop-is-how-a-component-takes-a-consumers-link.md`
2316
+ — not `as`, not `linkAs`, not a render prop. `AppSwitcher`, new in this release,
2317
+ already takes the same prop for its product and hub rows (B33-479).
2318
+
2319
+ ### Added — `WalletChip` takes its user-facing strings as props (B33-849, B33-873, B33-875, B33-877)
2320
+
2321
+ Four tickets built this API and not one of them has shipped: B33-849 added it,
2322
+ and B33-873, B33-875 and B33-877 corrected it before any consumer saw it. It is
2323
+ described here once, as the surface a consumer actually takes on the next bump,
2324
+ rather than as one entry plus three corrections to something nobody has.
2325
+
2326
+ `WalletChip` was the only component in the package that rendered words, and every
2327
+ one of them was an English literal. The first two consumers run next-intl with
2328
+ French as the default locale, so the chip rendered "Wallet", "Top up" and
2329
+ "Granted expires …" in English no matter what the app around it spoke.
2330
+
2331
+ Every string is now a field on a new optional `labels` prop, typed
2332
+ `Partial<WalletChipLabels>`, each with an English default:
2333
+
2334
+ | field | default |
2335
+ | --- | --- |
2336
+ | `region` | `Wallet balance` (the chip's accessible name) |
2337
+ | `name` | `Wallet` |
2338
+ | `unit` | `tokens` |
2339
+ | `balanceUnknown` | `Balance unknown` (screen-reader only) |
2340
+ | `granted` / `purchased` | `Granted` / `Purchased` |
2341
+ | `grantExpiry` | `Granted expires {date} · burns first` |
2342
+ | `topUp` | `Top up` |
2343
+ | `railBalance` | `Wallet balance {balance}` |
2344
+ | `railBalanceUnknown` | `Wallet balance unknown` |
2345
+ | `railUnknown` | `unknown` (screen-reader only) |
2346
+ | `bar` | `{granted} granted and {purchased} purchased of {remaining} remaining` |
2347
+
2348
+ **A consumer that passes nothing renders exactly what it rendered before** — the
2349
+ defaults are today's strings, so the wallet baselines do not move. Pass a
2350
+ `labels` object to translate, overriding only the fields you need:
2351
+
2352
+ ```tsx
2353
+ <WalletChip granted={3400} purchased={1200} grantCycle={3400} labels={{ name: 'Portefeuille', topUp: 'Recharger' }} />
2354
+ ```
2355
+
2356
+ `WalletChipLabels` and the `defaultWalletChipLabels` object are exported, so a
2357
+ consumer can build on the English set rather than re-type all twelve. **Build on
2358
+ it by spreading it** — the object is frozen (B33-877), and writing into it throws:
2359
+
2360
+ ```tsx
2361
+ const fr = { ...defaultWalletChipLabels, name: 'Portefeuille' } // correct
2362
+ const mine = defaultWalletChipLabels; mine.name = 'Portefeuille' // TypeError
2363
+ Object.assign(defaultWalletChipLabels, { name: 'Portefeuille' }) // TypeError
2364
+ ```
2365
+
2366
+ **No shipped consumer breaks.** The export and the freeze reach npm in the same
2367
+ version, so there is no published build of this package in which writing to the
2368
+ defaults ever worked. What changed is that an idiom which *would* have worked —
2369
+ and would have renamed the header of every chip in the process, silently, because
2370
+ the object is a process-wide singleton — is now a thrown `TypeError` at the first
2371
+ write.
2372
+
2373
+ It throws rather than failing quietly because this package is ESM-only and a
2374
+ module is strict-mode code. **Nothing in the types warns you**: the export is
2375
+ declared `WalletChipLabels`, not `Readonly<WalletChipLabels>`, so `tsc` accepts
2376
+ the assignment and the error arrives at runtime. That is the whole reason it is
2377
+ written down here rather than left to the typecheck.
2378
+
2379
+ **A label supplied as `undefined` falls back to English (B33-873).** A next-intl
2380
+ `t('...')` returns `undefined` on a missed key, so one missing translation out of
2381
+ twelve reaches the chip as `labels={{ name: undefined }}` — explicit,
2382
+ indistinguishable at the call site from an unsupplied field, and typechecking
2383
+ cleanly, because `Partial<T>` without `exactOptionalPropertyTypes` accepts an
2384
+ explicit `undefined`. The merge is now per field and drops `undefined` entries
2385
+ before applying them, so that one key falls back to its English default. A plain
2386
+ object spread copied the `undefined` across, which blanked the chip header,
2387
+ shipped an unnamed top-up button, or crashed the render outright on a function
2388
+ label such as `bar` or `grantExpiry`.
2389
+
2390
+ **The bar's accessible label is now supplied whole**, as one template around the
2391
+ three figures, rather than concatenated from fragments. Word order is not a
2392
+ property of English, and the old `"{n} granted and {n} purchased of {n}
2393
+ remaining"` could not be translated at all — a French consumer needs to put the
2394
+ numbers where French puts them. The three labels that carry a value —
2395
+ `grantExpiry`, `railBalance` and `bar` — name each value's position with a
2396
+ `{placeholder}`; see B33-880 below, which is also why they are strings rather
2397
+ than the functions this change originally shipped on `main`.
2398
+
2399
+ **Your `bar` is called only where a bar renders (B33-875).** The unknown state
2400
+ draws no bar — it is the degraded render, the one that exists because fetching
2401
+ the balance already failed — but the label was assembled above every branch, so a
2402
+ consumer's `bar` ran on `0, 0, 0` figures there too. It is now built lazily and
2403
+ invoked at the two sites that actually render a bar, so a `bar` that divides by
2404
+ the total or does an `Intl` lookup no longer has a chance to throw on the one
2405
+ render the chip most needs to survive. Nothing changes for a consumer on the
2406
+ default, which is a template string that cannot fail.
2407
+
2408
+ **No i18n dependency.** next-intl is not, and must not become, a peer dependency;
2409
+ the chip renders what it is given (CLAUDE.md). A new guard,
2410
+ `tests/no-hardcoded-strings.test.ts`, fails if a component grows a user-facing
2411
+ *phrase* — two words or more — or a string literal reaching an accessible-name
2412
+ attribute. It is the sibling of Score's gate of the same name.
2413
+
2414
+ It does not catch a single word routed through a variable (`const cta =
2415
+ 'Upgrade'` rendered as `<button>{cta}</button>`); the guard's own comment says
2416
+ so, and this line said otherwise until B33-874 corrected both.
2417
+
2418
+ ### Fixed — `WalletChip` labels now cross the Server Component boundary (B33-880)
2419
+
2420
+ The two entries above are individually fine and together broke the documented
2421
+ way to translate the chip. B33-849 gave `labels` three **function**-valued
2422
+ fields; B33-846 made `WalletChip` a real client module. A function cannot be a
2423
+ prop of a Client Component rendered from a React Server Component, so the
2424
+ sentence this package told consumers to write —
2425
+
2426
+ ```tsx
2427
+ <WalletChip {...balance} labels={{ ...defaultWalletChipLabels, name: 'Portefeuille' }} />
2428
+ ```
2429
+
2430
+ — threw out of React's Flight serializer:
2431
+
2432
+ > Functions cannot be passed directly to Client Components unless you explicitly
2433
+ > expose it by marking it with `"use server"`.
2434
+
2435
+ Three things made that worse than it sounds. Nesting the function inside an
2436
+ object does not help: the serializer walks the whole prop graph. It is not
2437
+ `__DEV__`-gated, so it throws in production. And `AppShell.wallet` is a
2438
+ `ReactNode` slot, so it is the **consumer's own page** — under the App Router, a
2439
+ Server Component by default — that constructs the element. The pattern that
2440
+ throws was the pattern the specimen demonstrated.
2441
+
2442
+ **The three fields now take a `{placeholder}` template as well as a function,
2443
+ and the English defaults are templates:**
2444
+
2445
+ | field | default | placeholders |
2446
+ | --- | --- | --- |
2447
+ | `grantExpiry` | `Granted expires {date} · burns first` | `{date}` |
2448
+ | `railBalance` | `Wallet balance {balance}` | `{balance}` |
2449
+ | `bar` | `{granted} granted and {purchased} purchased of {remaining} remaining` | `{granted}`, `{purchased}`, `{remaining}` |
2450
+
2451
+ A template is a plain string, so `labels` is data all the way down and survives
2452
+ the boundary intact. Word order stays the consumer's — that was the point of
2453
+ supplying these whole rather than as fragments, and a placeholder goes wherever
2454
+ the locale puts the figure:
2455
+
2456
+ ```tsx
2457
+ labels={{ bar: '{remaining} restants : {granted} et {purchased}' }}
2458
+ ```
2459
+
2460
+ A name the label does not define is left exactly as written, so a misspelled
2461
+ placeholder appears in the rendering instead of silently blanking, and copy that
2462
+ genuinely contains braces is not mangled. A template that names no placeholder
2463
+ renders as written: a locale that does not need the figure need not name it.
2464
+
2465
+ **Nothing renders differently.** The templates produce character-for-character
2466
+ what the functions produced, a consumer that passes no `labels` is untouched, and
2467
+ the five `walletchip-*` baselines do not move.
2468
+
2469
+ **The function form still works**, and is still the right shape when the sentence
2470
+ needs logic a template cannot express — ICU plurals, an `Intl.ListFormat` call.
2471
+ It is only ever reachable from a Client Component, which is where a consumer
2472
+ holding a function already is. Passing a mix of templates and functions in one
2473
+ object is fine.
2474
+
2475
+ **One shape did change, and it reaches nobody published.** Reading a default
2476
+ *as a function* — `defaultWalletChipLabels.bar('3,400', '1,200', '4,600')` — is
2477
+ now a call on a string. The function-valued defaults have never been published:
2478
+ 0.2.0 predates B33-849 and both changes are still unreleased above. The only
2479
+ code that could be affected is something written against `main` in the last few
2480
+ days, and the fix is to interpolate the template or keep a function of your own.
2481
+
2482
+ `tests/wallet-labels-serializable.test.tsx` pins the property directly: the
2483
+ defaults survive `structuredClone` and a JSON round-trip with every field intact,
2484
+ no default is a function, and a function *is* still rejected by the clone, so the
2485
+ check cannot go quietly vacuous. jsdom has no RSC boundary — this is the nearest
2486
+ honest stand-in for one, and the reason the original bug was invisible to
2487
+ `typecheck`, to the behaviour tests and to the screenshot gate alike.
2488
+
2489
+ ### Fixed — `WalletChip` formats its numbers for a `locale` (B33-876)
2490
+
2491
+ B33-849 made every *word* the chip renders a prop. Its **numbers** stayed
2492
+ English, and no prop could reach them. `format.ts` hardcoded
2493
+ `toLocaleString('en-US')` and a literal `k`, so a French-default consumer — and
2494
+ the first two consumers are French-default — read `4,600` where French writes
2495
+ `4 600`, and `4.6k` on the rail where French writes `4,6 k`. Those are the
2496
+ largest glyphs on the chip. The design also foreclosed the workaround: the label
2497
+ templates and functions receive the figures **already formatted**, by documented
2498
+ intent, so a consumer could translate "tokens" to "jetons" and still had no hook
2499
+ at all for the number in front of it.
2500
+
2501
+ **`WalletChip` now takes an optional `locale`** — one BCP-47 language tag,
2502
+ defaulting to `en-US`, driving `Intl.NumberFormat`:
2503
+
2504
+ ```tsx
2505
+ <WalletChip granted={3400} purchased={1200} grantCycle={3400} locale="fr-FR" />
2506
+ ```
2507
+
2508
+ | what it renders | `en-US` (the default) | `fr-FR` |
2509
+ | --- | --- | --- |
2510
+ | the balance | `4,600` | `4 600` |
2511
+ | each side of the split | `3,400` / `1,200` | `3 400` / `1 200` |
2512
+ | the rail, abbreviated | `4.6k` | `4,6 k` |
2513
+ | the `bar` accessible name | `3,400 granted and 1,200 purchased of 4,600 remaining` | the same figures, French-grouped |
2514
+
2515
+ French groups with a narrow no-break space (U+202F), not an ordinary space —
2516
+ worth knowing if you are writing an assertion against it.
2517
+
2518
+ **It reaches the accessible names, not only the visible text.** The same
2519
+ formatted strings are interpolated into `bar` and `railBalance`, which the
2520
+ screenshot gate cannot see. A fix that stopped at the visible figures would have
2521
+ left a screen-reader user in a French app hearing en-US separators.
2522
+
2523
+ **Why a locale tag, and not consumer-supplied formatters.** Both shapes were on
2524
+ the table. A `formatTokens` / `abbreviateThousands` prop pair is a **function**,
2525
+ and a function cannot cross the React Server Component boundary — which is the
2526
+ entire subject of the entry above. `AppShell.wallet` is a `ReactNode` slot, so a
2527
+ consumer's own page constructs the element, and under the App Router that page is
2528
+ a Server Component by default. Shipping injected formatters would have
2529
+ reintroduced B33-880 the same week it was fixed, and reintroduced it as the
2530
+ recommended path. A language tag is a plain string.
2531
+
2532
+ The second reason is that numbers are not words. `labels` takes whole sentences
2533
+ because word order is a property of the consumer's language and no library can
2534
+ know it. Grouping and the decimal separator are the opposite: CLDR facts the
2535
+ platform already ships for every locale. Asking four products to each
2536
+ re-implement French grouping is asking four products to each get it slightly
2537
+ wrong. So "`labels` is already the injected shape, be consistent" reverses once
2538
+ you read *why* `labels` took that shape. The reasoning is written out at the top
2539
+ of `src/components/WalletChip/format.ts`, so reversing it is cheap.
2540
+
2541
+ **One new label, `railFigure`, for the `k`.** The mark after the rail's
2542
+ abbreviated balance is a word rather than a number — English "k", German "Tsd.",
2543
+ and French puts a space in front of it that English does not — so it joins the
2544
+ chip's other words:
2545
+
2546
+ | field | default | placeholder |
2547
+ | --- | --- | --- |
2548
+ | `railFigure` | `{figure}k` | `{figure}` |
2549
+
2550
+ It takes a `{placeholder}` template or a function, exactly like `grantExpiry`,
2551
+ `railBalance` and `bar`, and a template rather than a bare suffix because a
2552
+ locale is entitled to put the mark somewhere other than the end. A French
2553
+ consumer writes `railFigure: '{figure} k'` and gets `4,6 k`.
2554
+
2555
+ `Intl`'s own `notation: 'compact'` cannot supply this, which is why the rail is
2556
+ not simply handed to it: it renders `en-US` as `4.6K`, a capital the baselines do
2557
+ not have, and German does not abbreviate four digits at all, so it renders
2558
+ `4600,0` — six characters and a decimal, in a 56px rail, longer than the number
2559
+ it was meant to shorten. One decimal thousand is this package's decision about a
2560
+ fixed width; the separator inside it is CLDR's, and that is the part `locale`
2561
+ fixes.
2562
+
2563
+ **Nothing renders differently for a consumer that passes nothing.** `en-US` is
2564
+ the default, `Intl.NumberFormat('en-US')` is the same CLDR data
2565
+ `toLocaleString('en-US')` reached, and `railFigure` defaults to `{figure}k`. That
2566
+ is asserted rather than assumed — `tests/wallet-format.test.ts` compares the
2567
+ default path against `toLocaleString('en-US')` value by value, and
2568
+ `tests/wallet-chip.test.tsx` compares the rendered markup with and without an
2569
+ explicit `en-US`. The five `walletchip-*` baselines do not move.
2570
+
2571
+ **The change is additive.** `locale` is optional, `railFigure` has a default,
2572
+ `WalletChipLabels` gains a field rather than changing one, and an explicitly
2573
+ `undefined` `railFigure` still falls back to English the way every other label
2574
+ does (B33-873). The one thing that changed shape is internal and reaches no
2575
+ consumer: `format.ts` now exports `thousandsFigure`, which returns the figure
2576
+ without the mark, in place of `abbreviateThousands`. Neither has ever been
2577
+ exported from `src/index.ts`.
2578
+
2579
+ **A malformed tag degrades rather than throwing.** `Intl.NumberFormat` raises a
2580
+ `RangeError` on a structurally invalid tag (`'en_US'`), and this chip sits in the
2581
+ sidebar of every screen in four products: one consumer's typo in a config file
2582
+ must mis-space a number, not take the shell down. A tag the platform refuses
2583
+ falls back to `en-US` and warns the developer once — `console` only, never
2584
+ rendered. A well-formed tag the platform has no data for (`'zz-ZZ'`) is not an
2585
+ error and is left to the platform to resolve.
2586
+
2587
+ ### Changed — the three font tokens now read a consumer-supplied hook (B33-848)
2588
+
2589
+ `--font-display`, `--font-sans` and `--font-mono` are now generated as a `var()`
2590
+ read of a property this package never declares. Three lines of `src/tokens.css`
2591
+ changed, and they are the only lines that changed:
2592
+
2593
+ | token | was | now |
2594
+ | --- | --- | --- |
2595
+ | `--font-display` | `Outfit, ui-sans-serif, …` | `var(--boost33-font-display, Outfit), ui-sans-serif, …` |
2596
+ | `--font-sans` | `Inter, ui-sans-serif, …` | `var(--boost33-font-sans, Inter), ui-sans-serif, …` |
2597
+ | `--font-mono` | `'Geist Mono', ui-monospace, …` | `var(--boost33-font-mono, 'Geist Mono'), ui-monospace, …` |
2598
+
2599
+ **No token value changed, and an app that does nothing renders exactly as it
2600
+ did.** The Paper family stays in place as the `var()` fallback, so all four
2601
+ products are unchanged until one of them opts in. Take this bump and set nothing
2602
+ and nothing moves.
2603
+
2604
+ **What a consuming app gains** is the ability to name the face its own loader
2605
+ minted. `next/font` mints a hashed family name at build time — something of the
2606
+ shape `__Inter_e8ce0c` — and exposes it only through a generated variable, a
2607
+ string nothing in this package can predict. A Next.js app can now say so
2608
+ directly:
2609
+
2610
+ ```css
2611
+ :root {
2612
+ --boost33-font-sans: var(--font-inter);
2613
+ }
2614
+ ```
2615
+
2616
+ and the shell renders in the same face as the rest of the app.
2617
+
2618
+ **What this fixes.** The package declares its tokens on `:root`, and a consumer
2619
+ that declared `--font-sans` on `:root` too had no defined winner — same selector,
2620
+ same specificity, same origin, so the cascade's last tie-break is document order
2621
+ and whichever stylesheet the bundler emitted last silently won. Nothing anywhere
2622
+ reported it: no build warning, no type error, no failing test. A lost race meant
2623
+ every screen in the app rendering in a different typeface with nothing failing.
2624
+ The package now reads a name it never declares, so the two stylesheets share no
2625
+ property name and load order has nothing left to decide.
2626
+
2627
+ **The rule this sits under:** the package's token values are authoritative, and a
2628
+ consumer may alias a package token but may never redefine one — font family is
2629
+ the single carve-out, and it is a hook rather than an override for exactly that
2630
+ reason (ADR-0011).
2631
+
2632
+ **Check your app if** it declares any `--color-*`, `--text-*`, `--spacing-*`,
2633
+ `--radius-*` or `--container-*` custom property on `:root` alongside this
2634
+ package. Those names still collide, and a collision is now a conflict to resolve
2635
+ rather than a race to win: where the two disagree the package's value is the
2636
+ correct one and yours moves. Two values are known to be wrong in the wild —
2637
+ `--color-neutral-600` must be `#6B6B6B` (the pre-fix `#737373` fails WCAG AA for
2638
+ muted text under 18.66px, ADR-0007) and `--color-orange-400` must be `#EB7147`.
2639
+ Live Paper was read on 2026-09-21 and holds both. Note that a textual diff of two
2640
+ token files under-reports this: `--color-foreground-muted` reads
2641
+ `var(--color-neutral-600)` on both sides and differs only in the paint
2642
+ underneath.
2643
+
2644
+ > Two limits, recorded rather than hidden. A `--boost33-font-*` hook pointed at a
2645
+ > variable the app has not itself defined makes the whole declaration invalid at
2646
+ > computed-value time, and the face falls back to the Paper name rather than the
2647
+ > app's — the hook moves that hazard into the consumer, where the font loader is
2648
+ > visible, but it does not remove it. And the screenshot gate cannot see a wrong
2649
+ > typeface at all, so this is guarded by the browser load-order check and the
2650
+ > token guards, not by a baseline.
2651
+
2652
+ ### Changed — the nav height budget now agrees with what the nav renders (B33-752)
2653
+
2654
+ `nav-height-budget` exists so a product can assert its nav fits *before*
2655
+ rendering it. Measured against a real render it was wrong in the one direction
2656
+ that matters: it under-counted the content and over-counted the column, and the
2657
+ two errors compounded toward a false "fits". A false "does not fit" costs someone
2658
+ a look; a false "fits" is how Boost Outbound's admin role pushed the wallet chip
2659
+ below the fold, which is the incident the module was written for.
2660
+
2661
+ **Four exported constants changed value.** No export was removed and no signature
2662
+ changed, so nothing here fails a typecheck — which is exactly why it is written
2663
+ down.
2664
+
2665
+ | export | was | now |
2666
+ | --- | --- | --- |
2667
+ | `NAV_HEIGHT_BUDGET_PX` | `700` (a literal) | `629` (derived: `navBudgetForViewport(900)`) |
2668
+ | `NAV_ITEM_PX` | `32` | `33` |
2669
+ | `NAV_SECTION_HEADER_PX` | `40` | `30` |
2670
+ | `NAV_SHELL_FIXED_PX` | `198` | `271` |
2671
+
2672
+ **`navContentHeight()` returns a different number for every input.** Its
2673
+ signature is unchanged. It now returns the nav's whole box — rows, the 16px gaps
2674
+ the nav sets between sections, and its 8px scroll gutter — rather than the sum of
2675
+ row heights alone. It is the nav's rendered `scrollHeight`, which is what it
2676
+ always claimed to be.
2677
+
2678
+ **Two constants are new**, both previously folded into the numbers above:
2679
+ `NAV_SECTION_GAP_PX` (`16`) and `NAV_FOCUS_GUTTER_PX` (`8`).
2680
+
2681
+ #### What this changes for a consuming product
2682
+
2683
+ Fewer nav items fit than the old model claimed. At the 900px reference viewport:
2684
+
2685
+ | nav shape | fitted before | fits now |
2686
+ | --- | --- | --- |
2687
+ | one flat section, no header | 21 items | **18** |
2688
+ | two sections, one headed (the Wallet's shape) | 20 items | **17** |
2689
+ | four sections, three headed | 18 items | **14** |
2690
+
2691
+ Nothing renders differently — no component, CSS or token changed in this work.
2692
+ The shell behaved this way already; only the prediction was wrong.
2693
+
2694
+ **Check your app if** it imports any of the four constants and does its own
2695
+ arithmetic with them, or asserts on `navContentHeight()` in its own tests. A test
2696
+ pinned to the old numbers (`navContentHeight(shape) <= 700`) will now get a
2697
+ different answer. If you only call `checkNavHeightBudget()` and read `fits`, you
2698
+ need do nothing — you will simply get a truthful answer where you previously
2699
+ might not have.
2700
+
2701
+ The model is now reconciled against a real browser render in CI, so the two
2702
+ cannot drift apart again silently.
2703
+
2704
+ > Two known limits, both recorded rather than hidden. `NAV_SHELL_FIXED_PX` is
2705
+ > measured from the specimen's stress stage, whose footer is 12px taller than the
2706
+ > Wallet's, so the budget under-states by that much at the Wallet reference —
2707
+ > B33-762, and the safe direction. And the row costs include a line box that
2708
+ > comes from Inter's own metrics, so an app that renders the shell without Inter
2709
+ > loaded will drift from the model — B33-765.
2710
+
2711
+ ### Added — the nav height budget says what it is a model of, and lets you override it (B33-762)
2712
+
2713
+ The two "known limits" recorded at the end of the B33-752 entry above turned out
2714
+ to be three, and all three were the same defect: **the model never said what it
2715
+ was a model of.** It did not say whose shell's furniture it had measured, which
2716
+ nav state its per-row costs described, or whose font metrics those costs came
2717
+ from. A consuming product importing the constants could not see any of it.
2718
+
2719
+ **No exported constant changed value.** `NAV_SHELL_FIXED_PX` is still `271`,
2720
+ `NAV_HEIGHT_BUDGET_PX` still `629`, `NAV_ITEM_PX` still `33`,
2721
+ `NAV_SECTION_HEADER_PX` still `30`. Nothing renders differently — no component,
2722
+ CSS or token was touched, and every screenshot baseline still matches. If you
2723
+ take this version and change nothing, you get the same numbers you had.
2724
+
2725
+ What is new is that the three assumptions are now named next to the constants
2726
+ that carry them, and two of them can be replaced by a caller who knows better.
2727
+
2728
+ **Two constants are new**, both a decomposition of `NAV_SHELL_FIXED_PX` rather
2729
+ than a new measurement:
2730
+
2731
+ | export | value | what it is |
2732
+ | --- | --- | --- |
2733
+ | `NAV_SHELL_PACKAGE_FIXED_PX` | `185` | the furniture this package draws — the wordmark block, the switcher wrapper's padding, `.foot`'s padding and gap, the expanded `WalletChip`, less the nav's negative gutter margin |
2734
+ | `NAV_SHELL_CONSUMER_SLOTS_PX` | `86` | an **allowance** for the slots this package does not own — a 42px switcher and a 44px sidebar footer, as the specimen's stress route fills them |
2735
+
2736
+ `NAV_SHELL_FIXED_PX` is now defined as the sum of the two. The 185 is identical
2737
+ on both shell routes in the specimen, which is what makes it a number this
2738
+ package can defend; the rest is one consumer's markup and was being published as
2739
+ though it were ours.
2740
+
2741
+ **`navBudgetForViewport()` takes an optional second argument**, your own slot
2742
+ total. `navBudgetForViewport(900)` is unchanged.
2743
+
2744
+ ```ts
2745
+ // Your footer is a 64px two-line block, not the 44px avatar row we assumed.
2746
+ const budget = navBudgetForViewport(900, switcherPx + 64)
2747
+ ```
2748
+
2749
+ **`navContentHeight()` takes an optional second argument**, per-row costs
2750
+ measured in your own font. `navContentHeight(sections)` is unchanged.
2751
+
2752
+ ```ts
2753
+ // Measured from a rendered row's offsetHeight in your app, not estimated.
2754
+ const used = navContentHeight(sections, { itemPx: 35, sectionHeaderPx: 32 })
2755
+ ```
2756
+
2757
+ `NavRowCosts` is exported for that object.
2758
+
2759
+ #### The three assumptions, now written down
2760
+
2761
+ **1. The column is the stress specimen's, and part of it is not ours.** The
2762
+ default stays pinned there rather than moving 12px toward the Wallet's shorter
2763
+ footer, because that move is 12px *toward* a false "fits" on the strength of one
2764
+ consumer's markup. A consumer with a taller footer than either route was, and
2765
+ still is, over-budgeted — but can now say so.
2766
+
2767
+ **2. The per-row costs describe the expanded nav; the rail is out of scope.** A
2768
+ rail row is 32px, not 33 (the label goes out of flow), and a collapsed section
2769
+ renders no header element at all, so a `hasHeader: true` costs 30px the rail
2770
+ never draws. That is not modelled, deliberately: on the specimen's 13-item shape
2771
+ the rail renders 472px of content into a 587px column where the same data
2772
+ expanded renders 575px into 549px, so the expanded model over-counts a rail nav
2773
+ by 103px and under-counts its column by 38px — 141px of slack, both errors
2774
+ toward safety. Adding a `NAV_RAIL_ITEM_PX` and a `collapsed` flag on
2775
+ `NavSectionShape` would be a public API change to describe a state that cannot
2776
+ overflow. The browser check now drives the rail stage and asserts the bound, so
2777
+ it fails CI if that ever stops being true.
2778
+
2779
+ **3. The line box belongs to your font, not to this package.** `NavItem` and
2780
+ `NavSection` set no `line-height`; the computed value is `normal`, so half of
2781
+ each row cost is token-derived padding and the rest is whatever line box your
2782
+ face draws. This package ships no font. Measured on the specimen by swapping
2783
+ `--font-sans`: Inter 33/30, system-ui 33/29, Helvetica 32/29, Arial and Georgia
2784
+ 32/28 — every face shorter than Inter, so the model over-counts, the safe
2785
+ direction.
2786
+
2787
+ > [!warning] One measurement is still outstanding, and it is the unsafe
2788
+ > direction.
2789
+ > `Segoe UI` is in the fallback stack and is reported to carry a ~1.33 `normal`
2790
+ > line-height factor, which at 14px would give a ~35px row — an **under**-count,
2791
+ > the one direction this module must never err in. It is not recorded above
2792
+ > because it could not be taken: this work ran on macOS, where Segoe UI is not
2793
+ > installed and a request for it silently resolves to the fallback. Nothing has
2794
+ > been tuned to a guessed number. **A Windows consumer on the fallback stack is
2795
+ > outside the model's stated scope** (a `normal` line box of 17px or less at
2796
+ > 14px) and is protected only by `Sidebar`'s development-mode warning, which
2797
+ > measures the real DOM and is correct whatever the font does. Pass measured
2798
+ > costs to `navContentHeight()` if this is you.
2799
+
2800
+ Fixing it properly means setting an explicit `line-height` from the `--leading-*`
2801
+ tokens, the way `PageHeader` already does. That is not done here because
2802
+ `--leading-tight` at `--text-2xs` gives a 29px section header against today's 30,
2803
+ which moves three sets of screenshot baselines by a pixel — a design change that
2804
+ needs a human at the fidelity gate, not an agent making the diff green.
2805
+
2806
+ **Check your app if** you import `NAV_SHELL_FIXED_PX` and do your own arithmetic
2807
+ with it, and your sidebar's switcher or footer is not roughly the specimen's
2808
+ 42px and 44px. Nothing breaks; the answer was and remains approximate for you,
2809
+ and there is now a parameter that makes it exact.
2810
+
2811
+ ### Changed — the page header is 18px shorter in every state (B33-768)
2812
+
2813
+ `PageHeader` was taking more vertical space than the screens wanted. It is now one line
2814
+ with no subtitle and two lines with one, and **18px shorter either way**:
2815
+
2816
+ | state | was | now |
2817
+ | --- | --- | --- |
2818
+ | title only | 80.8px | **62.8px** |
2819
+ | title + subtitle | 105px | **87px** |
2820
+ | title + actions | 84px | **66px** |
2821
+
2822
+ The saving is entirely padding — 28px/24px above and below became 18px/16px. The title
2823
+ is now semibold rather than bold and carries `-0.02em`; the subtitle keeps its size and
2824
+ its 4px gap. The metrics come from the Boost Outbound desktop screens, where every page
2825
+ header already renders this way.
2826
+
2827
+ **Every screen in every consuming product moves up 18px.** Nothing else about the
2828
+ component changed — same props, same markup, same behaviour. A product that positions
2829
+ anything against the header's height, or screenshots its own pages, will see it.
2830
+
2831
+ There is still no bottom border, and that is now a decision rather than an omission: it
2832
+ answers the question B33-477 left open.
2833
+
2834
+ ### Changed — a screen may now render without a page header (B33-750)
2835
+
2836
+ `AppShellProps.pageHeader` is now optional (`pageHeader?: PageHeaderProps`). The
2837
+ frame still owns the page header and still renders it above the body; supplying
2838
+ one is what became optional. Omit it and the top bar's hairline sits directly on
2839
+ the body.
2840
+
2841
+ Relaxing, not breaking: every existing call site that passes a `pageHeader` keeps
2842
+ working unchanged. It exists because the Wallet's overview screen renders no page
2843
+ header, and the shell could not previously reproduce that state at all.
2844
+
2845
+ ### Fixed — the `AppSwitcher` trigger is named even when the current slug is unknown (B33-919)
2846
+
2847
+ `currentSlug` matching no row in `products` is a state this package supports on
2848
+ purpose: `AppSwitcherProduct.slug` is typed `string` rather than `ProductSlug` so
2849
+ an unrecognised slug degrades to a bare tile instead of failing, because Identity
2850
+ owns the product registry and can serve a product this package has no artwork for
2851
+ yet (ADR-0003). A fifth Identity product, or a `currentSlug` left stale by a
2852
+ rename, arrives there.
2853
+
2854
+ The artwork degraded correctly. The accessible name degraded with it. The mark is
2855
+ `aria-hidden` unless it is handed a title, so on the rail — where the mark is the
2856
+ button's only content — **the trigger had no accessible name at all**. A screen
2857
+ reader reached the one control in the 56px collapsed shell and announced
2858
+ "button": no product, no purpose, nothing to act on, and no way to discover that
2859
+ this was the way back to a product it recognised. Expanded, the same path
2860
+ rendered an empty name between a blank tile and a chevron, so a sighted user saw
2861
+ the absence too.
2862
+
2863
+ The trigger now falls back to a new label, **`switchProduct`**, defaulting to
2864
+ `Switch product`. It names the control rather than guessing at the product,
2865
+ because the product is exactly what this package does not know.
2866
+
2867
+ **Nothing renders differently for a `currentSlug` that matches.** The fallback is
2868
+ reachable only when the lookup fails, the three `appswitcher-*` baselines were
2869
+ captured with the Wallet current, and they do not move.
2870
+
2871
+ **`labels` now takes a `Partial<AppSwitcherLabels>`**, matching `WalletChip`.
2872
+ This is a relaxation, not a break: a consumer that wrote the object out in full
2873
+ keeps compiling, and one that supplies a subset now inherits English for the
2874
+ rest. Two things follow from it. Adding `switchProduct` to a required-field
2875
+ interface would otherwise have failed the typecheck of every app that had written
2876
+ all the fields out — an accessibility fix arriving as a compile error in four
2877
+ products on a patch bump. And a field supplied as `undefined`, which is what a
2878
+ missed next-intl key looks like at the call site, now keeps its English default
2879
+ instead of blanking the string — the same resolution `WalletChip` gained in
2880
+ B33-873.
2881
+
2882
+ ```tsx
2883
+ <AppSwitcher products={products} currentSlug={slug} hub={hub} account={account}
2884
+ labels={{ switchProduct: 'Changer de produit' }} />
2885
+ ```
2886
+
2887
+ **Check your app if** it renders the switcher with a slug that may not be in the
2888
+ list it passes — the trigger now reads "Switch product" there rather than nothing,
2889
+ and if your app is not English it should pass a translation for it.
2890
+
2891
+ ### Internal — no consumer-visible change
2892
+
2893
+ - A second specimen stage, `WalletShellSpecimen`, reproduces the Wallet's shell
2894
+ at the baselines' own 1440×900 so the two `appshell-*` baselines finally have
2895
+ something to be compared against (B33-750). Specimen only; it is not built into
2896
+ `dist`. `@tabler/icons-react` was added as a **devDependency** for its glyphs —
2897
+ icons stay a consumer's to pass, and the published package gains no dependency.
2898
+ - `useAnchoredOverlay` can now express "expanded, but not a menu": a trigger that
2899
+ carries `aria-expanded` and `aria-controls` without claiming `aria-haspopup`,
2900
+ which is what ADR-0010 asks three of the four overlays to be (B33-720). The
2901
+ hook is still absent from `src/index.ts` and is not part of the public surface
2902
+ — a consumer cannot import it by name. It is no longer absent from `dist`,
2903
+ though: `AppSwitcher` imports it (B33-479), so from that ticket onward the
2904
+ hook's code ships as the switcher's dependency. This corrects the claim this
2905
+ bullet used to make, that "nothing reaches `dist` yet", which was true only
2906
+ while nothing exported from `src/index.ts` used it.
2907
+ - `AppShell.module.css`'s `.content` comment no longer credits the page header
2908
+ with a hairline it does not draw: the page header draws no border by decision
2909
+ (B33-750), so the only rule in that column is the top bar's, and a guard in
2910
+ `tests/guards.test.ts` now ties the comment to the borders actually declared
2911
+ (B33-771). The net change under `src/` is a rewritten three-line CSS comment —
2912
+ no declaration moved and nothing renders differently.
2913
+ - The three `appswitcher-*` fidelity baselines had been parked in
2914
+ `SKIPPED_BASELINES` waiting for a component to compare against; B33-479 un-parks
2915
+ them, so the screenshot gate now diffs 27 states rather than 24. No baseline was
2916
+ regenerated.
2917
+ - The nav height budget is now checked against a real render in CI, in the
2918
+ screenshot job, because jsdom has no layout engine and cannot see a
2919
+ `scrollHeight` (B33-752, B33-760).
2920
+ - A static collision guard, `tests/token-cascade.test.ts` over
2921
+ `scripts/token-cascade.mts`, asserts this package's sheets and a conforming
2922
+ consumer token sheet declare no custom property name in common (B33-848).
2923
+ - A browser load-order check, `scripts/token-order-check.mts`, attaches both
2924
+ sheets to a real page in both orders and asserts the resolved values match. It
2925
+ runs in the `screenshot-diff` job as `npm run token-order-check`, because
2926
+ jsdom never resolves `var()` and cannot observe the cascade (B33-848).
2927
+ - `WalletChipLocaleSpecimen` shows the chip in `fr-FR`, and renders **last** in
2928
+ `specimen/main.tsx` on purpose (B33-876). The harness captures a full page and
2929
+ clips to each state's box, so a route added above a gated state moves that
2930
+ state down the page and Chromium re-rasterizes its text at a different
2931
+ subpixel offset: putting these two examples inside `WalletChipSpecimen` took
2932
+ `appswitcher-open` from 4.835% of pixels differing to 5.059%, through the 5%
2933
+ threshold, in a component that change did not touch. Rendered after the last
2934
+ gated state, they move nothing. The route is ungated by design — it carries no
2935
+ `data-testid` and has no baseline, because capturing one is a human approving a
2936
+ rendering.
2937
+ - The screenshot gate now asserts hairlines structurally, because it could not
2938
+ see them at all: `--color-border` (`#E5E5EA`) on white scores 328.0 against
2939
+ pixelmatch's 352.15 cutoff, so a 1px rule could be added to or removed from any
2940
+ gated state and the pixel count did not move (B33-772). `scripts/border-proof.ts`
2941
+ records the border edges each state draws and the harness reads the real ones
2942
+ out of the DOM at capture time. Harness only — no component changed, no
2943
+ baseline moved, and nothing in `dist/` is different. Named here rather than
2944
+ omitted because it changes what a green screenshot job means: it is now a claim
2945
+ about the package's hairlines, which it previously was not.
2946
+ - Baseline accounting now closes in both directions (B33-725). `SKIPPED_BASELINES`
2947
+ accounts for every PNG on disk; a new `SPECIMEN_ONLY` list in
2948
+ `scripts/screenshot-states.ts` accounts for every specimen section that
2949
+ deliberately has no baseline, each with a written reason, and
2950
+ `tests/specimen-coverage.test.tsx` fails if a section is in neither. Before
2951
+ this, a row could be added to the specimen with no `data-testid`, shown to
2952
+ every human who opens the page, and gated by nothing. Harness only — nothing
2953
+ in `dist` changed.
2954
+
2955
+ ## 0.2.0 — 2026-09-17
2956
+
2957
+ **The first version that is actually usable.** `0.1.0` exported `NavItem` and
2958
+ nothing else, so `npm install @boost33/ui` got you one nav row while ten
2959
+ components and the `AppShell` frame sat unpublished in the repo. This release
2960
+ ships them: `AppShell`, `Sidebar`, `TopBar`, `PageHeader`, `NavSection`,
2961
+ `NavItem`, `WalletChip`, `Wordmark`, `Boost33Mark` and `ProductMark`, plus the
2962
+ `nav-height-budget` helpers and the compiled `./style.css`.
2963
+
2964
+ Also: `npm publish` could previously ship a stale `dist/`, or nothing at all, and
2965
+ neither was visible to whoever ran the command. `prepublishOnly` now re-runs
2966
+ typecheck, test and lint and rebuilds, and a guard fails if any of the four is
2967
+ dropped from it (B33-727).
2968
+
2969
+ Held at `0.x` deliberately — see the note at the top of this file.
2970
+
2971
+ ## 0.1.0 — 2026-09-09
2972
+
2973
+ First publish. `NavItem` only.