@boost33/ui 0.5.0 → 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.
- package/CHANGELOG.md +2894 -0
- package/README.md +11 -12
- package/dist/components/AccountMenu/AccountMenu.js +66 -0
- package/dist/components/AccountMenu/AccountMenu.js.map +1 -0
- package/dist/components/AccountMenu/AccountMenu.module.css.js +42 -0
- package/dist/components/AccountMenu/AccountMenu.module.css.js.map +1 -0
- package/dist/components/AccountMenu/icons.js +32 -0
- package/dist/components/AccountMenu/icons.js.map +1 -0
- package/dist/components/AccountMenu/labels.js +14 -0
- package/dist/components/AccountMenu/labels.js.map +1 -0
- package/dist/components/CommandPalette/CommandPalette.js +65 -0
- package/dist/components/CommandPalette/CommandPalette.js.map +1 -0
- package/dist/components/CommandPalette/CommandPalette.module.css.js +34 -0
- package/dist/components/CommandPalette/CommandPalette.module.css.js.map +1 -0
- package/dist/components/CommandPalette/icons.js +23 -0
- package/dist/components/CommandPalette/icons.js.map +1 -0
- package/dist/components/CommandPalette/labels.js +19 -0
- package/dist/components/CommandPalette/labels.js.map +1 -0
- package/dist/components/LaunchLoader/LaunchLoader.d.ts +8 -4
- package/dist/components/LaunchLoader/LaunchLoader.js +22 -35
- package/dist/components/LaunchLoader/LaunchLoader.js.map +1 -1
- package/dist/components/LaunchLoader/LaunchLoader.module.css.js +16 -18
- package/dist/components/LaunchLoader/LaunchLoader.module.css.js.map +1 -1
- package/dist/components/NotificationCenter/NotificationCenter.js +67 -0
- package/dist/components/NotificationCenter/NotificationCenter.js.map +1 -0
- package/dist/components/NotificationCenter/NotificationCenter.module.css.js +54 -0
- package/dist/components/NotificationCenter/NotificationCenter.module.css.js.map +1 -0
- package/dist/components/NotificationCenter/icons.js +45 -0
- package/dist/components/NotificationCenter/icons.js.map +1 -0
- package/dist/components/NotificationCenter/labels.js +26 -0
- package/dist/components/NotificationCenter/labels.js.map +1 -0
- package/dist/components/SettingsFrame/SettingsFrame.d.ts +91 -0
- package/dist/components/SettingsFrame/SettingsFrame.js +109 -0
- package/dist/components/SettingsFrame/SettingsFrame.js.map +1 -0
- package/dist/components/SettingsFrame/SettingsFrame.module.css.js +74 -0
- package/dist/components/SettingsFrame/SettingsFrame.module.css.js.map +1 -0
- package/dist/components/SettingsFrame/icons.d.ts +10 -0
- package/dist/components/SettingsFrame/icons.js +37 -0
- package/dist/components/SettingsFrame/icons.js.map +1 -0
- package/dist/components/SettingsFrame/labels.d.ts +36 -0
- package/dist/components/SettingsFrame/labels.js +18 -0
- package/dist/components/SettingsFrame/labels.js.map +1 -0
- package/dist/index.d.ts +18 -1
- package/dist/index.js +46 -32
- package/dist/index.js.map +1 -1
- package/dist/launch-gradient.d.ts +27 -103
- package/dist/launch-gradient.js +4 -56
- package/dist/launch-gradient.js.map +1 -1
- package/dist/style.css +1 -1
- 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.
|