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