@particle-academy/react-fancy 5.29.1 → 5.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +1722 -0
  2. package/dist/Card-CuRZivBz.d.cts +136 -0
  3. package/dist/Card-CuRZivBz.d.ts +136 -0
  4. package/dist/card.cjs +12 -6
  5. package/dist/card.d.cts +50 -3
  6. package/dist/card.d.ts +50 -3
  7. package/dist/card.js +3 -1
  8. package/dist/chunk-5F6V5HHT.cjs +399 -0
  9. package/dist/chunk-5F6V5HHT.cjs.map +1 -0
  10. package/dist/{chunk-Y3T7WMT6.cjs → chunk-65HKNZFG.cjs} +2 -2
  11. package/dist/{chunk-Y3T7WMT6.cjs.map → chunk-65HKNZFG.cjs.map} +1 -1
  12. package/dist/chunk-7MSIREUY.cjs +186 -0
  13. package/dist/chunk-7MSIREUY.cjs.map +1 -0
  14. package/dist/{chunk-2HGYDDIQ.js → chunk-ASCATIDJ.js} +3 -3
  15. package/dist/{chunk-2HGYDDIQ.js.map → chunk-ASCATIDJ.js.map} +1 -1
  16. package/dist/{chunk-6GWOB2Z4.js → chunk-CESGP5AF.js} +2 -2
  17. package/dist/{chunk-6GWOB2Z4.js.map → chunk-CESGP5AF.js.map} +1 -1
  18. package/dist/chunk-CF2U25LM.js +392 -0
  19. package/dist/chunk-CF2U25LM.js.map +1 -0
  20. package/dist/chunk-CFH6CJQG.js +3 -0
  21. package/dist/chunk-CFH6CJQG.js.map +1 -0
  22. package/dist/chunk-FJKZ5NOA.cjs +4 -0
  23. package/dist/chunk-FJKZ5NOA.cjs.map +1 -0
  24. package/dist/chunk-NH5XJ6VD.js +184 -0
  25. package/dist/chunk-NH5XJ6VD.js.map +1 -0
  26. package/dist/{chunk-FEUAZW3X.cjs → chunk-SCGE6DCE.cjs} +3 -3
  27. package/dist/{chunk-FEUAZW3X.cjs.map → chunk-SCGE6DCE.cjs.map} +1 -1
  28. package/dist/composer.cjs +2 -2
  29. package/dist/composer.d.cts +34 -1
  30. package/dist/composer.d.ts +34 -1
  31. package/dist/composer.js +1 -1
  32. package/dist/heading.cjs +3 -2
  33. package/dist/heading.js +2 -1
  34. package/dist/index.cjs +28 -27
  35. package/dist/index.d.cts +2 -2
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +6 -5
  38. package/dist/json-editor.cjs +13 -13
  39. package/dist/json-editor.js +2 -2
  40. package/docs/Card.md +170 -20
  41. package/package.json +6 -3
  42. package/dist/Card-BZ5F6-wd.d.cts +0 -50
  43. package/dist/Card-BZ5F6-wd.d.ts +0 -50
  44. package/dist/chunk-5DQ2XUJI.cjs +0 -173
  45. package/dist/chunk-5DQ2XUJI.cjs.map +0 -1
  46. package/dist/chunk-OGEE23SP.cjs +0 -83
  47. package/dist/chunk-OGEE23SP.cjs.map +0 -1
  48. package/dist/chunk-VVL5LMLW.js +0 -81
  49. package/dist/chunk-VVL5LMLW.js.map +0 -1
  50. package/dist/chunk-ZIDBI6JX.js +0 -167
  51. package/dist/chunk-ZIDBI6JX.js.map +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,1722 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@particle-academy/react-fancy` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ > **History before 4.16.0 is not yet recorded here.** This file starts at the
9
+ > release that introduced it; earlier versions are described by their git tags
10
+ > and GitHub releases. Backfilling is tracked separately.
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [5.31.0] - 2026-10-02
15
+
16
+ ### Added
17
+
18
+ - **`Composer` can hold a large paste beside the input as a pill instead of
19
+ inserting it.** Set `pasteThreshold={500}` (characters) to enable.
20
+
21
+ Paste a transcript into a composer and it becomes a scrolling wall: the message
22
+ you were half way through writing is above the fold, and the only way to see what
23
+ you are about to send is to scroll your own input.
24
+
25
+ - over the threshold, the paste is held and shown as `Pasted text · 2,036 characters`
26
+ - **hover** previews the first and last 20 characters, middle elided — the middle
27
+ is what made it unreadable, the ends are what tell you which paste this is when
28
+ there are several
29
+ - **click** inserts it, appended to what is already typed (people type the
30
+ question *before* pasting the thing it is about), and removes the pill
31
+ - **×** discards one without inserting it
32
+ - **a send with pills still closed CARRIES the text**, in arrival order, after
33
+ what was typed
34
+
35
+ That last point is the one worth knowing about. A pill reads as *"your paste is
36
+ attached"*, so a send that dropped it would mean asking a question about a
37
+ document without the document — with nobody able to tell, because the sender
38
+ believes it went and the receiver sees a question about nothing. `onSubmit`
39
+ receives everything; the pills clear.
40
+
41
+ Handles: `data-react-fancy-composer-paste-pill`, `-paste-pill-preview`,
42
+ `-paste-pill-remove`, `-paste-pills`.
43
+
44
+ The pill carries an `aria-label` with the count and an `aria-describedby`
45
+ pointing at its preview, and the discard control is labelled — a bare `×` is
46
+ otherwise announced as "times", which is not an action. The pill deliberately has
47
+ no `title`: a native tooltip plus the hover preview renders two tooltips on one
48
+ hover, at different delays.
49
+
50
+ Held pastes are controllable — `heldPastes` + `onHeldPastesChange` — per the
51
+ component contract, which forbids internal-only state for anything an agent might
52
+ need to read or write. Omit them and the component keeps its own.
53
+
54
+ **What you must DO: nothing.** `pasteThreshold` is **opt-in and has no default**,
55
+ so paste behaves exactly as it always has unless you ask for this. That is
56
+ deliberate rather than cautious: paste is a fundamental interaction and this
57
+ package is past 1.0, so a minor release must not quietly change what it does for
58
+ every consumer. Below the threshold nothing happens at all — no prevented event,
59
+ no pill, no ceremony for an address.
60
+
61
+ Specified by the MOIC estate, who had already built and proven it against their
62
+ own hand-rolled composer and sent the spec rather than a patch because they are
63
+ not on this component. The dangerous-state analysis above is theirs.
64
+
65
+ ### Fixed
66
+
67
+ - **`CHANGELOG.md` is now in the published tarball.** `files` did not whitelist it, so npm never shipped it: a consumer who followed a link to the changelog — from the README, from npm, or from an upgrade guide — found nothing. Nothing for you to do; the file simply arrives from this release on.
68
+
69
+ ## [5.30.0] - 2026-09-30
70
+
71
+ **Nothing here will stop your build.** Every prop below is new and optional,
72
+ and every default was chosen so a `Card` written before this release renders
73
+ byte-identically — `size` defaults to `md`, `sections` to `divided`,
74
+ `highlight` to `false`. No API changed, nothing was renamed, nothing was
75
+ removed. If you do not pass a new prop, there is nothing to do.
76
+
77
+ `Card` was measured against Flux UI's card and this closes the feature gap.
78
+
79
+ ### Added
80
+
81
+ - **`Card.Header` and `Card.Footer` compose from props.** `heading`,
82
+ `headingLevel`, `description` and `actions` lay a header out without a
83
+ wrapper from the caller. This suite's own showcase had two pages writing
84
+ `<div style={{ display: "flex", justifyContent: "space-between" }}>` INSIDE a
85
+ `Card.Header` — inline styles, in the app whose job is to demonstrate the
86
+ kit, across 136 headers, because nothing in the kit laid one out.
87
+
88
+ `children` still works and **wins**: pass it and the three props are ignored
89
+ rather than rendered alongside, so a caller who sets both gets one title
90
+ instead of two.
91
+
92
+ - **`sections="divided" | "plain" | "banded"`** and **`dividerInset`** — how
93
+ header and footer are set apart: a hairline rule (today's behaviour and the
94
+ default), nothing at all, or a tinted band. `dividerInset` keeps the rules but
95
+ stops them at the content's edge.
96
+
97
+ `divided` is the default because it is what Card has always rendered.
98
+ Seamless is arguably the nicer default — Flux chose it — but defaulting to it
99
+ here would have silently restyled every card in every consumer.
100
+
101
+ - **`size="xs" | "sm" | "md" | "lg"`** — scales padding and corner radius
102
+ together, and is inherited by `Card.Media` and `Card.Bleed`. `padding` stays
103
+ authoritative when you pass it, including `padding="none"`.
104
+
105
+ - **`variant` gained `muted` and `soft`** — tint steps between `outlined` (no
106
+ tint) and `flat`. `flat` is NOT renamed to Flux's `filled`: the name is public
107
+ API and a rename buys nothing.
108
+
109
+ - **`highlight`** — a 1px hairline just inside the top edge, white at 70% in
110
+ light mode and white at 10% in dark. Default `false`, deliberately unlike
111
+ Flux's `true`; turning a decorative hairline on by default would have changed
112
+ the look of every existing card.
113
+
114
+ - **`Card.Bleed`** — runs content out to the card's edges (an image, a table, a
115
+ chart strip), with `edges="x" | "top" | "bottom" | "all"`.
116
+
117
+ - **`Card.Header` and `Card.Footer` work standalone**, outside a `Card`, as a
118
+ section heading above one. They take their own `size` there, since there is
119
+ no card to inherit one from.
120
+
121
+ - **`CardMediaProps`, `CardBleedProps`, `CardSectionProps`, `CardSize`,
122
+ `CardPadding`, `CardSections` and `CardBleedEdges` are exported** from the
123
+ package root. `CardMediaProps` had never been, so `Card.Media`'s props could
124
+ not be typed by a consumer wrapping it.
125
+
126
+ - **`tests/card-tailwind.test.tsx`** renders every Card permutation, harvests
127
+ the class names it produced, and compiles them through the real Tailwind. A
128
+ class that is in the DOM and in no stylesheet now fails the build. See
129
+ *Fixed*, below, for why that check had to exist.
130
+
131
+ ### Changed
132
+
133
+ - **`Card.Media`'s top radius follows the card's `size`** instead of hardcoding
134
+ `rounded-t-lg`. A `size="lg"` card previously put a `rounded-xl` frame around
135
+ a `rounded-t-lg` image and showed a sliver of card through each top corner.
136
+ Unchanged at `md`, which is the class it used to hardcode and the default.
137
+
138
+ - **A standalone `Card.Header` / `Card.Footer` no longer draws a rule.** It
139
+ previously kept the `border-bottom` belonging to a card it was not inside.
140
+ This is the one visible change to existing markup, and only for a section
141
+ rendered outside a `Card` — which was a broken-looking state, not a style
142
+ anyone chose.
143
+
144
+ ### Fixed
145
+
146
+ These were all found reviewing the additions above before release, so no
147
+ released version carries them. They are recorded because each is a shape worth
148
+ recognising.
149
+
150
+ - **An inset divider and a `Card.Bleed` were measured from `size`, not from the
151
+ padding actually applied.** The two agree until someone passes `padding`, and
152
+ then they disagree by a whole step: `<Card size="md" padding="lg">` drew a
153
+ rule inset 1rem over content inset 1.5rem, and `<Card padding="none">` gave a
154
+ bleed a `-1rem` margin that carried it a centimetre PAST the card's edge and
155
+ hung the content outside the border. Both now read the resolved padding step
156
+ from context.
157
+
158
+ - **A `Card.Bleed` inside a *standalone* header did nothing.** The section
159
+ published "am I inside a card" where the bleed needed "how much padding is
160
+ above me" — and a standalone header pads itself, so the answer was `false`
161
+ when the padding was real.
162
+
163
+ - **`highlight` was a no-op in dark mode.** It carried
164
+ `dark:before:bg-transparent`, so the prop did nothing at all in the mode that
165
+ needs it most — a dark surface has no border contrast of its own, which is
166
+ why Flux turns its equivalent on by default.
167
+
168
+ ### Not copied from Flux, on purpose
169
+
170
+ - **The `--flux-bleed-*` CSS-variable protocol.** Flux's padding sits on the
171
+ CARD, so a descendant has no way to know how far it must reach, and it needs
172
+ published custom properties to tell it — a public CSS API to version forever.
173
+ Ours sits on the card's direct CHILDREN, so the distance is always exactly one
174
+ padding step and `Card.Bleed` can look it up. Arithmetic instead of a contract.
175
+
176
+ - **`body="inset"` / `body="flush"`.** A nested `<Card variant="soft">` already
177
+ composes an inset body, and `Card.Bleed` already composes a flush one. A prop
178
+ for it would be a second way to say the same thing.
179
+
180
+ - **A `Card.Heading` / `Card.Subheading` pair.** `Heading` already ships an
181
+ 11-step size ramp. Forking a card-local copy would give two answers to "how
182
+ big is a heading", and they would drift.
183
+
184
+
185
+ ## [5.29.1] - 2026-09-29
186
+
187
+ ### Fixed
188
+
189
+ - **Documented how to theme `color="brand"` per mode, in the file a consumer
190
+ actually receives.** 5.29.0 explained the three brand variables in a comment
191
+ in `src/styles.css` — and the build emits `@theme` without comments, so none
192
+ of it reached anyone who installed the package. The guidance now lives in
193
+ `README.md`, which ships.
194
+
195
+ The part worth having: **`--color-brand` and `--color-brand-contrast` are a
196
+ pair and must turn over together.** Repointing one for dark mode is the
197
+ obvious move and it fails QUIETLY — the surface goes pale while the label
198
+ stays white on top of it, and on `ghost` / `outline`, where brand is the
199
+ label rather than the surface, a brand that stayed dark puts dark text on a
200
+ near-black background. Nothing errors; it is simply unreadable.
201
+
202
+ ```css
203
+ .dark {
204
+ --color-brand: #BFD7EA;
205
+ --color-brand-contrast: #003057;
206
+ }
207
+ ```
208
+
209
+ `--color-brand-hover` usually needs no per-mode value.
210
+
211
+ Reported by the consumer who drove #30, from five ghost buttons that made it
212
+ real rather than theoretical. No code changed.
213
+
214
+ ## [5.29.0] - 2026-09-29
215
+
216
+ ### Added
217
+
218
+ - **`color="brand"` on every component that takes a colour** — `Button`,
219
+ `Badge`, `Callout`, `Progress`, `Switch` and `Timeline.Item`. Requested in
220
+ #30 by a consumer with 164 call sites reading `color="violet"` to mean "our
221
+ primary action"; their brand is navy, so every one of those was indigo-ish,
222
+ and `ButtonColor` was a closed union of the 22 Tailwind hue names with no
223
+ escape hatch.
224
+
225
+ `brand` is not a hue. It resolves through **three** CSS variables a host sets
226
+ once:
227
+
228
+ ```css
229
+ :root {
230
+ --color-brand: #003057;
231
+ --color-brand-contrast: #ffffff; /* text/icons ON the brand colour */
232
+ --color-brand-hover: #D4A017; /* need NOT be a shade of brand */
233
+ }
234
+ ```
235
+
236
+ **Three rather than one, and that is the whole design.** A generated hover
237
+ assumes "same hue, darker". A brand palette need not work that way — the
238
+ reporter's hover is gold on a navy brand, which no amount of arithmetic
239
+ recovers from navy. So the host states the hover instead of the kit guessing
240
+ it. The tinted variants (`outline`, `ghost`, `soft`) derive from the same
241
+ three via opacity modifiers, which is what lets three variables cover four
242
+ variants without asking anyone for a whole 50–950 scale.
243
+
244
+ Defaults keep the kit's violet, so a host that sets nothing sees no change.
245
+
246
+ `--color-brand` had existed in `styles.css` since before this release with
247
+ nothing in the kit reading it. That was not quite a dead token — Tailwind v4's
248
+ `@theme` generates `bg-brand` / `text-brand` utilities from it, so a host could
249
+ already paint its own markup. The gap was one-directional: nothing in the kit
250
+ could be pointed at it.
251
+
252
+ ### Changed
253
+
254
+ - **`Color` (and therefore `ButtonColor`) gained the member `"brand"`.** Runtime
255
+ behaviour is purely additive and every existing hue is untouched.
256
+
257
+ **What you must do:** nothing, unless you wrote an exhaustive
258
+ `Record<Color, …>` or a `switch` over `Color` in your own code. Those will now
259
+ fail to compile with a missing `brand` case, which is the compiler correctly
260
+ telling you there is a colour you do not handle. Add the one entry. If you
261
+ would rather not, type your map against the hue names directly instead of
262
+ against our union.
263
+
264
+ `COLORS` also gained `"brand"`, since it is documented as every value of
265
+ `Color` and is what an exhaustive map is meant to be built from.
266
+
267
+ ## [5.28.1] - 2026-09-28
268
+
269
+ ### Fixed
270
+
271
+ - **`contentEditableAdapter.replaceRange` flattened the element, so `InputTag`
272
+ could only ever insert ONE tag into a contenteditable that renders anything.**
273
+ Reported in #29 by a consumer whose entire surface is a contenteditable: the
274
+ first pill vanished on the second insert, and the characters around the insert
275
+ were eaten, because `start` / `end` index the visible text while the host's
276
+ `insert()` returns a marker of a different length — so the slice drifted by
277
+ the difference every time.
278
+
279
+ The cause was `el.textContent = next`, which destroys every element child.
280
+ `caretIndex()` and `setCaret()` already node-walked correctly; `replaceRange`
281
+ was the one place reaching for `textContent` as BOTH reader and writer, and
282
+ reading a flattened string is fine while writing one back is what threw the
283
+ elements away.
284
+
285
+ It now resolves both indices through the same walk `setCaret` uses (extracted
286
+ as a shared `locate`) and edits through a `Range` — `deleteContents()` +
287
+ `insertNode()` — so nodes outside the range survive by construction, and the
288
+ caret anchors on the node just written rather than on an offset that has
289
+ moved.
290
+
291
+ **What you must do: nothing.** A plain-text contenteditable behaves exactly as
292
+ before; this only adds behaviour where element children exist, which is the
293
+ case that used to lose them. If you wrote your own adapter to work around
294
+ this, you can delete it.
295
+
296
+ ## [5.28.0] - 2026-09-28
297
+
298
+ ### Fixed
299
+
300
+ - **`FileUpload`'s `accept` and `multiple` props were wired to nothing.** Both
301
+ were declared on `FileUploadProps`, so they were real props to anyone reading
302
+ the types or asking an agent to compose the component. Neither was
303
+ destructured in the root, neither reached the hidden `<input>`, and neither was
304
+ consulted when files were added. `accept="image/*"` filtered nothing in the
305
+ browse dialog and accepted any dropped file; `multiple={false}` sat beside an
306
+ input that hard-coded `multiple`.
307
+
308
+ **`accept` is now enforced on BOTH paths, and the drop path is the one that
309
+ matters.** A browser enforces `accept` in the file picker only — a
310
+ drag-and-drop never opens a picker, so forwarding the attribute alone would
311
+ still have let a `.exe` dropped onto an image-only zone through. A new
312
+ `matchesAccept` implements the HTML `accept` grammar (extensions, exact MIME
313
+ types, `type/*` wildcards, case-insensitive) and filters dropped files with it.
314
+ Extension rules match the file NAME, because a dropped file frequently reports
315
+ an empty `File.type` — that is the common case, not an edge one.
316
+
317
+ **What a consumer must do: check whether you pass `accept` to `FileUpload`.**
318
+ If you do, files that previously got through will now be refused — which is
319
+ what you asked for, but it is a real behaviour change rather than a no-op, and
320
+ it is why this is a minor rather than a patch. If you were relying on the prop
321
+ being ignored, remove it. If you pass neither prop, nothing changes: an absent
322
+ `accept` constrains nothing and `multiple` still defaults to `true`.
323
+
324
+ `multiple={false}` now sets the input attribute and caps the selection at one
325
+ file, applied exactly the way `maxFiles` is (first-wins, not replace-last) so
326
+ the two props cannot disagree about what a single-file upload means.
327
+
328
+ `FileUpload` had **no tests at all**; it now has eight, four of which fail
329
+ against 5.27.1.
330
+
331
+ ## [5.27.1] - 2026-09-20
332
+
333
+ ### Fixed
334
+
335
+ - **A wide `leading` / `trailing` no longer renders on top of the value.**
336
+ These adornments are absolutely positioned over the input, so they take no
337
+ space in flow and the input is padded by hand to keep text clear of them. That
338
+ padding was a fixed `pl-9` / `pr-9` — 36px, sized for a single icon — while the
339
+ adornment sits at `left-3` (12px), leaving 24px of room.
340
+
341
+ `leading="https://"` is roughly 50px, so the protocol and the value drew on top
342
+ of one another. Reported on the live showcase.
343
+
344
+ The width is now **measured** rather than assumed, because these props accept
345
+ arbitrary content — a currency code, a unit, a protocol, a short label — and no
346
+ fixed padding can be right for all of it. A `ResizeObserver` re-measures when a
347
+ webfont swaps in or the adornment's content changes.
348
+
349
+ **What to do:** nothing. The fixed padding remains the pre-measure fallback, so
350
+ server-rendered and first-paint output is unchanged, and an icon-sized
351
+ adornment lands on the same 36px it always did.
352
+
353
+ ## [5.27.0] - 2026-09-14
354
+
355
+ ### Added
356
+
357
+ - **`<PromptInput>` attaches a pasted image.** Pasting a screenshot into the
358
+ composer now adds it as an attachment, with the `File` and its MIME type, just
359
+ like a drop or the picker. There was no paste handler at all, so a pasted image
360
+ did nothing: no chip and no error. A host that swapped its own paste-capable
361
+ textarea for `PromptInput` (flabs did) lost the feature without noticing.
362
+
363
+ Files attach only when the clipboard carries no plain text. Word, Excel and
364
+ most editors put a picture of the selection beside the text, and attaching
365
+ that would turn every pasted sentence into a screenshot of the sentence. A text
366
+ paste is left entirely to the browser.
367
+
368
+ **What you must do:** nothing. If your host already handled `paste` on an
369
+ element wrapping `PromptInput`, an image paste now attaches here as well, so
370
+ remove your handler or you will get the image twice.
371
+
372
+ ## [5.26.0] - 2026-08-23
373
+
374
+ ### Fixed
375
+
376
+ - **`<Callout>` forwards its rest props** (#28). It destructured its six named
377
+ props and spread nothing, so everything else was accepted and discarded:
378
+
379
+ ```tsx
380
+ // Compiled. Rendered. The attribute was nowhere in the DOM.
381
+ <Callout data-my-handle="x" color="red">…</Callout>
382
+ ```
383
+
384
+ **TypeScript could not catch this.** A hyphenated JSX attribute is always
385
+ allowed and never checked against a component's props type, so `data-*` on a
386
+ Callout typechecked and vanished — accepted silently and dropped silently,
387
+ with no signal at compile time or run time. It also made Callout the odd one
388
+ out: `Badge`, `Button`, `Table.Cell`, `Table.Row` and `Table.Column` all
389
+ already spread rest.
390
+
391
+ Found building `fancy-trading-ui`, where **four** agent handles were dead for
392
+ this reason — each with a passing test, because the tests asserted on
393
+ rendered text rather than on the handle.
394
+
395
+ `CalloutProps` now extends `HTMLAttributes<HTMLDivElement>`, and the spread
396
+ sits last, so `id`, `aria-*`, event handlers and `role` all reach the element.
397
+ `role="alert"` remains the default and is now overridable — a static
398
+ informational callout no longer has to announce itself assertively.
399
+
400
+ **What you must do: nothing.** This only adds props that were previously
401
+ discarded. If you worked around it by moving a handle onto a wrapping
402
+ element, that still works; you can now move it back.
403
+
404
+
405
+ ## [5.25.0] - 2026-08-23
406
+
407
+ ### Fixed
408
+
409
+ - **`<Card>` padding now reaches every direct child, not only `<div>`
410
+ children.** `padding` compiled to `[&>div]:px-4`, so the TAG a caller
411
+ happened to choose silently decided whether that child was padded. A card
412
+ mixing element types came out half-padded:
413
+
414
+ ```tsx
415
+ <Card>
416
+ <div>Backend & headless</div> {/* inset 16px */}
417
+ <p>PHP / Node packages…</p> {/* flush against the border */}
418
+ </Card>
419
+ ```
420
+
421
+ Nothing in the caller's code hinted at the rule, so the result read as a bug
422
+ in the card rather than in the markup — it was found on the showcase's
423
+ `/packages` page, where a description paragraph and a footer link sat hard
424
+ against the border while the header and thumbnail strip beside them were
425
+ correctly inset.
426
+
427
+ **What you must do: nothing, unless you were compensating for this.** Any
428
+ non-`<div>` direct child of a `Card` now picks up the card's padding. If you
429
+ had added your own `padding-inline` to such a child to work around the gap,
430
+ remove it or you will get the inset twice. If you genuinely want a
431
+ full-bleed child, use the same escape hatch `Card.Media` uses — `!px-0
432
+ !py-0` — which keeps working and is now reachable from any tag.
433
+
434
+
435
+ ## [5.24.0] - 2026-08-20
436
+
437
+ Both from putting `<Progress>` on the showcase's player level meter, filed as
438
+ #26 and #27. They are one shape — the component decided everything and the
439
+ caller could not reach past the outer wrapper — and they failed differently.
440
+
441
+ ### Added
442
+
443
+ - **`trackClassName` and `fillClassName` on `<Progress>`** (#27). `className`
444
+ reached only the outermost of three nested elements, so the track
445
+ (`bg-zinc-200`) and the fill were unreachable, and `color` takes a single
446
+ palette name — a gradient was not expressible at all. The showcase shipped a
447
+ solid colour in place of its brand gradient rather than reach in with
448
+ `className="[&>div>div]:…"`, which hard-codes this component's DOM shape into
449
+ a consumer and breaks silently, still rendering but unstyled, the first time a
450
+ wrapper is added.
451
+
452
+ Named for the ROLE, not the element, so one vocabulary covers both variants:
453
+ on `variant="circular"` they land on the ring's background and value circles.
454
+
455
+ ```tsx
456
+ <Progress value={xp} max={next} fillClassName="bg-[linear-gradient(90deg,#7dd3fc,#818cf8_55%,#c4b5fd)]" />
457
+ ```
458
+
459
+ ### Fixed
460
+
461
+ - **`<Progress>` can be named, and can say what its number means** (#26). It
462
+ announced as *"progress bar, 42%"* — 42% of what was not recoverable from the
463
+ control. `ProgressProps` was a closed interface and the component had **no
464
+ rest spread**, so `aria-label`, `aria-labelledby` and `aria-valuetext` had no
465
+ route in at either the type or the runtime level. Unlike #24 this needed both.
466
+
467
+ `ProgressProps` now extends `HTMLAttributes<HTMLDivElement>` (minus `color`,
468
+ which here is a palette name rather than the legacy HTML attribute) and the
469
+ rest props are spread onto the `progressbar` element in both variants. That
470
+ brings `aria-valuetext` with it, which is the right answer for a meter whose
471
+ scale is not a percentage:
472
+
473
+ ```tsx
474
+ <Progress
475
+ value={1240} max={2500}
476
+ aria-labelledby="level-heading"
477
+ aria-valuetext="1,240 of 2,500 XP to Level 6"
478
+ />
479
+ ```
480
+
481
+ **Nothing to do on upgrade** — additive, and a bar with no ARIA props renders
482
+ exactly as before.
483
+
484
+ Not done, and deliberately left open on #27: the custom-property route
485
+ (`--fancy-progress-fill` and friends) for consumers who theme centrally rather
486
+ than per-call-site. That is a kit-wide decision about how components expose
487
+ theming hooks, and #23 is the reason not to add one casually — a property that
488
+ is set but never read is worse than none, because the doc comment then
489
+ promises a hook that does nothing.
490
+
491
+
492
+ ## [5.23.1] - 2026-08-20
493
+
494
+ ### Fixed
495
+
496
+ - **`<Grid>` now applies its own gutter, because 5.23.0's column cap could be
497
+ wrong by exactly one column.** That release drew the gutter with a Tailwind
498
+ `gap-*` class and hard-coded the matching length beside it for the cap
499
+ arithmetic. Those are two values for one thing, and in the showcase they
500
+ disagreed by 2px — the class resolved to 14px in context while the arithmetic
501
+ assumed 12px. That was enough: N tracks plus N-1 gaps overflowed the row, and
502
+ `auto-fit` responded by fitting N-1. A grid asked for 3 rendered 2, with both
503
+ numbers individually defensible and nothing to see in the markup.
504
+
505
+ The gutter now comes from `--fancy-grid-gap`, the same property the cap
506
+ divides by, so the spacing in effect and the spacing in the maths cannot
507
+ disagree.
508
+
509
+ **If you override a `<Grid>`'s gutter with a `gap-*` class, that stops
510
+ working** — the grid sets `gap` itself now, and an inline style beats a class.
511
+ Set `--fancy-grid-gap` instead, which moves the gutter AND keeps the column
512
+ cap correct:
513
+
514
+ ```tsx
515
+ <Grid cols={3} style={{ "--fancy-grid-gap": "2rem" } as CSSProperties}>
516
+ ```
517
+
518
+ The `gap` prop (`"sm" | "md" | "lg"`) is unchanged and is still the normal way
519
+ to ask for a gutter.
520
+
521
+ Found by the browser, not the suite: jsdom cannot compute grid layout, so the
522
+ tests could confirm the template referenced the property but never that the
523
+ resulting column count was right.
524
+
525
+
526
+ ## [5.23.0] - 2026-08-20
527
+
528
+ All three of these were found by one consumer — the showcase's `/packages`
529
+ rebuild — and filed as #23, #24 and #25.
530
+
531
+ ### Fixed
532
+
533
+ - **`<Grid cols>` now does something while `responsive` is on.** It did not.
534
+ The responsive track template was a fixed
535
+ `repeat(auto-fit, minmax(min(100%, 16rem), 1fr))` with no reference to the
536
+ column count, so a grid asked for 2 and a grid asked for 5 laid out
537
+ identically, and the only way to get a real count was `responsive={false}` —
538
+ which then had no breakpoints at all. `cols` is now a CEILING: at most that
539
+ many, fewer as the grid narrows.
540
+
541
+ **This will change how existing grids look, so check the ones you have.** Any
542
+ `<Grid>` wider than `cols × 16rem` was rendering MORE columns than it asked
543
+ for and will now cap correctly — that is the fix, but it is a visible change
544
+ and it is the reason this entry leads. Grids at or below that width are
545
+ unaffected. If you actually wanted "as many as fit", say so explicitly with a
546
+ high `cols`.
547
+
548
+ `--fancy-grid-cols` is now genuinely load-bearing. The doc comment has always
549
+ advertised it as the single place to override column behaviour in CSS; it was
550
+ set by the component and read by nothing, so overriding it did nothing. A
551
+ companion `--fancy-grid-gap` is published for the same reason — the cap has to
552
+ know the gutter, or N tracks plus N-1 gaps overflow the row and `auto-fit`
553
+ quietly drops to N-1.
554
+
555
+ The existing test named *"lays out the requested columns"* passed throughout,
556
+ because it asserted only that the property was **set**. Two tests now pin that
557
+ the template **reads** it.
558
+
559
+ - **`MultiSwitch` accepts the DOM attributes it already forwards.** It spreads
560
+ its rest props onto the container, so `style`, `data-*` and `aria-*` all
561
+ worked at runtime — while `MultiSwitchProps` extended `InputBaseProps` alone
562
+ and the compiler rejected every one of them. Implemented and unreachable.
563
+ `MultiSwitchProps` now also extends `HTMLAttributes<HTMLDivElement>` (minus
564
+ `onChange`, which would offer a handler that never fires — this control
565
+ reports through `onValueChange`).
566
+
567
+ **Nothing to do on upgrade.** Purely a widening: props you could already pass
568
+ still typecheck.
569
+
570
+ - **A labelled `MultiSwitch` or `RadioGroup` is now actually named.** `<Field>`
571
+ renders `<label for=…>`, and a `<label>` only names *labelable* elements —
572
+ input, select, textarea, button. Both of these controls are
573
+ `div[role="radiogroup"]`, which is not one, so the label sat beside an unnamed
574
+ group and looked like it had done its job. (`RadioGroup` passed no `htmlFor`
575
+ at all, so its label named nothing whatsoever.) Both now point
576
+ `aria-labelledby` at the rendered label.
577
+
578
+ **Nothing to do on upgrade** — unless you added your own `aria-label` to work
579
+ around this, in which case yours still wins and you can now drop it.
580
+
581
+ ### Added
582
+
583
+ - **`labelHidden` on every `Field`-wrapped input** — `Input`, `Textarea`,
584
+ `Select`, `DatePicker`, `Slider`, `CheckboxGroup`, `RadioGroup`,
585
+ `MultiSwitch`. Keeps the label in the accessibility tree and out of the
586
+ layout.
587
+
588
+ For a control whose options already say what it does — a filter toolbar's
589
+ segmented switches — where a visible label is noise but an unnamed control is
590
+ a bug. Before this the choices were an unnamed control or a label the design
591
+ does not have, and the silent one is the one that ships.
592
+
593
+ It is `labelHidden` and not `hideLabel` because `Input` has used `hideLabel`
594
+ since the reveal toggle shipped, for that toggle's accessible label ("Hide
595
+ password"). Different concept, and renaming a 5.x prop to free the name would
596
+ be breaking for the sake of a nicer word.
597
+
598
+
599
+ ## [5.22.0] - 2026-08-19
600
+
601
+ ### Changed
602
+
603
+ - **`clsx` is no longer a dependency.** It was a *runtime* dependency, so it
604
+ shipped to every consumer of this package, and its last commit was **801 days**
605
+ old. Under the suite's rule that third-party code must be both approved and
606
+ actively maintained, it fails — and it is roughly twenty lines of logic that
607
+ owning costs less than carrying.
608
+
609
+ `cn()` behaves identically. Its contract was pinned by tests written against
610
+ `clsx` *before* the swap, and those same tests pass unchanged after it:
611
+ falsy values dropped (including `0` and `""`), truthy numbers kept, arrays
612
+ flattened recursively, object keys taken when truthy.
613
+
614
+ `tailwind-merge` deliberately stays. Deciding which of two conflicting
615
+ Tailwind utilities wins requires modelling the whole utility space, which is
616
+ emphatically not twenty lines, and it is actively maintained.
617
+
618
+ **Nothing to do on upgrade.** `ClassValue` is now exported from this package
619
+ rather than re-exported from `clsx`; if you imported the type from `clsx`
620
+ yourself, that is still your own dependency and unaffected.
621
+
622
+
623
+ ## [5.21.0] - 2026-08-18
624
+
625
+ ### Added
626
+
627
+ - **Light / dark / system theming, in the kit rather than in every app.** This
628
+ package already owned the dark contract -- `styles.css` redefines the whole
629
+ `--color-secondary-*` scale under `:where(.dark)` -- but nothing here decided
630
+ when `.dark` was on, so every consumer hand-rolled that half. Every hand-rolled
631
+ copy we have looked at had the same two holes:
632
+
633
+ - **No live listener.** The OS preference was read once at boot, so changing
634
+ the system theme with the page open did nothing. That is a default, not a
635
+ system mode.
636
+ - **No way back.** Choosing light or dark wrote to storage forever, leaving
637
+ "follow my system" unreachable after a single click.
638
+
639
+ Both come from storing `"system"` as a third value. Here it is instead the
640
+ *absence* of a stored choice, which makes following the OS the natural
641
+ resting state.
642
+
643
+ ```ts
644
+ import { initTheme, useTheme } from "@particle-academy/react-fancy";
645
+
646
+ initTheme(); // once, as early as possible
647
+
648
+ const { preference, resolved, setPreference } = useTheme();
649
+ ```
650
+
651
+ `initTheme()` applies the theme and starts following the OS, returning a
652
+ disposer. `useTheme()` re-renders on change -- including when the OS flips
653
+ underneath a `system` preference. Also exported: `getThemePreference`,
654
+ `setThemePreference`, `resolveTheme`, `subscribeTheme`, `THEME_STORAGE_KEY`.
655
+
656
+ **Nothing to do on upgrade** -- this is additive, and nothing here applies a
657
+ theme unless you call `initTheme()`. If you already hand-roll this, the
658
+ storage key defaults to `fancy-ui.theme`, which is the one the showcase used,
659
+ so adopting it keeps a returning visitor's saved choice rather than resetting
660
+ it. Pass `initTheme({ storageKey })` if yours differs.
661
+
662
+ Every export is SSR-safe, and `useTheme` renders `system` / `light` on the
663
+ server and syncs in an effect -- reading the real theme during render is the
664
+ hydration mismatch this project has already chased down more than once.
665
+
666
+ ## [5.20.0] - 2026-08-11
667
+
668
+ ### Added
669
+
670
+ - **`preload` on `AudioViewer` and `VideoViewer`**, defaulting to `"metadata"`.
671
+
672
+ Both wrapped a media element and forwarded `controls`, `autoPlay` and `loop`
673
+ but not `preload`, so the browser decided — and Chrome decides `"auto"` for
674
+ `<audio>`. Rendering the component downloaded the entire file. Measured on the
675
+ Fancy showcase: one audio tile on the package grid transferred **995 KB**
676
+ before anyone pressed play.
677
+
678
+ `"metadata"` is enough for duration and the scrubber, which is all a viewer
679
+ needs before someone decides to listen. Pass `"none"` for a thumbnail that
680
+ should cost nothing, or `"auto"` to restore the eager behaviour.
681
+
682
+ **What to do:** nothing, unless you were relying on the file being fully
683
+ buffered by the time a user pressed play — in that case pass `preload="auto"`
684
+ explicitly. The expensive option is now the one you opt into.
685
+
686
+
687
+ ## [5.19.0] - 2026-08-11
688
+
689
+ ### Added
690
+
691
+ - **`Sidebar` takes an `embedded` prop** — no right border, no background, and
692
+ full width instead of the fixed `w-60`, for a sidebar living inside a
693
+ container that already owns its surface.
694
+
695
+ The defaults are right for the standalone app rail and actively wrong inside
696
+ a card: a 240px rail in a 300px panel draws its right border 240px in, which
697
+ reads as a stray vertical line slicing the panel rather than an edge, and the
698
+ opaque background sits as a lighter block over the card behind it. Both were
699
+ visible on the Fancy UI Curriculum's lesson list.
700
+
701
+ Chrome only — collapsing still works, and a collapsed rail keeps its fixed
702
+ narrow width.
703
+
704
+ Not a `className` fix: neutralising `border-r`, `bg-white`,
705
+ `dark:bg-zinc-900` and `w-60` from outside takes four `!important` utilities
706
+ that every embedding host has to rediscover, and that rot the moment the base
707
+ classes change.
708
+
709
+
710
+ ## [5.18.0] - 2026-08-11
711
+
712
+ ### Added
713
+
714
+ - **`FileBrowser` can create folders, if the app opts in.** Supply
715
+ `onCreateFolder` and the toolbar grows a "New folder" button; leave it off and
716
+ nothing renders. The handler IS the opt-in — a separate `showNewFolder` flag
717
+ would let the two disagree, and a visible button wired to nothing is the
718
+ failure this shape rules out.
719
+
720
+ ```tsx
721
+ <FileBrowser
722
+ provider={{ loadChildren }}
723
+ onCreateFolder={async ({ parentPath, name }) => {
724
+ await api.mkdir(`${parentPath}/${name}`);
725
+ }}
726
+ />
727
+ ```
728
+
729
+ An inline input, not `window.prompt`: a prompt blocks the event loop, cannot
730
+ be themed, is unusable on mobile, and has no handle an agent could target.
731
+
732
+ The browser validates before your handler is called — empty names, a name
733
+ already taken in that directory (files count), path separators, `.` and `..`.
734
+ It knows what is in the folder and you would have to round-trip to find out.
735
+ It does NOT guess at case-sensitivity, reserved device names or length limits;
736
+ those depend on a filesystem it cannot see, so **rejecting the promise is a
737
+ supported outcome** and the message lands on the form.
738
+
739
+ In provider mode a resolved promise reloads that directory so the folder
740
+ appears. A rejected one does not — reloading after a failure hides it behind
741
+ an unchanged listing.
742
+
743
+ Handles for agents and tests: `data-react-fancy-file-browser-new-folder`,
744
+ `-new-folder-input`, `-new-folder-submit`, `-new-folder-cancel`,
745
+ `-new-folder-error`.
746
+
747
+ - **`FileBrowser.NewFolder`** is exported separately, for hosts composing their
748
+ own toolbar out of the parts.
749
+
750
+ - **`validateFolderName(name, siblings)`** — the same rule the component uses,
751
+ exported so a host can pre-flight a name it is about to submit programmatically.
752
+
753
+
754
+ ## [5.17.1] — 2026-08-11
755
+
756
+ ### Fixed
757
+
758
+ - **A `Modal` could grow taller than the screen, and its content became
759
+ unreachable.** Only `size="full"` carried a max-height. `sm`, `md`, `lg` and
760
+ `xl` — including the `md` default — were width-constrained and vertically
761
+ unbounded, so a long modal simply extended past the bottom of the viewport.
762
+
763
+ No scrollbar appeared, because there was nothing to scroll: the box got
764
+ taller instead of overflowing. `ModalBody` has always had
765
+ `flex-1 overflow-y-auto` and the panel has always had `overflow-hidden` — the
766
+ entire scrolling mechanism was present and inert, waiting on the one
767
+ constraint that makes it engage.
768
+
769
+ Every size is now capped at `calc(100dvh - 2rem)`, with a `100vh` fallback
770
+ listed first for engines that do not know `dvh`. `dvh` rather than `vh`
771
+ deliberately: on mobile `100vh` is the tallest the viewport ever gets, so a
772
+ `vh` cap still runs under the browser chrome on exactly the devices with the
773
+ least room.
774
+
775
+ `ModalBody` also gains **`min-h-0`**, which is load-bearing rather than
776
+ tidying. A flex child defaults to `min-height: auto` and refuses to shrink
777
+ below its content, so without it the body would push the panel through its
778
+ own new max-height and the scrollbar still would not appear.
779
+
780
+ **What you must do:** nothing. A modal that fit before is unchanged; one that
781
+ did not now scrolls internally. If you had worked around this with your own
782
+ `max-h-*` and `overflow` on `Modal` or `Modal.Body`, those still win — they
783
+ are on the same elements and later in the class string — so nothing breaks,
784
+ but you can now delete them.
785
+
786
+
787
+ ## [5.17.0] — 2026-08-10
788
+
789
+ ### Changed
790
+
791
+ - **BREAKING — `Field` no longer spaces itself. Its container does.** The
792
+ stylesheet shipped an adjacent-sibling rule:
793
+
794
+ ```css
795
+ [data-react-fancy-field] + [data-react-fancy-field] { margin-top: 1rem; }
796
+ ```
797
+
798
+ A sibling selector has no idea which way its parent lays children out.
799
+ Stacked vertically that read as spacing; inside a `grid-cols-2` row it pushed
800
+ the right-hand cell 16px down — and `items-start` could not fix it, because a
801
+ margin is not alignment:
802
+
803
+ ```jsx
804
+ <div className="grid grid-cols-2 gap-4 items-start">
805
+ <Field label="Class">…</Field>
806
+ <Field label="Type">…</Field> {/* ← sat 16px lower */}
807
+ </div>
808
+ ```
809
+
810
+ The only escape was wrapping every `Field` in a plain `<div>` so each became
811
+ an only-child. Needing a wrapper per field to put a component in a grid is
812
+ the tell that the spacing was in the wrong place.
813
+
814
+ **What you must do:** add spacing to the containers that stack fields —
815
+ `space-y-4` on the wrapper, or `gap-4` if it is already flex or grid. One
816
+ class per form, not per field:
817
+
818
+ ```diff
819
+ - <div>
820
+ + <div className="space-y-4">
821
+ <Field label="Name">…</Field>
822
+ <Field label="Email">…</Field>
823
+ </div>
824
+ ```
825
+
826
+ **How to find them:** anywhere two or more `Field`s share a parent that sets
827
+ no `gap` or `space-y-*`. If your form already sat inside a flex/grid parent
828
+ with a `gap`, or holds a single `Field`, nothing changes. Grid and flex-row
829
+ layouts are *fixed* by this — they need no edit and stop being misaligned.
830
+
831
+ Why a breaking change rather than an opt-out attribute: an opt-out leaves the
832
+ default wrong and makes every future grid layout pay for it, and the amount
833
+ of consumer code silently relying on a component to space its own siblings
834
+ only grows. `gap` exists for this.
835
+
836
+ Reported by a consuming app, with the correct diagnosis and the correct root
837
+ fix. Reproduced in a real browser against the shipped CSS before changing
838
+ anything, and the showcase's own 54 stacked call sites were migrated in the
839
+ same change.
840
+
841
+ ## [5.16.0] — 2026-08-10
842
+
843
+ ### Added
844
+
845
+ - **`JsonEditor`** — a key/value editor for arbitrary JSON, nested to any depth,
846
+ where a `keyMap` imposes a data type on chosen paths and that type drives both
847
+ how a value is displayed and which input edits it.
848
+
849
+ ```tsx
850
+ <JsonEditor
851
+ value={config}
852
+ onChange={setConfig}
853
+ keyMap='{"user.age":"number","tags.*":"string","role":{"type":"enum","options":["admin","member"]}}'
854
+ />
855
+ ```
856
+
857
+ **`keyMap` is a JSON string, not an object**, and an already-parsed object is
858
+ deliberately not accepted. A string survives an MCP tool call, a config
859
+ column, a `data-*` attribute or a form field intact; a live object does not.
860
+ It is parsed once per distinct string, which a test asserts with a
861
+ call-counting spy rather than by inspection.
862
+
863
+ Paths are **dotted patterns with `*` as a single-segment wildcard** —
864
+ `orders.*.total` — chosen over a nested mirror because a mirror is ambiguous
865
+ the moment a key needs both a type and typed children, and needs a magic
866
+ `$type` escape to disambiguate. Specificity is literal-segment count, ties
867
+ broken by last-declared. A literal dot escapes as `\.`.
868
+
869
+ Types: `string`, `text`, `number`, `integer`, `boolean`, `date`, `datetime`,
870
+ `enum`, `secret`, `url`, `email`, `color`, `json`, `object`, `array`. With no
871
+ rule the type is inferred from the value.
872
+
873
+ **A value that contradicts its declared type is never coerced and never
874
+ dropped.** It keeps its real value, renders through the raw text editor — the
875
+ only control that can both hold and repair `"thirty-six"` in a `number` field
876
+ — and the row reports `data-conflict="true"`. Typing reaches the same state:
877
+ numeric fields commit on blur rather than per keystroke (a controlled number
878
+ input eats the `.` in `1.5`), and unparseable text is stored verbatim as a
879
+ string so the conflict is visible instead of silently becoming `0`.
880
+
881
+ Two failure modes are kept distinct, because collapsing them is how a typing
882
+ feature quietly stops typing things. An unusable `keyMap` **string** yields no
883
+ rules, sets `data-keymap-error`, and says plainly that values are untyped — it
884
+ never throws and never silently renders an untyped editor. A single malformed
885
+ **rule** is dropped and reported while the rest of the map stays in force, so
886
+ one typo cannot disable forty rules.
887
+
888
+ Human+: controlled `value` + `onChange(next, edit)` with no private copy;
889
+ stable `data-react-fancy-json-editor-*` handles carrying `data-path`, plus a
890
+ `role="tree"` DOM so an agent addresses `[data-path="orders.2.total"]` in one
891
+ selector; `pendingMode` for staged accept/reject; `onActivity` on every
892
+ commit. `applyJsonEdit`, `parseKeyMap` and `findJsonConflicts` are exported
893
+ pure and non-mutating — the surface a bridge needs.
894
+
895
+ 62 tests. Built from the kit's own `Input`, `Textarea`, `Select`, `Switch`,
896
+ `DatePicker`, `ColorPicker`, `Button`, `Badge`, `Callout` and `useInlineEdit`
897
+ — which is what gives `mode="view"` click-to-edit for free.
898
+
899
+ ### Fixed
900
+
901
+ - **`data-*`, `aria-*` and `id` now reach the DOM on `ColorPicker`.** It had no
902
+ `...rest`, so a handle put on it was dropped — the same defect swept out of
903
+ `Switch`, `Checkbox` and friends in 5.8.0 (#22), which this component was not
904
+ part of. Found by building `JsonEditor` on top of it: a `color`-typed field
905
+ could not carry the handle its own row needed.
906
+
907
+ Both roots are covered. The component renders a different element in `view`
908
+ mode than in `edit` mode, so a spread on only one of them would make the
909
+ handle appear and disappear with an unrelated prop.
910
+
911
+ - **`Table.Column` now announces its sort state with `aria-sort`.** The column
912
+ already computed whether it was the sort key and in which direction, and spent
913
+ both entirely on rendering a ▲/▼ glyph. Nothing reached assistive technology,
914
+ so a sortable table sounded exactly like a static one and activating a header
915
+ produced no feedback at all.
916
+
917
+ ```html
918
+ <!-- before --> <th class="…">Name ▲</th>
919
+ <!-- after --> <th class="…" aria-sort="ascending">Name ▲</th>
920
+ ```
921
+
922
+ A sortable column that is not the current key is now `aria-sort="none"`, which
923
+ is deliberately not the same as omitting the attribute — it says "this sorts,
924
+ but is not sorted right now". A **non**-sortable header still gets nothing,
925
+ because claiming otherwise invites someone to activate an inert control. The
926
+ arrow glyph is now `aria-hidden`, so it is no longer read out as stray text
927
+ next to the real state.
928
+
929
+ **What you must do:** nothing, unless you have CSS or tests keyed on the
930
+ absence of the attribute — a selector like `th:not([aria-sort])` will stop
931
+ matching sortable headers. Passing `aria-sort` yourself overrides the computed
932
+ value, which is what a server-sorted table needs.
933
+
934
+ - **`data-*` and `aria-*` props now reach the DOM on `Table`, `Table.Head`,
935
+ `Table.Body` and `Table.Column`.** They named every prop they used and had no
936
+ `...rest`, so anything unrecognised was dropped — the same defect fixed for
937
+ `Table.Row` in 5.8.0 and `Table.Cell` in #5. (#19)
938
+
939
+ Those two were fixed because they were the two reported. Auditing the rest of
940
+ the family in one pass — which is what the 5.8.0 entry said it had done for
941
+ the *form* controls — found the four above still carrying it. `Table.Column`
942
+ was the one that mattered: it is a `<th>`, so it is where `scope` and
943
+ `aria-sort` belong.
944
+
945
+ As elsewhere, the spread comes **before** the component's own attributes, so a
946
+ passed prop cannot clobber the internal `data-react-fancy-*` marker,
947
+ `className`, or the sort handler.
948
+
949
+ - **`Table.Column` no longer discards a caller's `onClick`** on a sortable
950
+ column. It set its own handler unconditionally, so passing one was accepted
951
+ and silently ignored. Both now run, the caller's first.
952
+
953
+ ## [5.15.0] — 2026-08-09
954
+
955
+ ### Added
956
+
957
+ - **`Container`, `Section` and `Grid`** — the layout primitives the kit did not
958
+ have. Story #170, task 228, which the report calls "the highest-leverage gap
959
+ found so far".
960
+
961
+ ```tsx
962
+ <Container as="main">
963
+ <Section divider><Grid cols={3}>…</Grid></Section>
964
+ </Container>
965
+ ```
966
+
967
+ All twenty Swiss-family gallery styles hand-rolled the same three things: a
968
+ page container with a max width, section rhythm, and a modular grid.
969
+
970
+ The obvious objection is that Tailwind already has `max-w-*`, `py-*` and
971
+ `grid-cols-*`. What got repeated twenty times was not the utilities though —
972
+ it was the **decisions**: how wide the page reads, how much air between
973
+ sections, when the grid collapses. Twenty copies of a decision drift. These own
974
+ the decision and leave the styling replaceable, the same split `Eyebrow` makes.
975
+
976
+ Three details worth knowing:
977
+
978
+ - `Container` and `Section` take `as`, because a container is often a `<main>`
979
+ and forcing a `<div>` makes you wrap it.
980
+ - `Grid` sets its column count through a **custom property and inline
981
+ `grid-template-columns`**, not a `grid-cols-N` class. N is a prop, and
982
+ Tailwind cannot generate a class it never sees in source — that is precisely
983
+ what each hand-rolled copy worked around differently.
984
+ - `Grid` collapses responsively by default, with `responsive={false}` for grids
985
+ that are genuinely fixed and look broken when they collapse.
986
+
987
+ ## [5.14.0] — 2026-08-09
988
+
989
+ ### Added
990
+
991
+ - **`StatList`** — the mono figure stack (`70 packages / 261 components / MIT
992
+ licensed`). Story #170, task 224.
993
+
994
+ ```tsx
995
+ <StatList items={[{ value: 70, label: "packages" }]} />
996
+ ```
997
+
998
+ The Inspiration index and `/packages` each hand-rolled this from tokens. That
999
+ is the gallery working as intended — it stress-tests the kit and what it
1000
+ hand-rolls is the gap list — but the list only pays off when the gap comes
1001
+ back here, otherwise every surface keeps a copy and they drift.
1002
+
1003
+ Takes `items` as **data rather than children**, because what those surfaces
1004
+ repeat is a shape, not a layout — and a data prop is what an agent can emit,
1005
+ which children are not. Each row carries a `data-stat` handle so a figure can
1006
+ be addressed rather than counted.
1007
+
1008
+ ## [5.13.1] — 2026-08-09
1009
+
1010
+ ### Fixed
1011
+
1012
+ - **5.13.0 never reached npm.** Its publish died with
1013
+ `ERR_WORKER_OUT_OF_MEMORY`: 5.13.0 raised the heap in `scripts.build`, but
1014
+ `prepublishOnly` invoked `tsup` directly and went straight past it, so the
1015
+ release path OOMed while CI was green.
1016
+
1017
+ Every script now builds one way, through `npm run build`. Two routes to the
1018
+ same build is the actual defect — fixing only the one I happened to be looking
1019
+ at is what shipped a tag that could not publish.
1020
+
1021
+ ## [5.13.0] — 2026-08-09
1022
+
1023
+ ### Added
1024
+
1025
+ - **Per-component subpath exports.** (#21)
1026
+
1027
+ ```tsx
1028
+ import { Badge } from "@particle-academy/react-fancy/badge";
1029
+ ```
1030
+
1031
+ 84 subpaths, one per component. **`.` stays the barrel**, so every existing
1032
+ import keeps working unchanged — this adds entry points, it does not move any.
1033
+
1034
+ ### Fixed
1035
+
1036
+ - **A consumer importing one component no longer pays for all of them.**
1037
+
1038
+ `dist/index.js` was ~660 kB exporting ~140 names, and tree-shaking could not
1039
+ rescue it from outside: the bundle carried hundreds of top-level initialiser
1040
+ calls, which a bundler must keep and which transitively retain most of the
1041
+ graph. The reporter's smallest route shipped ~931 kB of kit to render a form
1042
+ with four inputs.
1043
+
1044
+ Measured by bundling a consumer that imports **only `Badge`**, minified,
1045
+ React external:
1046
+
1047
+ | | bytes |
1048
+ |---|---|
1049
+ | published 5.12.0 | **1,133,438** |
1050
+ | 5.13.0 | **33,255** |
1051
+
1052
+ The build now emits one entry per component with `splitting: true`, so shared
1053
+ code lands in chunks instead of being copied into each entry — without that,
1054
+ per-component entries would trade one large download for many duplicated ones,
1055
+ which is worse for anyone importing several.
1056
+
1057
+ Note the **barrel itself got the same benefit**: the measurement above is
1058
+ identical whether you import from `.` or from `/badge`. Splitting is what made
1059
+ the graph reachable to a bundler; the subpaths make it explicit and guaranteed
1060
+ rather than dependent on your bundler's tree-shaking.
1061
+
1062
+ **What you must do:** nothing. Switch to subpaths if you like the explicitness,
1063
+ but the barrel is no longer the reason your bundle is large.
1064
+
1065
+ ### Changed
1066
+
1067
+ - `npm run build` runs tsup under `--max-old-space-size=8192`. Generating types
1068
+ for 84 entries exceeds Node's default heap and the build OOMs without it.
1069
+
1070
+ ## [5.12.0] — 2026-08-09
1071
+
1072
+ ### Added
1073
+
1074
+ - **`Toast` takes an `action`, so undo can be a button instead of a countdown.** (#18)
1075
+
1076
+ ```tsx
1077
+ toast({
1078
+ title: "Moved to Claimed",
1079
+ action: { label: "Undo", onClick: () => move(id, previousStage) },
1080
+ })
1081
+ ```
1082
+
1083
+ A timeout-based undo makes the safety of an action depend on how fast someone
1084
+ reads, which is worst for exactly the people who need undo most. Without a slot
1085
+ a consumer either dropped undo or rebuilt the toast — and a rebuilt one does not
1086
+ join the provider's stack, so it overlaps the real ones.
1087
+
1088
+ Three behaviours are baked in rather than left to callers:
1089
+
1090
+ - **A toast with an action does not auto-dismiss.** Expiring the offer is the
1091
+ same bug as making undo a timeout. Pass an explicit `duration` to opt back in.
1092
+ - Clicking the action runs it and then dismisses — leaving it up invites undoing
1093
+ an already-undone action.
1094
+ - **`Escape` dismisses**, scoped to the toast rather than the document, so it
1095
+ does not fight whatever modal or drawer is already listening.
1096
+
1097
+ The action renders as a real `<button>`, so it is reachable by Tab from wherever
1098
+ focus already is. `ToastAction` is exported.
1099
+
1100
+ **What you must do:** nothing. Toasts without an action keep the 5000ms default,
1101
+ and there is a test asserting it.
1102
+
1103
+ ## [5.11.0] — 2026-08-09
1104
+
1105
+ ### Added
1106
+
1107
+ - **`Progress` circular accepts a pixel `size` and an explicit `strokeWidth`.** (#17)
1108
+
1109
+ ```tsx
1110
+ <Progress variant="circular" value={62} size={152} strokeWidth={11} />
1111
+ ```
1112
+
1113
+ `size` topped out at `lg` (64px) and was written as an **inline style**, with
1114
+ the radius, circumference and dash maths computed in JS from that number. So a
1115
+ larger ring was impossible rather than awkward: inline styles beat any
1116
+ stylesheet rule short of `!important`, and winning that fight would still have
1117
+ left the geometry wrong. The reporter built a local ring instead, duplicating
1118
+ the circumference maths.
1119
+
1120
+ When no `strokeWidth` is given, a pixel size derives one from the diameter — a
1121
+ 5px stroke on a 152px ring reads as a hairline. The named scale keeps its exact
1122
+ previous values.
1123
+
1124
+ ### Changed
1125
+
1126
+ - The circular SVG now uses a `viewBox` with `width="100%" height="100%"`, so the
1127
+ ring follows whatever sizes its box instead of having its dimensions baked in.
1128
+
1129
+ - **Named diameters moved from inline styles to shipped CSS**, keyed off
1130
+ `[data-react-fancy-progress][data-size="…"]`, so `className` can override them.
1131
+
1132
+ Deliberately not Tailwind classes on the element: a consumer who has not
1133
+ `@source`d this package gets no generated utilities, and the ring would render
1134
+ with **no size at all**. Shipping them as real CSS works regardless — and
1135
+ because 5.9.0 put the stylesheet in `@layer base`, a utility still wins.
1136
+
1137
+ **What you must do:** nothing, unless you were relying on the inline style to
1138
+ beat your own CSS. Named sizes render at the same diameters as before, and
1139
+ there is a test asserting that.
1140
+
1141
+ ## [5.10.0] — 2026-08-09
1142
+
1143
+ ### Fixed
1144
+
1145
+ - **`PromptInput` discarded the `File` on drop, so attachments could not be
1146
+ uploaded.** (#16) Only `{ id, name, bytes }` survived, so a host could render
1147
+ the chip and do nothing else with it — no POST, no `FormData`, no
1148
+ send-to-model.
1149
+
1150
+ `PromptAttachment` now also carries:
1151
+
1152
+ ```ts
1153
+ file?: File; // what the user actually attached
1154
+ type?: string; // MIME, for hosts that branch on it
1155
+ ```
1156
+
1157
+ Additive — existing code reading `name`/`bytes` is unaffected. They are
1158
+ optional because an attachment restored from a server has no `File`, but
1159
+ anything the user dropped or picked always has one.
1160
+
1161
+ This is the expensive shape of bug: the UI looked finished — chips rendered,
1162
+ sizes correct — so it was only discovered when someone wired the upload up and
1163
+ found nothing to send. There was no escape hatch either, since attachments are
1164
+ internal state with no `value`/`onChange` and no ref.
1165
+
1166
+ - **The `📎 attach` button did nothing.** It shipped with a no-op `onClick` and a
1167
+ tooltip reading "Drop files here, or click" — an affordance that lied. It now
1168
+ opens a real file picker, which also means **keyboard and touch users can
1169
+ attach at all**, where before drop was the only route.
1170
+
1171
+ ### Changed
1172
+
1173
+ - Attachment ids now use `crypto.randomUUID()` instead of
1174
+ `` `${name}-${Date.now()}-${Math.random()}` ``. Tidier, not a bug fix — the old
1175
+ scheme did produce distinct ids, and there is a test asserting it still does.
1176
+
1177
+ ## [5.9.0] — 2026-08-09
1178
+
1179
+ ### Fixed
1180
+
1181
+ - **Base styles are now in `@layer base`, so a Tailwind utility passed through
1182
+ `className` finally wins.** (#20)
1183
+
1184
+ ```tsx
1185
+ <Badge className="bg-[#4338CA] text-white">Epic</Badge> // was silently ignored
1186
+ ```
1187
+
1188
+ In the CSS cascade an **unlayered** normal declaration outranks every layered
1189
+ one, whatever the specificity — and Tailwind v4 puts utilities in
1190
+ `@layer utilities`. The kit shipped its base looks unlayered, so `className`
1191
+ utilities could never win. It failed silently and backwards: the class is in
1192
+ the DOM, DevTools shows it, nothing warns, and the consumer doing the
1193
+ documented thing got a worse result than one reaching for `!important`.
1194
+
1195
+ Note this was **not** solved by the existing `:where()` wrappers. Those drop
1196
+ specificity to zero, and a comment in `styles.css` claimed a generated utility
1197
+ would therefore win. It would not — specificity is only compared *within* a
1198
+ layer. Measured in a browser before and after: the overlay dark-mode rule beat
1199
+ a `@layer utilities` background before this change and loses to it now.
1200
+
1201
+ **`base`, not a private layer name.** A `@layer fancy-base` orders by first
1202
+ declaration, so whether utilities won would depend on whether you imported this
1203
+ file before or after Tailwind. `base` always precedes `components` and
1204
+ `utilities`, so it is order-independent and needs nothing from you.
1205
+
1206
+ `@theme` stays unlayered — wrapping it stops Tailwind registering the tokens at
1207
+ all, which would turn `bg-brand` and `text-primary-600` back into classes that
1208
+ resolve to nothing.
1209
+
1210
+ **What you must do:** check any place you worked around this. Overrides that
1211
+ were previously ignored now apply, so a `className` you left in believing it
1212
+ did nothing will start taking effect. If you moved tones into your own
1213
+ unlayered stylesheet (the documented workaround), that still wins — unlayered
1214
+ beats layered — so nothing breaks until you choose to move it back.
1215
+
1216
+ ## [5.8.0] — 2026-08-09
1217
+
1218
+ ### Fixed
1219
+
1220
+ - **`data-*` and `aria-*` props now reach the DOM** on `Switch`, `Checkbox`,
1221
+ `CheckboxGroup`, `RadioGroup`, `MultiSwitch`, `Field` and `Table.Row`. They
1222
+ named every prop they used and had no `...rest`, so anything unrecognised was
1223
+ silently dropped. (#22, #19)
1224
+
1225
+ ```tsx
1226
+ <Switch data-pw-set="digits" … />
1227
+ document.querySelectorAll('[data-pw-set]') // was [] — nothing, anywhere
1228
+ ```
1229
+
1230
+ Nothing reported it: React discards unknown props on a component silently,
1231
+ TypeScript does not check hyphenated prop names at all, and the component
1232
+ renders perfectly. It only shows up when something tries to **address** the
1233
+ control — the agent-driving case the component contract exists to protect:
1234
+
1235
+ > Each interactive element has a stable identity (`id`, `data-*`, or a
1236
+ > selector prop). Agents never guess DOM.
1237
+
1238
+ The two reported were `Switch` and `Table.Row`; auditing the family in one pass
1239
+ found three more, so all of them are fixed and asserted together.
1240
+
1241
+ **Where the handle lands:** the component's own outermost element, which exists
1242
+ in every mode. `Switch` renders a `<span>` instead of its `<button>` in view
1243
+ mode, and the group components are only wrapped in `<Field>` when a label is
1244
+ present — so attaching to the control or the wrapper would have moved the
1245
+ handle around depending on unrelated props.
1246
+
1247
+ The spread comes **before** the component's own attributes, so a passed prop
1248
+ cannot clobber the internal marker, `className` or the handlers.
1249
+
1250
+ **What you must do:** nothing. This only adds props that previously vanished.
1251
+ If you worked around it with `id`, that still works — `id` was always honoured.
1252
+
1253
+ ## [5.7.0] — 2026-08-09
1254
+
1255
+ ### Added
1256
+
1257
+ - **`<PullQuote>`** — the oversized line lifted out of body copy, with optional
1258
+ attribution and a rule-bracketed treatment.
1259
+
1260
+ Twelve gallery styles build one, but the reason this is a component rather
1261
+ than a class is the same reason `<Kbd>` renders a real `<kbd>`: of the eight
1262
+ quote elements in those styles, **one** was a `<blockquote>` — five were `<p>`
1263
+ and two were `<div>` — and **none** used `<cite>`. They all look right and
1264
+ none of them says what it is.
1265
+
1266
+ `citeUrl` maps to `<blockquote cite>`, which is the attribute's actual job
1267
+ (the source URL) and is routinely confused with the visible `<cite>` element
1268
+ (the speaker or work). Separate props so the two cannot collapse into one
1269
+ another.
1270
+
1271
+ **What you must do:** nothing. Additive.
1272
+
1273
+
1274
+ ## [5.6.1] — 2026-08-09
1275
+
1276
+ ### Fixed
1277
+
1278
+ - **`<Stat>` and `<IndexList>` now carry a stable handle on each PART** —
1279
+ `data-react-fancy-stat-value` / `-stat-label`, and `-index-num` / `-index-title`
1280
+ / `-index-meta` / `-index-value`.
1281
+
1282
+ Shipped in 5.6.0 with a handle on the root only, which made them unrestylable
1283
+ in practice: a consumer styling `.swiss-figure__num` had no selector for the
1284
+ figure inside the component, so adopting it meant losing the design. Being
1285
+ unrestylable is how twenty-seven separate copies of a figure band happened in
1286
+ the first place, so a primitive that cannot be restyled does not solve the
1287
+ problem it was built for.
1288
+
1289
+ **What you must do:** nothing. Attributes only.
1290
+
1291
+
1292
+ ## [5.6.0] — 2026-08-09
1293
+
1294
+ ### Added
1295
+
1296
+ - **`<Stat>` / `<Stat.Band>`** — a big display figure with a caption, and the
1297
+ row it sits in.
1298
+
1299
+ Twenty-seven gallery styles build this, and **five of seventeen forgot
1300
+ `tabular-nums`**. That is not cosmetic: in a proportional face a `1` is
1301
+ narrower than a `0`, so a row meant to read as a band comes out visibly
1302
+ ragged — from one property nobody thinks to look for. It is the default here,
1303
+ at every size.
1304
+
1305
+ - **`<IndexList>`** — the flush-left numbered index: `num · title · meta ·
1306
+ trailing value`, each row optionally a single click target. Thirteen styles
1307
+ build it; the sandbox uses a stretched-link treatment in twenty-four places.
1308
+
1309
+ **Exactly one anchor per row, by construction.** The row is `relative` and the
1310
+ title's `<a>` carries `after:inset-0`, so the anchor's own pseudo-element
1311
+ covers the row. Wrapping the row in an `<a>` instead produces nested anchors
1312
+ — issue #418 — which the browser silently restructures, so the server HTML and
1313
+ the client tree disagree and hydration fails. A test asserts the invariant.
1314
+
1315
+ The link text stays the title rather than becoming an `aria-label` on an
1316
+ invisible overlay, so the accessible name is what you would say out loud.
1317
+ `linkAs` takes your router's link component (Inertia, `next/link`) to keep
1318
+ client-side navigation.
1319
+
1320
+ **What you must do:** nothing. Both additive.
1321
+
1322
+
1323
+ ## [5.5.0] — 2026-08-09
1324
+
1325
+ ### Added
1326
+
1327
+ - **`<Kbd>`** — a keyboard key cap. `<Kbd>K</Kbd>`, or a chord with
1328
+ `<Kbd keys={["Cmd", "K"]} />`.
1329
+
1330
+ Twenty `<kbd>` elements across this suite's own showcase carried **six**
1331
+ different className recipes for the same thing: rounded, bordered, mono,
1332
+ small, with a dark-mode counterpart. None disagreed on intent — they
1333
+ disagreed on which greys.
1334
+
1335
+ Two details it puts in one place. `min-w`, because without it a `K` cap is
1336
+ visibly narrower than an `Esc` beside it and a row of single letters reads as
1337
+ ragged rather than as keys. And `not-italic`, because several UA stylesheets
1338
+ italicise `<kbd>` — every hand-rolled copy that happened to set a mono font
1339
+ hid that by accident, and the one that did not, did not.
1340
+
1341
+ A chord renders as one `<kbd>` containing per-key caps, with the separator
1342
+ `aria-hidden`, so a screen reader announces "Cmd + K" as the single shortcut
1343
+ it is rather than as two unrelated keys.
1344
+
1345
+ **What you must do:** nothing. Additive.
1346
+
1347
+
1348
+ ## [5.4.0] — 2026-08-09
1349
+
1350
+ ### Added
1351
+
1352
+ - **`<Brand.Mark>`** — the square logo tile: a glyph centred in a rounded
1353
+ coloured square, at `sm`/`md`/`lg` or an exact pixel size.
1354
+
1355
+ `Brand` has always taken its mark as a caller-supplied `logo` node, which is
1356
+ exactly why four surfaces each rebuilt the same square inline — fixed size,
1357
+ rounded, grid-centred, bold, and `shrink-0` so a flex row never squashes it.
1358
+ That last one is the detail every hand-rolled copy had to remember.
1359
+
1360
+ **It ships no brand colour, deliberately.** The default is a neutral zinc
1361
+ tile and the fill is `className`'s job. A component library inventing your
1362
+ brand gradient would be wrong and would be the first thing every consumer
1363
+ overrode — and each duplicated copy already pulled its gradient from a token
1364
+ class and hand-rolled only the box, so the box is all this owns.
1365
+
1366
+ Decorative by default (`aria-hidden`), which lifts when you pass an
1367
+ `aria-label`: a mark beside the brand name is redundant to a screen reader, a
1368
+ mark standing alone is not.
1369
+
1370
+ - **`<Eyebrow>`** — the mono running head that opens a section:
1371
+ `<Eyebrow num="01" label="Selected work" aside="…" rule />`.
1372
+
1373
+ Thirty-six gallery surfaces carry a version of this. What they share is the
1374
+ arrangement — a numbered marker, a label, an optional trailing aside, an
1375
+ optional hairline — while every one restyles the type, so this owns the
1376
+ structure and the uppercase-mono default and gets out of `className`'s way.
1377
+
1378
+ The aside uses `ml-auto` rather than the row using `justify-between`: with
1379
+ `justify-between` a single-item eyebrow drifts, and half of them have only
1380
+ one item.
1381
+
1382
+ **What you must do:** nothing. Both are additive; `Brand` and every existing
1383
+ component render unchanged.
1384
+
1385
+
1386
+ ## [5.3.0] — 2026-08-09
1387
+
1388
+ ### Added
1389
+
1390
+ - **`<Card.Media>`** — the fixed-ratio thumbnail region at the top of a card,
1391
+ with slots pinned to its corners (`topLeft` / `topRight` / `bottomLeft` /
1392
+ `bottomRight`), an `src`, a `ratio` or fixed `height`, and a `background`.
1393
+
1394
+ ```tsx
1395
+ <Card interactive>
1396
+ <Card.Media src={thumb} background={swatch} height={122}
1397
+ topLeft={<Chip>{num}</Chip>} topRight={<Chip>{mode}</Chip>} />
1398
+ <Card.Body>…</Card.Body>
1399
+ </Card>
1400
+ ```
1401
+
1402
+ Four surfaces had each rebuilt exactly this from tokens, and one of them was
1403
+ already wrapping `Card` and hand-rolling only this region — which is why it is
1404
+ `Card.Media` and not a separate `<MediaCard>`. What was being asked for was
1405
+ never a different card; it was a card with a picture in it.
1406
+
1407
+ `background` is not decoration. It shows through while the image loads and
1408
+ stays visible if the image never arrives, so a missing thumbnail degrades to
1409
+ the intended colour rather than to a hole.
1410
+
1411
+ - **`<Card interactive>`** — the hover lift, border/shadow response, and corner
1412
+ clipping a grid tile or link card wants. Clipping is part of `interactive`
1413
+ rather than always-on, because clipping unconditionally would cut off
1414
+ popovers and dropdowns that legitimately overflow a static card.
1415
+
1416
+ **What you must do:** nothing. Both are additive, and a `Card` written before
1417
+ this release renders identically — pinned by a test.
1418
+
1419
+
1420
+ ## [5.2.0] — 2026-08-09
1421
+
1422
+ ### Added
1423
+
1424
+ - **`<Heading>` reaches display scale.** The `size` scale gains `3xl`–`7xl`,
1425
+ mapping to `text-5xl` through `text-9xl`, with tighter tracking at those steps
1426
+ (`tracking-tight` at 3xl–4xl, `tracking-tighter` above).
1427
+
1428
+ It stopped at `2xl` before, which is why a hero wanting "typography at maximum
1429
+ volume" had to hand-roll its own `<h1>` — a primitive that exists but cannot
1430
+ reach the size a design needs is a primitive that gets bypassed.
1431
+
1432
+ **What you must do:** nothing. Purely additive; every existing size renders
1433
+ exactly as before, pinned by a test.
1434
+
1435
+ **Note the ramp is semantic, not literal.** These names are steps on a scale,
1436
+ not Tailwind class names: `xl` has meant `text-2xl` and `2xl` has meant
1437
+ `text-4xl` since this component shipped, and the new steps continue that
1438
+ offset. Re-basing so `4xl` meant `text-4xl` would have silently shrunk every
1439
+ heading already using `2xl`, so the offset stays and is now documented on the
1440
+ prop.
1441
+
1442
+ Tracking is applied to the display steps ONLY — spacing tuned for
1443
+ body-adjacent headings reads loose once type is big, and that difference was
1444
+ most of why a hand-rolled `<h1>` looked better than the component did.
1445
+
1446
+
1447
+ ## 5.1.0 — 2026-08-07
1448
+
1449
+ ### Added
1450
+
1451
+ - **`reveal` on `Input`** — a show/hide toggle for `type="password"`, which the
1452
+ component did not have. Consumers were hand-rolling an eye-icon button beside
1453
+ the field to get it.
1454
+
1455
+ ```tsx
1456
+ <Input type="password" reveal />
1457
+ ```
1458
+
1459
+ Optional `revealed` / `onRevealedChange` make the state controllable, and
1460
+ `revealLabel` / `hideLabel` replace the default accessible labels.
1461
+
1462
+ Details that are easy to get wrong when hand-rolling it, and are handled here:
1463
+
1464
+ - The toggle is `type="button"`. The HTML default is `submit`, so a reveal
1465
+ inside a login form submits the form on every click.
1466
+ - It is `tabIndex={-1}`. The path through a login form is field → submit, and
1467
+ a reveal in between is a stop most people do not want. It stays reachable by
1468
+ click and to screen readers, which carry `aria-pressed` and `aria-controls`.
1469
+ - Only the RENDERED type changes. The declared `type` stays `"password"`, so
1470
+ `mode="view"` still masks the value rather than printing it.
1471
+ - `reveal` on a non-password input is ignored — a reveal on a visible field is
1472
+ a button that does nothing.
1473
+ - It replaces `trailing` rather than stacking on it; both occupy the same
1474
+ corner, and rendering both looks like a bug.
1475
+
1476
+ The icons are inline SVG rather than `<Icon>`, so a password field does not
1477
+ depend on an icon set being registered.
1478
+
1479
+
1480
+ ## 5.0.0 — 2026-08-04
1481
+
1482
+ The kit 0.5 platform floors. **This release changes only what the package
1483
+ requires — no component API changed, nothing was removed, and nothing was
1484
+ renamed.** If you are already on React 19 and Node 22, upgrading is a version
1485
+ bump and nothing else.
1486
+
1487
+ ### Changed
1488
+
1489
+ - **BREAKING — React 18 is no longer supported.** `peerDependencies.react` and
1490
+ `react-dom` move from `^18.0.0 || ^19.0.0` to `^19.0.0`.
1491
+
1492
+ **What you must do:** if you are on React 19, nothing. If you are on React 18,
1493
+ stay on `react-fancy@4` — it continues to receive fixes on the 0.4 kit line —
1494
+ or upgrade your app to React 19 first, then take this release.
1495
+
1496
+ React 18 support was a claim nothing tested. Every build and every test in
1497
+ this package has always run against 19 (`devDependencies.react` was `^19.0.0`),
1498
+ so the 18 half of that range was never executed. An untested compatibility
1499
+ claim is worse than an absent one, because it reads as support.
1500
+
1501
+ - **BREAKING — Node 22 is now declared as the floor.** `engines.node` is
1502
+ `>=22`, where the package previously declared nothing at all.
1503
+
1504
+ **What you must do:** on Node 22 or newer, nothing. Note that npm only
1505
+ *warns* on an `engines` mismatch, but **pnpm fails the install**, so this
1506
+ surfaces differently depending on your package manager. Node 18 is
1507
+ end-of-life and 20 is maintenance-only.
1508
+
1509
+ Declaring nothing was not the same as supporting old Node — it meant a
1510
+ consumer on 18 installed cleanly and discovered the problem at runtime.
1511
+
1512
+ - `devDependencies` for `react`, `react-dom`, `@types/react` and
1513
+ `@types/react-dom` pinned to `^19.2.0`, so the tested version is stated
1514
+ rather than inherited.
1515
+
1516
+ ### Why this is a major
1517
+
1518
+ This package is past 1.0, so a breaking change takes a major. Most of the suite
1519
+ is pre-1.0 and lands the same floors in a minor — that difference is semver, not
1520
+ a difference in how much changed.
1521
+
1522
+ **Peer ranges in consuming packages:** most first-party packages declare
1523
+ `@particle-academy/react-fancy` as an open range (`>=3`, `>=4`) and resolve 5.x
1524
+ without any change. A few pinned an upper bound and need widening before they
1525
+ can take this; those are released alongside.
1526
+
1527
+ ## 4.19.1 — 2026-08-02
1528
+
1529
+ ### Fixed
1530
+
1531
+ - **`Sidebar.Item` labels were clipped mid-word instead of ellipsing.** Two
1532
+ causes stacked. A `<button>` sizes to FIT-CONTENT rather than to its parent,
1533
+ so a label longer than the sidebar made the item wider than the sidebar and an
1534
+ ancestor clipped the overflow. And the label span carried `truncate` without
1535
+ `min-w-0`, so even under width pressure a flex item defaults to
1536
+ `min-width: auto` — its min-content width — and refuses to shrink. Fixed with
1537
+ `w-full` on the item and `min-w-0` on the label; either alone is insufficient.
1538
+
1539
+ Only visible with labels longer than the sidebar is wide, which is why short
1540
+ nav labels never surfaced it.
1541
+
1542
+ ## 4.19.0 — 2026-08-02
1543
+
1544
+ ### Added
1545
+
1546
+ - **A brand colour scheme — `brand`, `primary-*` and `secondary-*`.** Packages
1547
+ across the suite were already writing `bg-brand`, `text-primary-600` and
1548
+ `border-secondary-200` against tokens **nothing defined**. Tailwind generated
1549
+ no such utility, the class resolved to nothing, and the component rendered
1550
+ with correct markup and no colour — and no error anywhere. This turns those
1551
+ class names into a contract instead of a hope.
1552
+
1553
+ - `brand` — one accent; the thing a consumer rebrands first.
1554
+ - `primary-50…950` — the accent as a full scale.
1555
+ - `secondary-50…950` — the **neutral** scale for surfaces, borders and text.
1556
+
1557
+ **`secondary-*` inverts with the theme.** `text-secondary-900` means
1558
+ "strongest foreground", not "nearly black", so it stays readable on a dark
1559
+ surface. That distinction is the defect it was written to prevent: mixing a
1560
+ literal background (`bg-white`) with a semantic foreground
1561
+ (`text-secondary-900`) is what rendered white-on-white in a dark host and made
1562
+ `@particle-academy/classroom` unusable on the showcase.
1563
+
1564
+ **What you must DO: nothing.** Purely additive — no existing utility changes
1565
+ meaning. Override any token in your own `@theme` and yours wins, because the
1566
+ utilities read the variable rather than baking a value in:
1567
+
1568
+ ```css
1569
+ @theme { --color-brand: var(--color-emerald-600); }
1570
+ ```
1571
+
1572
+ Requires `@import "@particle-academy/react-fancy/styles.css"` in your Tailwind
1573
+ entry, which is already the documented setup.
1574
+
1575
+ ### Changed
1576
+
1577
+ - **README: component demos now point at the showcase package pages.** The
1578
+ "Demo Pages" section still sent readers (and agents) to
1579
+ `resources/js/react-demos/pages/`, a tree that was removed from the showcase.
1580
+ Every component's live demo is a page at
1581
+ `https://ui.particle.academy/packages/react-fancy/<component>`, sourced from
1582
+ one file per component in `Particle-Academy/pa-ux-sandbox` under
1583
+ `resources/js/Pages/Packages/ComponentDocs/`. Docs only — no code change.
1584
+
1585
+ ## [4.18.0] - 2026-07-31
1586
+
1587
+ ### Added
1588
+
1589
+ - **Navigation primitives accept `as`, so a router `<Link>` can render in their
1590
+ place.** `Navbar.Item`, `Sidebar.Item`, `Menu.Item`, `MobileMenu.Item` and
1591
+ `Breadcrumbs.Item` all hardcoded a plain `<a href>` — a **full page load** in
1592
+ every client-routed app, from the app's own nav chrome. The only workaround
1593
+ was nesting a router `<Link>` inside the item, which is an anchor inside an
1594
+ anchor: invalid HTML, and the same nested-anchor shape behind a past SSR
1595
+ hydration bug.
1596
+
1597
+ `as` is the seam `Button` already had, and it is router-agnostic on purpose —
1598
+ one prop covers Inertia, TanStack Router, React Router and Next rather than a
1599
+ package per router. **No action needed:** the default is still `"a"`, so an
1600
+ item given only `href` renders exactly what it rendered before.
1601
+
1602
+ ```tsx
1603
+ // href-based routers (Inertia, Next)
1604
+ <Navbar.Item as={Link} href="/dashboard">Dashboard</Navbar.Item>
1605
+
1606
+ // to-based routers (TanStack Router, React Router) — router props pass through
1607
+ <Navbar.Item as={Link} to="/dashboard">Dashboard</Navbar.Item>
1608
+ ```
1609
+
1610
+ Link mode engages on `href` **or** `as`, because a `to`-based router passes no
1611
+ `href` and would otherwise fall through to the non-interactive branch — an item
1612
+ that renders correctly and navigates nowhere. `href` is only forwarded when
1613
+ actually set, so it cannot overwrite the one a router derives for itself.
1614
+
1615
+ Unrecognized props now reach the rendered element, which is what carries `to`,
1616
+ `params`, `preload` and `search` through to a router's Link.
1617
+
1618
+ ### Fixed
1619
+
1620
+ - **`onClick` was silently dropped on any item with an `href`.** `Sidebar.Item`,
1621
+ `Menu.Item` and `MobileMenu.Item` swapped the whole prop set in link mode, so
1622
+ an item that navigates *and* closes a drawer — the common mobile case — never
1623
+ ran its handler, with no error to notice. It now fires alongside navigation.
1624
+
1625
+ ## [4.17.1] — 2026-07-28
1626
+
1627
+ ### Fixed
1628
+
1629
+ - **`TimePicker` crashed the first time you clicked "Click to edit".** Four
1630
+ `useCallback`s sat below the `mode="view"` early return, so view mode ran three
1631
+ hooks and edit mode ran seven. React compares hook counts between renders, so
1632
+ the transition threw `Rendered more hooks than during the previous render` from
1633
+ inside React — an error naming none of this component, with the field gone from
1634
+ the page.
1635
+
1636
+ **If you use `<TimePicker mode="view">`, this was reproducible on every first
1637
+ click.** `mode="edit"` (the default) was never affected, which is why it
1638
+ survived: the whole existing suite rendered one mode and stopped, and the bug
1639
+ lives exclusively in the transition.
1640
+
1641
+ A regression test now mounts in view mode and clicks. It fails against the
1642
+ previous release, which is the only reason to trust it.
1643
+
1644
+ ### Changed
1645
+
1646
+ - Widened the `@particle-academy/fancy-file-commons` requirement from `^0.2.0` to
1647
+ `>=0.2 <2.0`, so a sibling minor release is an upgrade and not a resolver
1648
+ conflict. **No action needed** — widening a range only adds candidates; the
1649
+ version you have today still resolves.
1650
+
1651
+ A caret on a `0.x` range locks the MINOR, so this pinned a sibling at
1652
+ whatever it happened to be on the day it was written, and each sibling
1653
+ release then read as a conflict to the resolver rather than an upgrade.
1654
+ Nothing here was using an API the newer minors removed — the range was the
1655
+ whole problem.
1656
+
1657
+ ## [4.17.0] - 2026-07-27
1658
+
1659
+ ### Added
1660
+
1661
+ - **`<Breadcrumbs.Item onClick>`** — a crumb that navigates by callback instead
1662
+ of by URL.
1663
+
1664
+ The item only ever supported `href`, so a **controlled** component that owns
1665
+ its own navigation had no address to link to: a repository browser walking
1666
+ directories, a wizard stepping back, any surface whose "location" lives in
1667
+ React state rather than the URL bar. The only way to make such a crumb
1668
+ clickable was to nest a `<button>` inside the rendered `<span>`, which puts
1669
+ interactive content inside a non-interactive element.
1670
+
1671
+ Renders the crumb as a `<button>`. `href` still wins if both are given — a
1672
+ link is right-clickable, middle-clickable and copyable and a button is none of
1673
+ those, so silently downgrading one to the other would be the wrong way to
1674
+ resolve the ambiguity. Ignored on the `active` crumb, which is where you
1675
+ already are.
1676
+
1677
+ **Nothing to do.** Purely additive; every existing crumb renders exactly as
1678
+ before.
1679
+
1680
+ ## [4.16.0] - 2026-07-20
1681
+
1682
+ ### Added
1683
+
1684
+ - **`Drawer`** — a panel that slides in from any edge. `side` takes
1685
+ `left` / `right` / `top` / `bottom`, with compound `Drawer.Header` /
1686
+ `Drawer.Body` / `Drawer.Footer` slots matching `Modal`'s shape.
1687
+
1688
+ `size` addresses the drawer's **own axis** — width on the horizontal edges,
1689
+ height on the vertical ones — so `size="lg"` does not need re-learning when a
1690
+ drawer moves from the side to the bottom.
1691
+
1692
+ Two anchoring modes via `attach`:
1693
+
1694
+ - `viewport` (default) — portalled, `fixed`, locks body scroll, traps focus
1695
+ and sets `aria-modal`. The classic app-level drawer.
1696
+ - `container` — `absolute` within the nearest positioned ancestor, with no
1697
+ portal, no scroll lock and no focus trap. Attach a drawer to a `Card`, a
1698
+ layout pane, or the shell around a prompt input and it stays in that box.
1699
+ A container-attached drawer is a panel, not a dialog, so trapping focus in
1700
+ it would strand keyboard users inside the card.
1701
+
1702
+ `Drawer.Container` supplies the `relative overflow-hidden` anchor that
1703
+ `attach="container"` needs. Without a positioned ancestor an absolute drawer
1704
+ silently escapes to the viewport, which reads as `attach` being ignored.
1705
+
1706
+ Dismissal is à la carte: `backdrop`, `dismissOnBackdrop`, `dismissOnEscape`.
1707
+ With `backdrop={false}` the frame turns off pointer events so the drawer
1708
+ cannot swallow clicks meant for the page behind it.
1709
+
1710
+ - Vertical slide keyframes `fancy-slide-in-top` / `-out-top` /
1711
+ `-in-bottom` / `-out-bottom`. The horizontal pair already existed but was
1712
+ built for `MobileMenu.Flyout`, which only ever opened left or right.
1713
+
1714
+ ### Changed
1715
+
1716
+ - Slide animations now collapse to a ~0 duration under
1717
+ `prefers-reduced-motion: reduce` rather than being disabled. `useAnimation`
1718
+ unmounts on `animationend`, so `animation: none` would remove the event and
1719
+ leave a closed drawer in the DOM permanently.
1720
+
1721
+ [Unreleased]: https://github.com/particle-academy/react-fancy/compare/v4.16.0...HEAD
1722
+ [4.16.0]: https://github.com/particle-academy/react-fancy/releases/tag/v4.16.0