@wtfalch/design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/components/Brand.d.ts +10 -0
  4. package/dist/components/Brand.js +212 -0
  5. package/dist/components/Button.d.ts +63 -0
  6. package/dist/components/Button.js +74 -0
  7. package/dist/components/Callout.d.ts +37 -0
  8. package/dist/components/Callout.js +71 -0
  9. package/dist/components/Card.d.ts +42 -0
  10. package/dist/components/Card.js +30 -0
  11. package/dist/components/Checkbox.d.ts +32 -0
  12. package/dist/components/Checkbox.js +31 -0
  13. package/dist/components/DangerZone.d.ts +59 -0
  14. package/dist/components/DangerZone.js +50 -0
  15. package/dist/components/Dialog.d.ts +28 -0
  16. package/dist/components/Dialog.js +29 -0
  17. package/dist/components/Empty.d.ts +45 -0
  18. package/dist/components/Empty.js +35 -0
  19. package/dist/components/Field.d.ts +58 -0
  20. package/dist/components/Field.js +46 -0
  21. package/dist/components/Icon.d.ts +64 -0
  22. package/dist/components/Icon.js +235 -0
  23. package/dist/components/Illustration.d.ts +36 -0
  24. package/dist/components/Illustration.js +48 -0
  25. package/dist/components/Input.d.ts +14 -0
  26. package/dist/components/Input.js +65 -0
  27. package/dist/components/Markdown.d.ts +21 -0
  28. package/dist/components/Markdown.js +29 -0
  29. package/dist/components/Modal.d.ts +59 -0
  30. package/dist/components/Modal.js +72 -0
  31. package/dist/components/Pill.d.ts +40 -0
  32. package/dist/components/Pill.js +41 -0
  33. package/dist/components/Progress.d.ts +35 -0
  34. package/dist/components/Progress.js +27 -0
  35. package/dist/components/Rows.d.ts +101 -0
  36. package/dist/components/Rows.js +55 -0
  37. package/dist/components/Select.d.ts +28 -0
  38. package/dist/components/Select.js +56 -0
  39. package/dist/components/SizeGrid.d.ts +34 -0
  40. package/dist/components/SizeGrid.js +41 -0
  41. package/dist/components/Skeleton.d.ts +45 -0
  42. package/dist/components/Skeleton.js +47 -0
  43. package/dist/components/Slider.d.ts +70 -0
  44. package/dist/components/Slider.js +100 -0
  45. package/dist/components/Table.d.ts +43 -0
  46. package/dist/components/Table.js +13 -0
  47. package/dist/components/Tabs.d.ts +72 -0
  48. package/dist/components/Tabs.js +82 -0
  49. package/dist/components/Textarea.d.ts +9 -0
  50. package/dist/components/Textarea.js +22 -0
  51. package/dist/components/Toast.d.ts +43 -0
  52. package/dist/components/Toast.js +78 -0
  53. package/dist/components/Toggle.d.ts +56 -0
  54. package/dist/components/Toggle.js +189 -0
  55. package/dist/components/Tooltip.d.ts +22 -0
  56. package/dist/components/Tooltip.js +62 -0
  57. package/dist/components/Tour.d.ts +33 -0
  58. package/dist/components/Tour.js +108 -0
  59. package/dist/components/iconNames.d.ts +18 -0
  60. package/dist/components/iconNames.js +60 -0
  61. package/dist/components/tourMarker.d.ts +29 -0
  62. package/dist/components/tourMarker.js +58 -0
  63. package/dist/contrast.d.ts +18 -0
  64. package/dist/contrast.js +27 -0
  65. package/dist/hooks/useTrapFocus.d.ts +24 -0
  66. package/dist/hooks/useTrapFocus.js +67 -0
  67. package/dist/illustrations.d.ts +11 -0
  68. package/dist/illustrations.js +55 -0
  69. package/dist/index.d.ts +72 -0
  70. package/dist/index.js +65 -0
  71. package/dist/styles/index.css +3124 -0
  72. package/dist/themes.d.ts +210 -0
  73. package/dist/themes.js +300 -0
  74. package/dist/tokens.css +251 -0
  75. package/package.json +74 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @wtfalch/design
2
+
3
+ Components on a fixed token vocabulary, so an app can look like itself without
4
+ forking the stylesheet.
5
+
6
+ ```bash
7
+ pnpm add @wtfalch/design
8
+ ```
9
+
10
+ ```ts
11
+ import '@wtfalch/design/tokens.css' // the vocabulary and its base values
12
+ import '@wtfalch/design/styles.css' // the components
13
+ ```
14
+
15
+ ## The idea
16
+
17
+ A **theme is a set of values for a fixed vocabulary** — colour, surface,
18
+ typography, shape, density, motion and interaction. It never ships a selector.
19
+
20
+ That constraint is not a limitation, it is what makes the package safe to
21
+ change: the moment a theme can write a rule, every theme becomes a fork of the
22
+ stylesheet and no change to a component is safe again. What makes a theme
23
+ expressive instead is a vocabulary wide enough that the thing you want to vary
24
+ is already a value — a screen background, a hover lift, how fast things move.
25
+
26
+ Where a theme genuinely needs a layer that values cannot reach — a paper grain,
27
+ a vignette — the base CSS pre-declares the slot and the theme fills it. The rule
28
+ is always ours.
29
+
30
+ ## Writing a theme
31
+
32
+ ```ts
33
+ import { applyTheme, defineTheme } from '@wtfalch/design'
34
+
35
+ export const brand = defineTheme({
36
+ name: 'Brand',
37
+ note: 'Warm, roomy, and slower than the default',
38
+ scheme: 'light',
39
+ tokens: {
40
+ '--bg': '#faf7f2',
41
+ '--panel': '#ffffff',
42
+ '--accent': '#7c3aed',
43
+ '--on-accent': '#ffffff',
44
+ '--density': '1.15', // every --space-* step follows
45
+ '--font-size': '15px', // every --text-* step follows
46
+ '--dur-md': '320ms',
47
+ },
48
+ })
49
+
50
+ applyTheme(brand) // the object, straight from defineTheme
51
+ applyTheme(brand, myEl) // or on a subtree
52
+ applyTheme('paper') // or a built-in, by name
53
+ ```
54
+
55
+ Name the tokens you change; the rest inherit from `tokens.css`. A theme naming
56
+ three tokens is valid.
57
+
58
+ **A typo is a compile error.** `tokens` is a `Partial<ThemeTokens>`, so
59
+ `'--densty'` fails to build rather than silently doing nothing — which is the
60
+ failure a string-keyed map produces at run time, invisibly. This is the main
61
+ reason the type is exported at all.
62
+
63
+ ## The three kinds of token
64
+
65
+ | | |
66
+ |---|---|
67
+ | **themeable** (47) | A theme may set it. `TOKEN_KEYS`, and the keys of `ThemeTokens`. |
68
+ | **derived** (16) | `calc()` off a themeable token, and **not** settable. `--text-*` follow `--font-size`; `--space-*` follow `--density`. Move the input, not the output — a step written as a literal is a scale that stopped scaling. |
69
+ | **fixed** (3) | Not themeable. `--tile-control` is geometry other things are measured against, `--nudge` is optical alignment rather than spacing, and `--tick-mask` is a glyph — an arbitrary SVG from a theme is a theme shipping markup. |
70
+
71
+ `tokens.test.ts` holds all three lists to `tokens.css` in both directions. A
72
+ token added to the stylesheet and to no list fails the suite rather than
73
+ becoming a fourth, undocumented category.
74
+
75
+ ## The measurement ships
76
+
77
+ ```ts
78
+ import { ratio } from '@wtfalch/design'
79
+ ratio('#6d28d9', '#ffffff') // 6.30 -- the label on your primary button
80
+ ```
81
+
82
+ `test/contrast.test.ts` measures every built-in theme, pair by pair, with the
83
+ WCAG 2 formula: text on the page and on both panels, hints, the label on the
84
+ primary button, all four status colours, and the two boundaries that want
85
+ 3:1. On its first run it found the built-in information blue at 3.96:1 on
86
+ white. `ratio` and `luminance` are exported so the theme you write -- the one
87
+ nobody here will ever look at -- can be held to the same numbers.
88
+
89
+ ## Two rules that outrank any theme
90
+
91
+ **`prefers-reduced-motion` wins.** The motion tokens collapse to `0s` under it,
92
+ whatever the theme says, and the hover and press transforms go with them. Motion
93
+ is an accessibility setting before it is a style, so a theme may set the
94
+ durations and may not decide whether they apply.
95
+
96
+ **Contrast is measured, not judged.** 4.5:1 for text and 3:1 for a non-text
97
+ boundary, in every theme. `--on-accent` exists because a hardcoded white button
98
+ label vanishes under a pale accent, and `--border-strong` exists because a
99
+ hairline divider and a control's outline have different thresholds and cannot
100
+ share a value.
101
+
102
+ ## First paint
103
+
104
+ React mounts after the stylesheet, so a theme applied in an effect flashes the
105
+ default. Cache the name and apply it from a blocking script before the bundle
106
+ loads:
107
+
108
+ ```html
109
+ <script>
110
+ try {
111
+ var t = localStorage.getItem('theme')
112
+ if (t) document.documentElement.dataset.theme = t
113
+ } catch (e) {}
114
+ </script>
115
+ ```
116
+
117
+ Your server stays the source of truth. `localStorage` only beats the paint.
118
+
119
+ ## Built-ins
120
+
121
+ `system`, `night` and `paper` ship as **examples, not as the menu** — an app
122
+ that installs this is expected to bring its own. `system` is a theme rather than
123
+ a mode: it is the only one scoped to `prefers-color-scheme`, so choosing a dark
124
+ theme on a light-mode laptop is not silently repainted.
125
+
126
+ ## Status
127
+
128
+ `0.1.0`. Twenty-eight components, every one of the 70 gallery specimens
129
+ photographed in four themes, the open windows photographed too, and the
130
+ contrast, reduced-motion and keyboard rules are tests rather than sentences.
131
+ The first consumer is [tf](https://github.com/wtfalch/tf), the app it came from.
132
+
133
+ Requires React 19. Behaviour comes from
134
+ [React Aria Components](https://react-spectrum.adobe.com/react-aria/); every
135
+ pixel is the stylesheet's, styled through `data-*` attributes off the token
136
+ vocabulary. No Tailwind, no utility layer.
137
+
138
+ ## Source and issues
139
+
140
+ [github.com/wtfalch/design](https://github.com/wtfalch/design) — the package
141
+ under `packages/design`, the gallery under `gallery`, and the visual, axe and
142
+ keyboard suites under `gallery-e2e`. The repository's `CLAUDE.md` carries the
143
+ rules the components follow, each with the bug that produced it.
144
+
145
+ ## Licence
146
+
147
+ MIT.
@@ -0,0 +1,10 @@
1
+ export default function Brand({ className, title, cog, }: {
2
+ className?: string;
3
+ /** The product's name, which is what a screen reader should say the header
4
+ * starts with. There is no text beside this to repeat it. */
5
+ title?: string;
6
+ /** Show the cog. Given, the caller decides -- the settings button wraps
7
+ * the mark and hovers as a whole; left out, the mark watches its own
8
+ * pointer. */
9
+ cog?: boolean;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,212 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ /**
4
+ * The wordmark: the app icon's mark, without the tile.
5
+ *
6
+ * A lowercase tf in one unbroken stroke -- down the t, round the foot, up the
7
+ * f, over its head, and back along the crossbar through both stems. The path
8
+ * is the one `desktop/build/icon.html` draws, and `test/brandMark.test.ts`
9
+ * fails the moment the two differ. The header used to typeset "TF" to match
10
+ * an icon that did the same, and the pair only stayed in step because a
11
+ * comment in the icon asked the header to follow it.
12
+ *
13
+ * `currentColor`, so the stylesheet decides the colour and a theme can move
14
+ * it; the mark itself knows nothing about green. No `width` or `height`
15
+ * attributes either: the viewBox is the ink box, measured, and `.brand` sets
16
+ * a height off the type scale so the width follows the aspect.
17
+ *
18
+ * **Under the pointer it becomes a cog and turns.** The mark is one stroke
19
+ * and a cog's outline is one stroke, so the two are sampled at the same
20
+ * number of points along their length and the points are slid from one
21
+ * shape to the other, frame by frame -- CSS cannot morph them, because a
22
+ * path animation needs the same commands in the same order and a `V A V A A
23
+ * H` has nothing in common with a hundred `L`s round a gear. Honoured by
24
+ * `prefers-reduced-motion`: the mark stays the mark.
25
+ */
26
+ const D = 'M372 262V620A100 100 0 0 0 572 620V330A92 92 0 0 1 756 330A100 100 0 0 1 656 430H296';
27
+ /* The stroke's outer bounds, not the canvas: x 248..804, y 190..768. The
28
+ stroke is 96 wide, so 48 of cap and arc on every side is already in these. */
29
+ const VIEW = '248 190 556 578';
30
+ /* The cog, in the mark's own coordinates: centred on the ink box, teeth to
31
+ the box's edge. Six teeth, each a flat tip on two flanks over a root, as
32
+ one closed outline of `N` points -- the same `N` the mark is sampled at,
33
+ which is the whole trick. The stroke ends where it starts, and a round cap
34
+ on each end makes the seam invisible.
35
+
36
+ Eight teeth at two-thirds of the mark's weight -- the first version, and
37
+ the one kept. Six at full weight (#172) and seven at nearly full were
38
+ tried on William's "fatter, fewer teeth"; both lost the wheel: a thick
39
+ stroke fills the ring and the teeth become petals. "Can you not make it
40
+ look like the wheel we had?" This is that wheel.
41
+
42
+ Solid, since 2026-09-03. A stroked outline of a cog is a drawing of a
43
+ cog, and at header size it read as one -- "the uncanny valley where it
44
+ looks like a settings icon but isn't one". The morph is still stroke to
45
+ stroke, because that is the only way the points can slide; what changed
46
+ is the end of it: over the last quarter a fill comes up inside the
47
+ outline, with a round hole growing at the centre, and the stroke thins
48
+ to an edge. The result is the plain cog everyone recognises -- body,
49
+ hole, square teeth -- and the flanks are steeper than before so the
50
+ teeth are teeth and not petals once they are filled. Eight teeth again since
51
+ 2026-09-03 -- nine and ten were tried once the cog was solid and both taken
52
+ back; 144 samples divide by eight. */
53
+ const N = 144;
54
+ const TEETH = 8;
55
+ /* Every radius and stroke below is 0.9 of what it was on 2026-09-03: the
56
+ cog at the mark's full height read large beside the mark, 0.8 read
57
+ small, so a tenth off, proportions kept. */
58
+ const CENTRE = { x: 526, y: 479 };
59
+ const R_ROOT = 158;
60
+ const R_TIP = 236;
61
+ /* The hole, as a share of the tip radius: the reference cog's is about 0.42. */
62
+ const R_HOLE = 104;
63
+ /* The outline stroke rounds every corner of the filled polygon by half its
64
+ width -- a round join at each vertex. 40 left the teeth square; 64 was the
65
+ reference's rounding and a touch heavy. 48 now, sized with the teeth so
66
+ that a tooth is as wide as the mark's stroke: the tip arc at the outline's
67
+ outer edge is 0.22 of 2*pi*(262+24)/8 = 49, plus the outline's 48, is 97
68
+ against the mark's 96. The ring, root to hole, is 176+24-116 = 84. */
69
+ const COG_STROKE = 43;
70
+ /* A thin ring inside the hole, concentric, with clear ground between it and
71
+ the body -- the hub a cog wheel has and a plain disc with a hole does not.
72
+ Radius to the ring's centreline, and its stroke. */
73
+ const R_HUB = 56;
74
+ const HUB_STROKE = 20;
75
+ const MARK_STROKE = 96;
76
+ /* Where along the morph the fill starts coming up. Earlier and the fill
77
+ shows under a shape that is still mostly the mark, which is a blot. */
78
+ const FILL_FROM = 0.72;
79
+ function cogPoints() {
80
+ const out = [];
81
+ for (let i = 0; i < N; i++) {
82
+ const u = i / N;
83
+ const a = u * Math.PI * 2 - Math.PI / 2;
84
+ // Where in its tooth this point is: root, up the flank, along the tip,
85
+ // down the flank. The flanks are ramps rather than steps so the sampled
86
+ // outline has no corners the interpolation would cut.
87
+ const t = (u * TEETH) % 1;
88
+ let r;
89
+ // A tooth as wide as the mark's stroke -- see COG_STROKE.
90
+ // The base of a tooth spans 0.40 of its period and the tip 0.22, so each
91
+ // tooth narrows towards its tip -- the flanks lean in.
92
+ if (t < 0.3)
93
+ r = R_ROOT;
94
+ else if (t < 0.39)
95
+ r = R_ROOT + ((t - 0.3) / 0.09) * (R_TIP - R_ROOT);
96
+ else if (t < 0.61)
97
+ r = R_TIP;
98
+ else if (t < 0.7)
99
+ r = R_TIP - ((t - 0.61) / 0.09) * (R_TIP - R_ROOT);
100
+ else
101
+ r = R_ROOT;
102
+ out.push([CENTRE.x + r * Math.cos(a), CENTRE.y + r * Math.sin(a)]);
103
+ }
104
+ return out;
105
+ }
106
+ const COG = cogPoints();
107
+ /* The cog's outline, started at the tooth and run in the direction that
108
+ keeps the points' journeys shortest. Point `i` of the mark slides to point
109
+ `i` of the cog, so where the cog's `i = 0` falls -- and which way round it
110
+ goes -- decides whether the mark unwinds into the ring or crumples through
111
+ it on the way. Every start and both directions are tried once, against
112
+ the mark as measured, and the one with the least total travel wins. */
113
+ function aligned(mark) {
114
+ let best = COG;
115
+ let least = Number.POSITIVE_INFINITY;
116
+ for (const dir of [1, -1]) {
117
+ for (let start = 0; start < N; start++) {
118
+ let travel = 0;
119
+ for (let i = 0; i < N; i++) {
120
+ const c = COG[(((start + dir * i) % N) + N) % N];
121
+ const dx = c[0] - mark[i][0];
122
+ const dy = c[1] - mark[i][1];
123
+ travel += dx * dx + dy * dy;
124
+ }
125
+ if (travel < least) {
126
+ least = travel;
127
+ best = mark.map((_, i) => COG[(((start + dir * i) % N) + N) % N]);
128
+ }
129
+ }
130
+ }
131
+ return best;
132
+ }
133
+ function toPath(points, close) {
134
+ const body = points.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`).join('');
135
+ return close ? `${body}Z` : body;
136
+ }
137
+ const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
138
+ /* 180 ms, from 520: William asked for much faster. It is the time the eye
139
+ needs to see it happen, not a beat to admire. */
140
+ const MORPH_MS = 180;
141
+ export default function Brand({ className, title = 'tf', cog, }) {
142
+ const still = useRef(null);
143
+ /* The mark sampled along its own length, once, off the real path -- the
144
+ browser does the arc arithmetic -- and the cog aligned to it. Null
145
+ until it has. */
146
+ const mark = useRef(null);
147
+ const ring = useRef(COG);
148
+ /* 0 is the mark, 1 is the cog; what is drawn is the point between. */
149
+ const [t, setT] = useState(0);
150
+ const [hovered, setHovered] = useState(false);
151
+ const hover = cog ?? hovered;
152
+ const frame = useRef(0);
153
+ useEffect(() => {
154
+ const el = still.current;
155
+ if (!el || mark.current)
156
+ return;
157
+ const len = el.getTotalLength();
158
+ const pts = [];
159
+ for (let i = 0; i < N; i++) {
160
+ const p = el.getPointAtLength((len * i) / (N - 1));
161
+ pts.push([p.x, p.y]);
162
+ }
163
+ mark.current = pts;
164
+ ring.current = aligned(pts);
165
+ }, []);
166
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `t` is the starting point of a run, read once when `hover` flips -- listing it would restart the morph on every frame it sets.
167
+ useEffect(() => {
168
+ if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches)
169
+ return;
170
+ const from = t;
171
+ const to = hover ? 1 : 0;
172
+ if (from === to)
173
+ return;
174
+ const started = performance.now();
175
+ const step = (now) => {
176
+ const k = Math.min(1, (now - started) / (MORPH_MS * Math.abs(to - from)));
177
+ setT(from + (to - from) * ease(k));
178
+ if (k < 1)
179
+ frame.current = requestAnimationFrame(step);
180
+ };
181
+ frame.current = requestAnimationFrame(step);
182
+ return () => cancelAnimationFrame(frame.current);
183
+ }, [hover]);
184
+ /* Bound once, narrowed once. `mark.current` is set by the layout effect
185
+ above and `t > 0` only after that, so inside the morph it is never
186
+ undefined -- but TypeScript cannot carry that across a `&&`, and a `!` is
187
+ the thing the linter rightly refuses. A named binding says the same
188
+ thing without asserting it. */
189
+ const points = t > 0 ? mark.current : null;
190
+ const morphing = points !== null;
191
+ const d = points
192
+ ? toPath(points.map(([x, y], i) => [
193
+ x + (ring.current[i][0] - x) * t,
194
+ y + (ring.current[i][1] - y) * t,
195
+ ]), t >= 1)
196
+ : D;
197
+ const width = MARK_STROKE + (COG_STROKE - MARK_STROKE) * t;
198
+ /* The body: 0 until FILL_FROM, 1 at the cog. The hole grows with it, so
199
+ the fill arrives as a disc that opens rather than a ring that appears. */
200
+ const body = Math.max(0, Math.min(1, (t - FILL_FROM) / (1 - FILL_FROM)));
201
+ const hole = R_HOLE * body;
202
+ const holePath = body > 0
203
+ ? ` M${CENTRE.x + hole} ${CENTRE.y} A${hole} ${hole} 0 1 0 ${CENTRE.x - hole} ${CENTRE.y}` +
204
+ ` A${hole} ${hole} 0 1 0 ${CENTRE.x + hole} ${CENTRE.y} Z`
205
+ : '';
206
+ return (_jsxs("svg", {
207
+ /* `brand-turning` -- the slow spin once the cog is complete -- is not
208
+ applied since 2026-09-03: William asked to try it without. The rule
209
+ is still in the stylesheet; putting the class back is the whole
210
+ change. */
211
+ className: className, viewBox: VIEW, fill: "none", role: "img", "aria-label": title, onPointerEnter: cog === undefined ? () => setHovered(true) : undefined, onPointerLeave: cog === undefined ? () => setHovered(false) : undefined, children: [_jsx("title", { children: title }), _jsx("path", { ref: still, d: D, stroke: "currentColor", strokeWidth: "96", strokeLinecap: "round", strokeLinejoin: "round", style: morphing ? { opacity: 0 } : undefined }), morphing && body > 0 && (_jsx("path", { d: d + holePath, fill: "currentColor", fillRule: "evenodd", fillOpacity: body })), morphing && body > 0 && (_jsx("circle", { cx: CENTRE.x, cy: CENTRE.y, r: R_HUB * body, stroke: "currentColor", strokeWidth: HUB_STROKE, strokeOpacity: body })), morphing && (_jsx("path", { d: d, stroke: "currentColor", strokeWidth: width, strokeLinecap: "round", strokeLinejoin: "round" }))] }));
212
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A button. The most-copied markup in the app, finally a component.
3
+ *
4
+ * Four kinds that look like four things, three sizes, and a busy state that is
5
+ * not a disabled one. None of that is new — all of it was in the stylesheet
6
+ * already, and every call site assembled it by hand from
7
+ * `<button className="primary size-sm">`. A page in the gallery documented that
8
+ * markup, which is how a catalogue teaches people to copy rather than import.
9
+ *
10
+ * **`type="button"` is the default, and that is the whole reason this exists
11
+ * rather than a class.** A `<button>` with no type is `type="submit"`: drop one
12
+ * inside a `<form>` and pressing it submits the form and reloads the page. That
13
+ * is not a thing anybody writes on purpose, it is a thing everybody forgets —
14
+ * fifty-eight of them here, found by a linter rather than by a person. A
15
+ * default cannot be forgotten. `type="submit"` is still available, and now it
16
+ * has to be asked for, which is the right way round.
17
+ *
18
+ * **Behaviour comes from React Aria.** Press handling that works with a mouse,
19
+ * a touch, a pen and a keyboard is more than `onClick`: it is pointer capture,
20
+ * the difference between a press that ends on the button and one that drags
21
+ * off it, and not firing twice on a touch screen. `data-pressed`,
22
+ * `data-hovered` and `data-focus-visible` land on the element, so the
23
+ * stylesheet keeps describing states rather than tracking them.
24
+ */
25
+ import { type ButtonProps } from 'react-aria-components';
26
+ export interface Props extends Omit<ButtonProps, 'className' | 'style' | 'children'> {
27
+ children?: React.ReactNode;
28
+ /** The same word every other control in the package uses. React Aria spells
29
+ * it `isDisabled`, and that still works; this one exists so a consumer does
30
+ * not have to remember which of the two a given component wants. */
31
+ disabled?: boolean;
32
+ /**
33
+ * What kind of thing pressing it does. `kind`, not `tone`: across the
34
+ * package `tone` is a semantic colour -- info, good, warn, bad -- and a
35
+ * button's primary/ghost/danger is a role, not a colour.
36
+ *
37
+ * `primary` is the one action the panel is for, and there is at most one.
38
+ * `ghost` is a secondary action that should not compete. `danger` is red
39
+ * before you hover it, because hover is the one moment it is too late to be
40
+ * told.
41
+ */
42
+ kind?: 'default' | 'primary' | 'ghost' | 'danger';
43
+ /** Asked for, never inherited. Size used to come from a descendant selector,
44
+ * which made a button's size a fact about where somebody had put it. */
45
+ size?: 'sm' | 'md' | 'lg';
46
+ /**
47
+ * Working, not disabled.
48
+ *
49
+ * On `aria-busy`, so the styling and the screen reader read the same
50
+ * attribute. A busy button keeps its label — "Installing…" — because
51
+ * replacing the words with a spinner removes the only thing that says what is
52
+ * taking so long.
53
+ */
54
+ busy?: boolean;
55
+ /** Full width of whatever holds it. For a button that is the whole row. */
56
+ block?: boolean;
57
+ /** An icon and nothing else. The `aria-label` is then not optional, and
58
+ * TypeScript cannot make it required here without a second component, so
59
+ * `a11y.spec.ts` is what actually holds it. */
60
+ iconOnly?: boolean;
61
+ className?: string;
62
+ }
63
+ export default function Button({ kind, disabled, size, busy, block, iconOnly, className, children, ...rest }: Props): import("react").JSX.Element;
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * A button. The most-copied markup in the app, finally a component.
4
+ *
5
+ * Four kinds that look like four things, three sizes, and a busy state that is
6
+ * not a disabled one. None of that is new — all of it was in the stylesheet
7
+ * already, and every call site assembled it by hand from
8
+ * `<button className="primary size-sm">`. A page in the gallery documented that
9
+ * markup, which is how a catalogue teaches people to copy rather than import.
10
+ *
11
+ * **`type="button"` is the default, and that is the whole reason this exists
12
+ * rather than a class.** A `<button>` with no type is `type="submit"`: drop one
13
+ * inside a `<form>` and pressing it submits the form and reloads the page. That
14
+ * is not a thing anybody writes on purpose, it is a thing everybody forgets —
15
+ * fifty-eight of them here, found by a linter rather than by a person. A
16
+ * default cannot be forgotten. `type="submit"` is still available, and now it
17
+ * has to be asked for, which is the right way round.
18
+ *
19
+ * **Behaviour comes from React Aria.** Press handling that works with a mouse,
20
+ * a touch, a pen and a keyboard is more than `onClick`: it is pointer capture,
21
+ * the difference between a press that ends on the button and one that drags
22
+ * off it, and not firing twice on a touch screen. `data-pressed`,
23
+ * `data-hovered` and `data-focus-visible` land on the element, so the
24
+ * stylesheet keeps describing states rather than tracking them.
25
+ */
26
+ import { useEffect, useRef } from 'react';
27
+ import { Button as AriaButton } from 'react-aria-components';
28
+ export default function Button({ kind = 'default', disabled, size = 'md', busy, block, iconOnly, className, children, ...rest }) {
29
+ const classes = [
30
+ iconOnly ? 'icon-btn' : '',
31
+ kind === 'default' ? '' : kind,
32
+ size === 'md' ? '' : `size-${size}`,
33
+ block ? 'block' : '',
34
+ className ?? '',
35
+ ]
36
+ .filter(Boolean)
37
+ .join(' ');
38
+ /**
39
+ * `aria-busy`, set on the element rather than passed as a prop.
40
+ *
41
+ * React Aria runs everything through `filterDOMProps`, which forwards only
42
+ * the labelling aria attributes -- `aria-label`, `aria-labelledby`,
43
+ * `aria-describedby`, `aria-details`. `aria-busy` is dropped in silence, so
44
+ * the prop looked right, typechecked, and the countdown bar under a busy
45
+ * button simply stopped rendering.
46
+ *
47
+ * The obvious alternative is React Aria's own `isPending`, and it is the
48
+ * wrong one here: it marks the button `aria-disabled` and stops it being
49
+ * pressed. This component's whole documented distinction is that busy is
50
+ * *not* disabled -- "Working, not disabled", and the label stays because it
51
+ * is the only thing saying what is taking so long. Swapping the semantics to
52
+ * get a tidier call site would be changing behaviour nobody asked to change.
53
+ */
54
+ const ref = useRef(null);
55
+ useEffect(() => {
56
+ const el = ref.current;
57
+ if (!el)
58
+ return;
59
+ if (busy)
60
+ el.setAttribute('aria-busy', 'true');
61
+ else
62
+ el.removeAttribute('aria-busy');
63
+ }, [busy]);
64
+ return (_jsx(AriaButton, { ref: ref,
65
+ /* The default, and the point of the component. React Aria sets this too;
66
+ stating it here means the prop is visible in the signature rather than
67
+ inherited from a library the caller has not read. */
68
+ type: "button", ...rest, isDisabled: rest.isDisabled ?? disabled,
69
+ /* Always a string, never undefined. React Aria stamps its own
70
+ `react-aria-Button` class when `className` is absent, so an untoned
71
+ button arrived carrying a class from a library the stylesheet has never
72
+ heard of. */
73
+ className: classes, children: children }));
74
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Something the page needs to say, in the place it applies to.
3
+ *
4
+ * The third component the gallery documented and nobody could import — 31 call
5
+ * sites assembling `className="callout callout-bad"` by hand, and a `-mark`
6
+ * that was a letter somebody typed (`i`, `!`, `✓`) rather than the icon set the
7
+ * rest of the app draws from.
8
+ *
9
+ * **A Callout is not a Toast.** A toast floats over the page and takes no
10
+ * space; this sits in the flow, next to the thing it is about, and pushes what
11
+ * follows down. That is the point of it: "this server is not answering" belongs
12
+ * beside the server, not in the corner.
13
+ *
14
+ * **`timed` makes it a toast that stayed home.** A countdown bar, five seconds,
15
+ * then gone. The rule that comes with it: **anything that disappears on a timer
16
+ * must be safe to have missed.** "Saved" qualifies. "This download failed" does
17
+ * not, and neither does anything carrying a button — if the reader has to act,
18
+ * the message waits for them. The countdown pauses on hover for the same
19
+ * reason, because a message that expires while you are reading it was never
20
+ * really shown.
21
+ */
22
+ import { type IconName } from './Icon';
23
+ export default function Callout({ tone, children, icon, timed, onDismiss, className, }: {
24
+ tone?: 'info' | 'good' | 'warn' | 'bad';
25
+ children: React.ReactNode;
26
+ /** Off by default: most callouts are a sentence, and an icon beside every
27
+ * sentence is noise. `true` uses the one that matches the tone. */
28
+ icon?: boolean | IconName;
29
+ /** `true` for five seconds, a number for that many milliseconds. */
30
+ timed?: boolean | number;
31
+ /** Told when the countdown runs out, for a caller that keeps its own state.
32
+ * Optional: `timed` on its own is enough, and the callout removes itself.
33
+ * Requiring a callback to make a timer work is a bar that counts down to
34
+ * nothing when somebody forgets one. */
35
+ onDismiss?: () => void;
36
+ className?: string;
37
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Something the page needs to say, in the place it applies to.
4
+ *
5
+ * The third component the gallery documented and nobody could import — 31 call
6
+ * sites assembling `className="callout callout-bad"` by hand, and a `-mark`
7
+ * that was a letter somebody typed (`i`, `!`, `✓`) rather than the icon set the
8
+ * rest of the app draws from.
9
+ *
10
+ * **A Callout is not a Toast.** A toast floats over the page and takes no
11
+ * space; this sits in the flow, next to the thing it is about, and pushes what
12
+ * follows down. That is the point of it: "this server is not answering" belongs
13
+ * beside the server, not in the corner.
14
+ *
15
+ * **`timed` makes it a toast that stayed home.** A countdown bar, five seconds,
16
+ * then gone. The rule that comes with it: **anything that disappears on a timer
17
+ * must be safe to have missed.** "Saved" qualifies. "This download failed" does
18
+ * not, and neither does anything carrying a button — if the reader has to act,
19
+ * the message waits for them. The countdown pauses on hover for the same
20
+ * reason, because a message that expires while you are reading it was never
21
+ * really shown.
22
+ */
23
+ import { useEffect, useRef, useState } from 'react';
24
+ import Icon from './Icon';
25
+ /** Long enough to read twice, which is how long it takes to notice something
26
+ * appeared and then read it.
27
+ *
28
+ * Not exported: nothing outside this file used it, and a non-component export
29
+ * costs the whole module its Fast Refresh boundary -- editing the callout then
30
+ * re-runs App instead of swapping this component in place. */
31
+ const CALLOUT_TIMEOUT = 5000;
32
+ const MARK = {
33
+ info: 'info',
34
+ good: 'check',
35
+ warn: 'warning',
36
+ bad: 'error',
37
+ };
38
+ export default function Callout({ tone, children, icon, timed, onDismiss, className, }) {
39
+ const total = typeof timed === 'number' ? timed : CALLOUT_TIMEOUT;
40
+ const [gone, setGone] = useState(false);
41
+ const running = Boolean(timed) && !gone;
42
+ const [paused, setPaused] = useState(false);
43
+ const left = useRef(total);
44
+ const since = useRef(0);
45
+ useEffect(() => {
46
+ if (!running || paused)
47
+ return;
48
+ since.current = Date.now();
49
+ const timer = setTimeout(() => {
50
+ setGone(true);
51
+ onDismiss?.();
52
+ }, left.current);
53
+ return () => {
54
+ clearTimeout(timer);
55
+ // What is left when a hover interrupts, so resuming does not restart the
56
+ // five seconds from the top every time the pointer crosses it.
57
+ left.current = Math.max(0, left.current - (Date.now() - since.current));
58
+ };
59
+ }, [running, paused, onDismiss]);
60
+ const mark = icon === true ? MARK[tone ?? 'info'] : typeof icon === 'string' ? icon : null;
61
+ if (gone)
62
+ return null;
63
+ return (_jsxs("div", { className: `callout${tone ? ` callout-${tone}` : ''}${running ? ' callout-timed' : ''}${className ? ` ${className}` : ''}`,
64
+ /* An alert interrupts whatever is being read, which is right for bad news
65
+ and rude for "saved". */
66
+ role: tone === 'bad' ? 'alert' : 'status', onMouseEnter: () => running && setPaused(true), onMouseLeave: () => running && setPaused(false), onFocusCapture: () => running && setPaused(true), onBlurCapture: () => running && setPaused(false), children: [mark && (_jsx("span", { className: "callout-mark", "aria-hidden": "true", children: _jsx(Icon, { name: mark, size: 16 }) })), _jsx("div", { className: "grow", children: children }), running && (
67
+ /* Decoration: the time remaining is not information anybody can act on,
68
+ and a screen reader counting down a bar is noise on top of a message
69
+ it has already read out. */
70
+ _jsx("span", { className: `callout-clock${paused ? ' is-held' : ''}`, style: { animationDuration: `${total}ms` }, "aria-hidden": "true" }))] }));
71
+ }