@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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/components/Brand.d.ts +10 -0
- package/dist/components/Brand.js +212 -0
- package/dist/components/Button.d.ts +63 -0
- package/dist/components/Button.js +74 -0
- package/dist/components/Callout.d.ts +37 -0
- package/dist/components/Callout.js +71 -0
- package/dist/components/Card.d.ts +42 -0
- package/dist/components/Card.js +30 -0
- package/dist/components/Checkbox.d.ts +32 -0
- package/dist/components/Checkbox.js +31 -0
- package/dist/components/DangerZone.d.ts +59 -0
- package/dist/components/DangerZone.js +50 -0
- package/dist/components/Dialog.d.ts +28 -0
- package/dist/components/Dialog.js +29 -0
- package/dist/components/Empty.d.ts +45 -0
- package/dist/components/Empty.js +35 -0
- package/dist/components/Field.d.ts +58 -0
- package/dist/components/Field.js +46 -0
- package/dist/components/Icon.d.ts +64 -0
- package/dist/components/Icon.js +235 -0
- package/dist/components/Illustration.d.ts +36 -0
- package/dist/components/Illustration.js +48 -0
- package/dist/components/Input.d.ts +14 -0
- package/dist/components/Input.js +65 -0
- package/dist/components/Markdown.d.ts +21 -0
- package/dist/components/Markdown.js +29 -0
- package/dist/components/Modal.d.ts +59 -0
- package/dist/components/Modal.js +72 -0
- package/dist/components/Pill.d.ts +40 -0
- package/dist/components/Pill.js +41 -0
- package/dist/components/Progress.d.ts +35 -0
- package/dist/components/Progress.js +27 -0
- package/dist/components/Rows.d.ts +101 -0
- package/dist/components/Rows.js +55 -0
- package/dist/components/Select.d.ts +28 -0
- package/dist/components/Select.js +56 -0
- package/dist/components/SizeGrid.d.ts +34 -0
- package/dist/components/SizeGrid.js +41 -0
- package/dist/components/Skeleton.d.ts +45 -0
- package/dist/components/Skeleton.js +47 -0
- package/dist/components/Slider.d.ts +70 -0
- package/dist/components/Slider.js +100 -0
- package/dist/components/Table.d.ts +43 -0
- package/dist/components/Table.js +13 -0
- package/dist/components/Tabs.d.ts +72 -0
- package/dist/components/Tabs.js +82 -0
- package/dist/components/Textarea.d.ts +9 -0
- package/dist/components/Textarea.js +22 -0
- package/dist/components/Toast.d.ts +43 -0
- package/dist/components/Toast.js +78 -0
- package/dist/components/Toggle.d.ts +56 -0
- package/dist/components/Toggle.js +189 -0
- package/dist/components/Tooltip.d.ts +22 -0
- package/dist/components/Tooltip.js +62 -0
- package/dist/components/Tour.d.ts +33 -0
- package/dist/components/Tour.js +108 -0
- package/dist/components/iconNames.d.ts +18 -0
- package/dist/components/iconNames.js +60 -0
- package/dist/components/tourMarker.d.ts +29 -0
- package/dist/components/tourMarker.js +58 -0
- package/dist/contrast.d.ts +18 -0
- package/dist/contrast.js +27 -0
- package/dist/hooks/useTrapFocus.d.ts +24 -0
- package/dist/hooks/useTrapFocus.js +67 -0
- package/dist/illustrations.d.ts +11 -0
- package/dist/illustrations.js +55 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +65 -0
- package/dist/styles/index.css +3124 -0
- package/dist/themes.d.ts +210 -0
- package/dist/themes.js +300 -0
- package/dist/tokens.css +251 -0
- 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
|
+
}
|