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