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