@wtfalch/design 0.1.0 → 0.3.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/README.md +84 -37
- package/dist/components/Brand.d.ts +35 -6
- package/dist/components/Brand.js +26 -201
- package/dist/components/Icon.d.ts +1 -1
- package/dist/components/Icon.js +5 -228
- package/dist/components/Illustration.js +1 -2
- package/dist/components/Tour.d.ts +3 -1
- package/dist/components/Tour.js +3 -3
- package/dist/components/brandMarks.d.ts +71 -0
- package/dist/components/brandMarks.js +26 -0
- package/dist/components/icons.d.ts +37 -0
- package/dist/components/icons.js +253 -0
- package/dist/components/tourMarker.d.ts +14 -3
- package/dist/components/tourMarker.js +11 -7
- package/dist/index.d.ts +13 -1
- package/dist/index.js +11 -1
- package/dist/products/index.d.ts +69 -0
- package/dist/products/index.js +54 -0
- package/dist/products/tf.d.ts +32 -0
- package/dist/products/tf.js +104 -0
- package/dist/products/valet.d.ts +23 -0
- package/dist/products/valet.js +103 -0
- package/dist/styles/index.css +23 -57
- package/dist/tf.css +3362 -0
- package/dist/tf.d.ts +18 -0
- package/dist/tf.js +17 -0
- package/dist/themes/css.d.ts +45 -0
- package/dist/themes/css.js +78 -0
- package/dist/{themes.d.ts → themes/index.d.ts} +11 -13
- package/dist/{themes.js → themes/index.js} +12 -89
- package/dist/tokens.css +17 -0
- package/dist/valet.css +3366 -0
- package/dist/valet.d.ts +18 -0
- package/dist/valet.js +17 -0
- package/package.json +15 -5
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @wtfalch/design
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
forking the stylesheet.
|
|
3
|
+
wtfalch's design system: components on a fixed token vocabulary, so every
|
|
4
|
+
product can look like itself without forking the stylesheet.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
pnpm add @wtfalch/design
|
|
@@ -27,38 +27,70 @@ Where a theme genuinely needs a layer that values cannot reach — a paper grain
|
|
|
27
27
|
a vignette — the base CSS pre-declares the slot and the theme fills it. The rule
|
|
28
28
|
is always ours.
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Products
|
|
31
|
+
|
|
32
|
+
A site is one product, so it imports its product and everything it touches is
|
|
33
|
+
its own:
|
|
31
34
|
|
|
32
35
|
```ts
|
|
33
|
-
import
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
36
|
+
import '@wtfalch/design/valet.css' // the vocabulary, valet's identity, the components, valet's themes
|
|
37
|
+
import { Brand, Button, applyTheme } from '@wtfalch/design/valet'
|
|
38
|
+
// the same components; Brand is valet's badge, applyTheme knows valet's themes
|
|
53
39
|
```
|
|
54
40
|
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
Three layers, each falling back to the one under it:
|
|
42
|
+
|
|
43
|
+
| | |
|
|
44
|
+
|---|---|
|
|
45
|
+
| **the system** | The components, the base values in `tokens.css`, the shared icons and illustrations. |
|
|
46
|
+
| **the product** | Its mark, and its identity: the tokens that make it itself under every theme — font, shape, density. On `:root` in the product's stylesheet, so a theme that is silent on them gets the product, not tf. `src/products/<name>.ts`. |
|
|
47
|
+
| **the theme** | A palette and a colour scheme, plus anything it deliberately changes. One `:root[data-theme='<id>']` rule each, generated from the object. |
|
|
48
|
+
|
|
49
|
+
The middle layer is what lets a theme be shared between products: it names its
|
|
50
|
+
colours and inherits the identity of whichever product wears it. Before it
|
|
51
|
+
existed, valet's two palettes each restated valet's font and corners, and a
|
|
52
|
+
palette written for two products would have shown tf's font on valet wherever
|
|
53
|
+
it kept quiet.
|
|
54
|
+
|
|
55
|
+
The product's default theme is also written on `:root` when no `data-theme` is
|
|
56
|
+
set, so the first paint is right with no attribute at all; set the attribute
|
|
57
|
+
before the bundle loads only to restore a theme somebody picked (see First
|
|
58
|
+
paint). tf's default is `system`, a `prefers-color-scheme` rule rather than a
|
|
59
|
+
palette, so tf still sets the attribute.
|
|
60
|
+
|
|
61
|
+
The main entry is the neutral view of all of it: `PRODUCTS` by name, `THEMES`
|
|
62
|
+
as the union every product's picker and the contrast test read,
|
|
63
|
+
`productTheme(product, id)` for a theme as a product wears it, `bindProduct`
|
|
64
|
+
for a product defined outside this package, and `productStylesheet` for its
|
|
65
|
+
CSS.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { PRODUCTS, applyTheme, productTheme } from '@wtfalch/design'
|
|
69
|
+
|
|
70
|
+
applyTheme(productTheme(PRODUCTS.valet, 'valet-night')) // valet night, on valet's identity
|
|
71
|
+
applyTheme('valet-night') // the palette alone, over the base
|
|
72
|
+
applyTheme(productTheme(PRODUCTS.valet), myEl) // valet's default, on a subtree
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Writing a theme
|
|
76
|
+
|
|
77
|
+
A theme is a `Partial<ThemeTokens>` with a name, a note and a scheme: name the
|
|
78
|
+
tokens you change, the rest inherit from the product's identity and then from
|
|
79
|
+
`tokens.css`. A theme naming three tokens is valid. A product's themes go in
|
|
80
|
+
`src/products/<name>.ts` beside its identity, keyed by the id `applyTheme`
|
|
81
|
+
derives from the name (lowercased, spaces to hyphens); `products.test.ts`
|
|
82
|
+
refuses a key that disagrees, a palette that restates its product's identity,
|
|
83
|
+
and a default that is not one of the product's themes. `build-products.mjs`
|
|
84
|
+
writes `dist/<name>.css` from the objects at build time.
|
|
57
85
|
|
|
58
86
|
**A typo is a compile error.** `tokens` is a `Partial<ThemeTokens>`, so
|
|
59
87
|
`'--densty'` fails to build rather than silently doing nothing — which is the
|
|
60
|
-
failure a string-keyed map produces at run time, invisibly.
|
|
61
|
-
|
|
88
|
+
failure a string-keyed map produces at run time, invisibly.
|
|
89
|
+
|
|
90
|
+
**A product names its font and does not ship it.** valet's identity sets
|
|
91
|
+
`--font` to read a `--font-sans` variable the app defines with whatever loads
|
|
92
|
+
its fonts, and falls back to the family by name. The gallery vendors the two
|
|
93
|
+
families valet names so the specimens are photographed in them.
|
|
62
94
|
|
|
63
95
|
## The three kinds of token
|
|
64
96
|
|
|
@@ -101,9 +133,10 @@ share a value.
|
|
|
101
133
|
|
|
102
134
|
## First paint
|
|
103
135
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
136
|
+
Your product's stylesheet paints its default theme with no attribute set, so
|
|
137
|
+
this is for restoring a choice. React mounts after the stylesheet, so a theme
|
|
138
|
+
applied in an effect flashes the default. Cache the name and apply it from a
|
|
139
|
+
blocking script before the bundle loads:
|
|
107
140
|
|
|
108
141
|
```html
|
|
109
142
|
<script>
|
|
@@ -116,19 +149,33 @@ loads:
|
|
|
116
149
|
|
|
117
150
|
Your server stays the source of truth. `localStorage` only beats the paint.
|
|
118
151
|
|
|
119
|
-
##
|
|
152
|
+
## Marks
|
|
153
|
+
|
|
154
|
+
Every product's mark, by name, in `brandMarks.ts`. tf's is one stroke. valet's
|
|
155
|
+
is a filled badge: the jacket with the shirt cut out of it and a bow tie in the
|
|
156
|
+
cut, one path under `evenodd` so the surface shows through the shirt. Both are
|
|
157
|
+
`currentColor`, so the stylesheet decides the colour and a theme can move it.
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
import { BRAND_MARKS, Brand } from '@wtfalch/design'
|
|
161
|
+
|
|
162
|
+
<Brand name="valet" /> // anywhere; a product entry's Brand defaults to its own
|
|
163
|
+
BRAND_MARKS.valet.d // the path, for a favicon or an app icon cut from the same drawing
|
|
164
|
+
```
|
|
120
165
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
theme on a light-mode laptop is not silently repainted.
|
|
166
|
+
Icons and illustrations are the system's, shared by every product the way
|
|
167
|
+
`Button` is. A product wanting its own inside the package's components is a
|
|
168
|
+
case nobody has had; when it comes, the product entry is where to bind it.
|
|
125
169
|
|
|
126
170
|
## Status
|
|
127
171
|
|
|
128
|
-
`0.
|
|
172
|
+
`0.3.0`. Twenty-eight components, every one of the 70 gallery specimens
|
|
129
173
|
photographed in four themes, the open windows photographed too, and the
|
|
130
174
|
contrast, reduced-motion and keyboard rules are tests rather than sentences.
|
|
131
|
-
|
|
175
|
+
It came out of [tf](https://github.com/wtfalch/tf), which is its first consumer;
|
|
176
|
+
valet is the second. A product is a layer: tf and valet each ship as one
|
|
177
|
+
stylesheet and one entry, with their identity under their themes and their
|
|
178
|
+
mark in the table.
|
|
132
179
|
|
|
133
180
|
Requires React 19. Behaviour comes from
|
|
134
181
|
[React Aria Components](https://react-spectrum.adobe.com/react-aria/); every
|
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { type BrandName } from './brandMarks';
|
|
2
|
+
/**
|
|
3
|
+
* A product's mark, at header size.
|
|
4
|
+
*
|
|
5
|
+
* One component for every wtfalch product: `name` picks the mark out of
|
|
6
|
+
* `brandMarks.ts`, and adding a product is adding a row there. The default is
|
|
7
|
+
* `tf` because it is the first, not because it is special.
|
|
8
|
+
*
|
|
9
|
+
* `currentColor`, so the stylesheet decides the colour and a theme can move
|
|
10
|
+
* it; the mark itself knows nothing about green. No `width` or `height`
|
|
11
|
+
* attributes either: the viewBox is the ink box, measured, and `.brand` sets
|
|
12
|
+
* a height off the type scale so the width follows the aspect.
|
|
13
|
+
*
|
|
14
|
+
* Still, on purpose. Until 0.2.0 this component also morphed tf's mark into a
|
|
15
|
+
* cog under the pointer, which made a shared package carry one product's
|
|
16
|
+
* animation. That morph is tf's own `Brand` now, layered over this path; a
|
|
17
|
+
* product that wants its mark to move does the same in its own code, and the
|
|
18
|
+
* package stays the source of the still shape every product's icon is drawn
|
|
19
|
+
* from.
|
|
20
|
+
*
|
|
21
|
+
* Stroked or filled, whichever the mark is. tf's is one line at a weight;
|
|
22
|
+
* valet's is a filled badge with the shirt cut out of it and the bow inside
|
|
23
|
+
* the cut, one path under `evenodd` so the surface shows through the shirt.
|
|
24
|
+
* Both are `currentColor`, so `.brand` still decides the colour and a theme
|
|
25
|
+
* can still move it -- a second colour would be the one thing a theme could
|
|
26
|
+
* not reach.
|
|
27
|
+
*
|
|
28
|
+
* A product's entry, `@wtfalch/design/<product>`, exports this with the
|
|
29
|
+
* product's name as the default, so a header there writes `<Brand />` and
|
|
30
|
+
* gets its own mark. Here the default is the first row.
|
|
31
|
+
*/
|
|
32
|
+
export default function Brand({ name, title, className, }: {
|
|
33
|
+
/** Which product's mark. */
|
|
34
|
+
name?: BrandName;
|
|
3
35
|
/** The product's name, which is what a screen reader should say the header
|
|
4
36
|
* starts with. There is no text beside this to repeat it. */
|
|
5
37
|
title?: string;
|
|
6
|
-
|
|
7
|
-
* the mark and hovers as a whole; left out, the mark watches its own
|
|
8
|
-
* pointer. */
|
|
9
|
-
cog?: boolean;
|
|
38
|
+
className?: string;
|
|
10
39
|
}): import("react").JSX.Element;
|
package/dist/components/Brand.js
CHANGED
|
@@ -1,212 +1,37 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { BRAND_MARKS, BRAND_NAMES } from './brandMarks';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* A product's mark, at header size.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* is the
|
|
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.
|
|
6
|
+
* One component for every wtfalch product: `name` picks the mark out of
|
|
7
|
+
* `brandMarks.ts`, and adding a product is adding a row there. The default is
|
|
8
|
+
* `tf` because it is the first, not because it is special.
|
|
12
9
|
*
|
|
13
10
|
* `currentColor`, so the stylesheet decides the colour and a theme can move
|
|
14
11
|
* it; the mark itself knows nothing about green. No `width` or `height`
|
|
15
12
|
* attributes either: the viewBox is the ink box, measured, and `.brand` sets
|
|
16
13
|
* a height off the type scale so the width follows the aspect.
|
|
17
14
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
15
|
+
* Still, on purpose. Until 0.2.0 this component also morphed tf's mark into a
|
|
16
|
+
* cog under the pointer, which made a shared package carry one product's
|
|
17
|
+
* animation. That morph is tf's own `Brand` now, layered over this path; a
|
|
18
|
+
* product that wants its mark to move does the same in its own code, and the
|
|
19
|
+
* package stays the source of the still shape every product's icon is drawn
|
|
20
|
+
* from.
|
|
21
|
+
*
|
|
22
|
+
* Stroked or filled, whichever the mark is. tf's is one line at a weight;
|
|
23
|
+
* valet's is a filled badge with the shirt cut out of it and the bow inside
|
|
24
|
+
* the cut, one path under `evenodd` so the surface shows through the shirt.
|
|
25
|
+
* Both are `currentColor`, so `.brand` still decides the colour and a theme
|
|
26
|
+
* can still move it -- a second colour would be the one thing a theme could
|
|
27
|
+
* not reach.
|
|
28
|
+
*
|
|
29
|
+
* A product's entry, `@wtfalch/design/<product>`, exports this with the
|
|
30
|
+
* product's name as the default, so a header there writes `<Brand />` and
|
|
31
|
+
* gets its own mark. Here the default is the first row.
|
|
25
32
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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" }))] }));
|
|
33
|
+
export default function Brand({ name = BRAND_NAMES[0], title, className, }) {
|
|
34
|
+
const mark = BRAND_MARKS[name];
|
|
35
|
+
const said = title ?? name;
|
|
36
|
+
return (_jsxs("svg", { className: `brand${className ? ` ${className}` : ''}`, viewBox: mark.view, fill: "none", role: "img", "aria-label": said, children: [_jsx("title", { children: said }), 'fill' in mark ? (_jsx("path", { d: mark.d, fill: "currentColor", fillRule: mark.fill })) : (_jsx("path", { d: mark.d, stroke: "currentColor", strokeWidth: mark.stroke, strokeLinecap: "round", strokeLinejoin: "round" }))] }));
|
|
212
37
|
}
|
|
@@ -61,4 +61,4 @@ export default function Icon({ name, size, className, title, }: {
|
|
|
61
61
|
/** Given only when the icon is the whole message. An icon beside a label it
|
|
62
62
|
* repeats is decoration, and decoration announced twice is noise. */
|
|
63
63
|
title?: string;
|
|
64
|
-
}): import("react").JSX.Element;
|
|
64
|
+
}): import("react").JSX.Element | null;
|