@wtfalch/design 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # @wtfalch/design
2
2
 
3
- Components on a fixed token vocabulary, so an app can look like itself without
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
@@ -125,10 +125,11 @@ theme on a light-mode laptop is not silently repainted.
125
125
 
126
126
  ## Status
127
127
 
128
- `0.1.0`. Twenty-eight components, every one of the 70 gallery specimens
128
+ `0.2.0`. Twenty-eight components, every one of the 70 gallery specimens
129
129
  photographed in four themes, the open windows photographed too, and the
130
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.
131
+ It came out of [tf](https://github.com/wtfalch/tf), which is its first consumer;
132
+ `Brand` holds every product's mark by name, tf's first.
132
133
 
133
134
  Requires React 19. Behaviour comes from
134
135
  [React Aria Components](https://react-spectrum.adobe.com/react-aria/); every
@@ -1,10 +1,28 @@
1
- export default function Brand({ className, title, cog, }: {
2
- className?: string;
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
+ export default function Brand({ name, title, className, }: {
22
+ /** Which product's mark. */
23
+ name?: BrandName;
3
24
  /** The product's name, which is what a screen reader should say the header
4
25
  * starts with. There is no text beside this to repeat it. */
5
26
  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;
27
+ className?: string;
10
28
  }): import("react").JSX.Element;
@@ -1,212 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { BRAND_MARKS } from './brandMarks';
3
3
  /**
4
- * The wordmark: the app icon's mark, without the tile.
4
+ * A product's mark, at header size.
5
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.
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
- * **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.
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.
25
21
  */
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" }))] }));
22
+ export default function Brand({ name = 'tf', title = name, className, }) {
23
+ const mark = BRAND_MARKS[name];
24
+ return (_jsxs("svg", { className: `brand${className ? ` ${className}` : ''}`, viewBox: mark.view, fill: "none", role: "img", "aria-label": title, children: [_jsx("title", { children: title }), _jsx("path", { d: mark.d, stroke: "currentColor", strokeWidth: mark.stroke, strokeLinecap: "round", strokeLinejoin: "round" })] }));
212
25
  }
@@ -27,7 +27,9 @@ export interface TourStop {
27
27
  title: string;
28
28
  body: React.ReactNode;
29
29
  }
30
- export default function Tour({ stops, onDone }: {
30
+ export default function Tour({ stops, onDone, storageKey, }: {
31
31
  stops: TourStop[];
32
32
  onDone: () => void;
33
+ /** Where this product records that the tour was seen. See `tourMarker.ts`. */
34
+ storageKey?: string;
33
35
  }): import("react").ReactPortal | null;
@@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
26
26
  import { createPortal } from 'react-dom';
27
27
  import Button from './Button';
28
28
  import { markTourSeen } from './tourMarker';
29
- export default function Tour({ stops, onDone }) {
29
+ export default function Tour({ stops, onDone, storageKey, }) {
30
30
  const [at, setAt] = useState(0);
31
31
  const [box, setBox] = useState(null);
32
32
  const card = useRef(null);
@@ -47,9 +47,9 @@ export default function Tour({ stops, onDone }) {
47
47
  "no stop", which ends the tour. Only reachable in development. */
48
48
  const stop = live[Math.min(at, Math.max(0, live.length - 1))];
49
49
  const finish = useCallback(() => {
50
- markTourSeen();
50
+ markTourSeen(storageKey);
51
51
  onDone();
52
- }, [onDone]);
52
+ }, [onDone, storageKey]);
53
53
  useLayoutEffect(() => {
54
54
  if (!stop)
55
55
  return finish();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The marks, one per wtfalch product.
3
+ *
4
+ * `@wtfalch/design` serves more than one product, so `Brand` cannot be one
5
+ * product's logo -- it is the place every product's mark lives, and adding a
6
+ * brand to the system is adding a row here. A mark is a path in its own ink
7
+ * box, drawn with `currentColor` at the stroke weight it was designed at, so
8
+ * the stylesheet decides the colour and a theme can move it.
9
+ *
10
+ * A product that animates its mark does that in its own code: tf's lowercase
11
+ * tf becomes a cog under the pointer, and that morph is
12
+ * `tf/dashboard/src/components/Brand.tsx`, not this package's business. What
13
+ * ships here is the still mark, which is also what the product's app icon is
14
+ * rendered from -- tf's `brandMark.test.ts` holds the icon, this row and its
15
+ * own animated copy to one path.
16
+ *
17
+ * A sibling module rather than part of `Brand.tsx`, so that file keeps its
18
+ * Fast Refresh boundary (`fastRefresh.test.ts`).
19
+ */
20
+ export declare const BRAND_MARKS: {
21
+ /** A lowercase tf in one unbroken stroke -- down the t, round the foot, up
22
+ * the f, over its head, and back along the crossbar through both stems.
23
+ * The view is the stroke's outer bounds, not the canvas: the stroke is 96
24
+ * wide, so 48 of cap and arc on every side is already in these. */
25
+ readonly tf: {
26
+ readonly view: "248 190 556 578";
27
+ readonly d: "M372 262V620A100 100 0 0 0 572 620V330A92 92 0 0 1 756 330A100 100 0 0 1 656 430H296";
28
+ readonly stroke: 96;
29
+ };
30
+ };
31
+ export type BrandName = keyof typeof BRAND_MARKS;
32
+ export declare const BRAND_NAMES: BrandName[];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The marks, one per wtfalch product.
3
+ *
4
+ * `@wtfalch/design` serves more than one product, so `Brand` cannot be one
5
+ * product's logo -- it is the place every product's mark lives, and adding a
6
+ * brand to the system is adding a row here. A mark is a path in its own ink
7
+ * box, drawn with `currentColor` at the stroke weight it was designed at, so
8
+ * the stylesheet decides the colour and a theme can move it.
9
+ *
10
+ * A product that animates its mark does that in its own code: tf's lowercase
11
+ * tf becomes a cog under the pointer, and that morph is
12
+ * `tf/dashboard/src/components/Brand.tsx`, not this package's business. What
13
+ * ships here is the still mark, which is also what the product's app icon is
14
+ * rendered from -- tf's `brandMark.test.ts` holds the icon, this row and its
15
+ * own animated copy to one path.
16
+ *
17
+ * A sibling module rather than part of `Brand.tsx`, so that file keeps its
18
+ * Fast Refresh boundary (`fastRefresh.test.ts`).
19
+ */
20
+ export const BRAND_MARKS = {
21
+ /** A lowercase tf in one unbroken stroke -- down the t, round the foot, up
22
+ * the f, over its head, and back along the crossbar through both stems.
23
+ * The view is the stroke's outer bounds, not the canvas: the stroke is 96
24
+ * wide, so 48 of cap and arc on every side is already in these. */
25
+ tf: {
26
+ view: '248 190 556 578',
27
+ d: 'M372 262V620A100 100 0 0 0 572 620V330A92 92 0 0 1 756 330A100 100 0 0 1 656 430H296',
28
+ stroke: 96,
29
+ },
30
+ };
31
+ export const BRAND_NAMES = Object.keys(BRAND_MARKS);
@@ -11,7 +11,18 @@
11
11
  * These are also not React at all: three reads and writes of one string. They
12
12
  * were only in the component file because that is where the tour was written.
13
13
  */
14
- export declare function tourSeen(): boolean;
14
+ /** Remembered per browser: a reload in the middle should not start it again,
15
+ * and neither should opening the app tomorrow.
16
+ *
17
+ * Re-running onboarding clears it -- see `forgetTour`. There is still no way
18
+ * to replay the tour on its own without doing that, which is worth a row in
19
+ * Settings and is said here rather than left implied. */
20
+ /** Where a product records that the tour was seen. Every product that installs
21
+ * the package gets its own key by passing one; the default is the package's,
22
+ * so two products on one origin do not share a memory. tf passes
23
+ * `tf-tour-seen`, the key its users already hold. */
24
+ export declare const DEFAULT_TOUR_KEY = "design-tour-seen";
25
+ export declare function tourSeen(key?: string): boolean;
15
26
  /**
16
27
  * Forget it, so the next completed onboarding shows it again.
17
28
  *
@@ -25,5 +36,5 @@ export declare function tourSeen(): boolean;
25
36
  * this a fact about one browser, which is what it is. If you are being asked
26
37
  * these questions again, you have not seen the walk that comes after them.
27
38
  */
28
- export declare function forgetTour(): void;
29
- export declare function markTourSeen(): void;
39
+ export declare function forgetTour(key?: string): void;
40
+ export declare function markTourSeen(key?: string): void;
@@ -17,10 +17,14 @@
17
17
  * Re-running onboarding clears it -- see `forgetTour`. There is still no way
18
18
  * to replay the tour on its own without doing that, which is worth a row in
19
19
  * Settings and is said here rather than left implied. */
20
- const SEEN = 'tf-tour-seen';
21
- export function tourSeen() {
20
+ /** Where a product records that the tour was seen. Every product that installs
21
+ * the package gets its own key by passing one; the default is the package's,
22
+ * so two products on one origin do not share a memory. tf passes
23
+ * `tf-tour-seen`, the key its users already hold. */
24
+ export const DEFAULT_TOUR_KEY = 'design-tour-seen';
25
+ export function tourSeen(key = DEFAULT_TOUR_KEY) {
22
26
  try {
23
- return localStorage.getItem(SEEN) === '1';
27
+ return localStorage.getItem(key) === '1';
24
28
  }
25
29
  catch {
26
30
  // A browser refusing storage is not a reason to refuse the tour.
@@ -40,17 +44,17 @@ export function tourSeen() {
40
44
  * this a fact about one browser, which is what it is. If you are being asked
41
45
  * these questions again, you have not seen the walk that comes after them.
42
46
  */
43
- export function forgetTour() {
47
+ export function forgetTour(key = DEFAULT_TOUR_KEY) {
44
48
  try {
45
- localStorage.removeItem(SEEN);
49
+ localStorage.removeItem(key);
46
50
  }
47
51
  catch {
48
52
  /* nothing to do about it, and nothing that needs doing */
49
53
  }
50
54
  }
51
- export function markTourSeen() {
55
+ export function markTourSeen(key = DEFAULT_TOUR_KEY) {
52
56
  try {
53
- localStorage.setItem(SEEN, '1');
57
+ localStorage.setItem(key, '1');
54
58
  }
55
59
  catch {
56
60
  /* nothing to do about it, and nothing that needs doing */
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@
17
17
  * that shipped. They are worth more than the code around them.
18
18
  */
19
19
  export { default as Brand } from './components/Brand';
20
+ export { BRAND_NAMES } from './components/brandMarks';
21
+ export type { BrandName } from './components/brandMarks';
20
22
  export { default as Button } from './components/Button';
21
23
  export type { Props as ButtonProps } from './components/Button';
22
24
  export { default as Callout } from './components/Callout';
@@ -56,7 +58,7 @@ export type { TourStop } from './components/Tour';
56
58
  /** The tour's memory. `forgetTour` is what onboarding calls so a config reset
57
59
  * replays the tour rather than leaving it suppressed by a browser that has
58
60
  * seen it. */
59
- export { forgetTour, markTourSeen, tourSeen } from './components/tourMarker';
61
+ export { DEFAULT_TOUR_KEY, forgetTour, markTourSeen, tourSeen } from './components/tourMarker';
60
62
  /** The art. Its own module because it is ~200 KB of inlined SVG — referenced
61
63
  * only by `Illustration`, so a bundler that drops the component drops the art
62
64
  * with it. */
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * that shipped. They are worth more than the code around them.
18
18
  */
19
19
  export { default as Brand } from './components/Brand';
20
+ export { BRAND_NAMES } from './components/brandMarks';
20
21
  export { default as Button } from './components/Button';
21
22
  export { default as Callout } from './components/Callout';
22
23
  export { default as Card } from './components/Card';
@@ -51,7 +52,7 @@ export { default as Tour } from './components/Tour';
51
52
  /** The tour's memory. `forgetTour` is what onboarding calls so a config reset
52
53
  * replays the tour rather than leaving it suppressed by a browser that has
53
54
  * seen it. */
54
- export { forgetTour, markTourSeen, tourSeen } from './components/tourMarker';
55
+ export { DEFAULT_TOUR_KEY, forgetTour, markTourSeen, tourSeen } from './components/tourMarker';
55
56
  /** The art. Its own module because it is ~200 KB of inlined SVG — referenced
56
57
  * only by `Illustration`, so a bundler that drops the component drops the art
57
58
  * with it. */
@@ -329,10 +329,10 @@ button[aria-busy='true']::after {
329
329
  width: 40%;
330
330
  background: currentColor;
331
331
  opacity: 0.55;
332
- animation: tf-busy 1.1s var(--ease) infinite;
332
+ animation: ds-busy 1.1s var(--ease) infinite;
333
333
  }
334
334
 
335
- @keyframes tf-busy {
335
+ @keyframes ds-busy {
336
336
  0% { transform: translateX(-100%); }
337
337
  100% { transform: translateX(350%); }
338
338
  }
@@ -423,13 +423,12 @@ button.ghost:hover:not(:disabled) {
423
423
  height: calc(var(--text-md) * 1.5);
424
424
  width: auto;
425
425
  color: var(--good);
426
- /* The cog turns about its own centre, which is the ink box's. */
426
+ /* A product that animates its mark turns it about its own centre, which is
427
+ the ink box's; tf's cog does. */
427
428
  transform-origin: center;
428
429
  }
429
430
 
430
- @keyframes brand-turn { to { transform: rotate(360deg); } }
431
-
432
- @keyframes tf-callout-clock {
431
+ @keyframes ds-callout-clock {
433
432
  from { transform: scaleX(1); }
434
433
  to { transform: scaleX(0); }
435
434
  }
@@ -515,16 +514,16 @@ button.ghost:hover:not(:disabled) {
515
514
 
516
515
  .icon-btn:hover { opacity: 1; color: var(--text); }
517
516
 
518
- @keyframes tf-bar {
517
+ @keyframes ds-bar {
519
518
  0% { transform: translateX(-110%); }
520
519
  100% { transform: translateX(320%); }
521
520
  }
522
521
 
523
- @keyframes tf-toast-in {
522
+ @keyframes ds-toast-in {
524
523
  from { opacity: 0; transform: translateY(8px); }
525
524
  }
526
525
 
527
- @keyframes tf-toast-out {
526
+ @keyframes ds-toast-out {
528
527
  to { opacity: 0; transform: translateY(4px); }
529
528
  }
530
529
 
@@ -545,7 +544,7 @@ button.ghost:hover:not(:disabled) {
545
544
  .icon-btn:hover:not(:disabled) { color: var(--text); }
546
545
 
547
546
  /* skeleton pulse ------------------------------------------------------ */
548
- @keyframes tf-pulse {
547
+ @keyframes ds-pulse {
549
548
  0%, 100% { opacity: 0.25; }
550
549
  50% { opacity: 0.65; }
551
550
  }
@@ -684,7 +683,7 @@ input:active, textarea:active, select:active { transform: none; }
684
683
  line-height: 1.5;
685
684
  }
686
685
 
687
- @keyframes tf-throb {
686
+ @keyframes ds-throb {
688
687
  0%, 100% { opacity: 0.55; }
689
688
  50% { opacity: 1; }
690
689
  }
@@ -720,7 +719,7 @@ input:active, textarea:active, select:active { transform: none; }
720
719
  /* A round is in flight. On the button that asks for one, because that is where
721
720
  you are looking when you have just pressed it -- and not in the foot, which
722
721
  would flicker every thirty seconds on a tile that is working perfectly. */
723
- @keyframes tf-spin { to { transform: rotate(360deg); } }
722
+ @keyframes ds-spin { to { transform: rotate(360deg); } }
724
723
 
725
724
  @keyframes dock-working {
726
725
  0%, 100% { opacity: .15; transform: scale(1); }
@@ -737,10 +736,10 @@ input:active, textarea:active, select:active { transform: none; }
737
736
  The animation is not made more robust; the shape is made indifferent to it. */
738
737
  .spin {
739
738
  display: inline-block;
740
- animation: tf-spin 900ms linear infinite;
739
+ animation: ds-spin 900ms linear infinite;
741
740
  }
742
741
 
743
- @keyframes tf-spin { to { transform: rotate(360deg); } }
742
+ @keyframes ds-spin { to { transform: rotate(360deg); } }
744
743
 
745
744
  @media (prefers-reduced-motion: reduce) {
746
745
  /* Still says "working" -- it just says it without moving. */
@@ -781,7 +780,7 @@ input:active, textarea:active, select:active { transform: none; }
781
780
  border: 0;
782
781
  }
783
782
 
784
- @keyframes tf-skel {
783
+ @keyframes ds-skel {
785
784
  from { opacity: 0.55; }
786
785
  to { opacity: 1; }
787
786
  }
@@ -1139,7 +1138,7 @@ button.danger:active:not(:disabled) {
1139
1138
  transform-origin: left;
1140
1139
  background: currentColor;
1141
1140
  opacity: 0.5;
1142
- animation: tf-callout-clock linear forwards;
1141
+ animation: ds-callout-clock linear forwards;
1143
1142
  }
1144
1143
 
1145
1144
  /* Held while the pointer is on it. A message that expires while you are reading
@@ -1561,10 +1560,10 @@ input[type='radio']:active:not(:disabled) {
1561
1560
  width: 220%; height: 220%;
1562
1561
  translate: -50% -50%;
1563
1562
  background: conic-gradient(from 0deg, transparent 0deg, transparent 60deg, color-mix(in oklab, var(--accent) 70%, transparent) 180deg, transparent 300deg, transparent 360deg);
1564
- animation: tf-spin 1s linear infinite;
1563
+ animation: ds-spin 1s linear infinite;
1565
1564
  }
1566
1565
 
1567
- @keyframes tf-spin { to { rotate: 1turn; } }
1566
+ @keyframes ds-spin { to { rotate: 1turn; } }
1568
1567
 
1569
1568
  @media (prefers-reduced-motion: reduce) {
1570
1569
  .toggle-sweep::before { animation: none; background: color-mix(in oklab, var(--accent) 45%, transparent); }
@@ -1578,7 +1577,7 @@ input[type='radio']:active:not(:disabled) {
1578
1577
  height: 8px;
1579
1578
  border-radius: var(--radius-sm);
1580
1579
  background: var(--muted);
1581
- animation: tf-pulse 1.4s ease-in-out infinite;
1580
+ animation: ds-pulse 1.4s ease-in-out infinite;
1582
1581
  vertical-align: middle;
1583
1582
  }
1584
1583
 
@@ -1619,7 +1618,7 @@ input[type='radio']:active:not(:disabled) {
1619
1618
 
1620
1619
  /* Breathing rather than sliding. A sweep travelling across a skeleton implies
1621
1620
  progress from left to right, and nothing here knows how far along it is. */
1622
- .skel-pulse { animation: tf-skel calc(var(--dur-slow) * 3) var(--ease) infinite alternate; }
1621
+ .skel-pulse { animation: ds-skel calc(var(--dur-slow) * 3) var(--ease) infinite alternate; }
1623
1622
 
1624
1623
  @media (prefers-reduced-motion: reduce) {
1625
1624
  /* Still distinguishable from a real block of colour, and still still. */
@@ -1761,7 +1760,7 @@ input[type='radio']:active:not(:disabled) {
1761
1760
  something happening now. */
1762
1761
  .rows-pick > .rows-row.is-loading {
1763
1762
  border-color: var(--accent);
1764
- animation: tf-pulse 1.4s ease-in-out infinite;
1763
+ animation: ds-pulse 1.4s ease-in-out infinite;
1765
1764
  }
1766
1765
 
1767
1766
  /* And nothing else can be chosen while it happens: picking a second model
@@ -1973,7 +1972,7 @@ input[type='radio']:active:not(:disabled) {
1973
1972
  happening" without claiming to know how much of it is left. */
1974
1973
  .bar-indeterminate > i {
1975
1974
  width: 35%;
1976
- animation: tf-bar 1.2s var(--ease) infinite;
1975
+ animation: ds-bar 1.2s var(--ease) infinite;
1977
1976
  }
1978
1977
 
1979
1978
  @media (prefers-reduced-motion: reduce) {
@@ -2103,7 +2102,7 @@ input[type='radio']:active:not(:disabled) {
2103
2102
  background: var(--panel);
2104
2103
  box-shadow: var(--shadow-2);
2105
2104
  font-size: var(--text-sm);
2106
- animation: tf-toast-in var(--dur-md) var(--ease);
2105
+ animation: ds-toast-in var(--dur-md) var(--ease);
2107
2106
  }
2108
2107
 
2109
2108
  /* The tone is the edge and the mark, in the tone's own colour. It used to be the
@@ -2133,7 +2132,7 @@ input[type='radio']:active:not(:disabled) {
2133
2132
  /* `data-exiting` is React Aria's: it keeps a toast in the DOM for the length
2134
2133
  of this animation after it is closed, then removes it, which is what the
2135
2134
  `.leaving` class and a second timer used to do by hand. */
2136
- .toast[data-exiting] { animation: tf-toast-out var(--dur-fast) var(--ease) forwards; }
2135
+ .toast[data-exiting] { animation: ds-toast-out var(--dur-fast) var(--ease) forwards; }
2137
2136
 
2138
2137
  @media (prefers-reduced-motion: reduce) {
2139
2138
  .toast, /* `data-exiting` is React Aria's: it keeps a toast in the DOM for the length
@@ -2779,39 +2778,6 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
2779
2778
 
2780
2779
  .table thead th[style*='right'] { text-align: right; }
2781
2780
 
2782
- /* ---- ./brand.css ---- */
2783
- /* Once the morph has finished (`Brand.tsx` adds the class at t = 1), the cog
2784
- turns -- a revolution in 5 s, a tooth every 0.8 s: the pace of a
2785
- mechanism, not a spinner. It was 2.4 s, which was too quick to William's
2786
- eye once the teeth were six and fat. Off under reduced motion, where the
2787
- mark never becomes a cog in the first place.
2788
-
2789
- Not applied since 2026-09-03 -- William asked to try it without, and
2790
- `Brand.tsx` says so where the class would go. The rule stays so that putting
2791
- the class back is the whole change; it stays HERE, rather than in the app
2792
- that first held it, because the component that would add the class is this
2793
- package's. The keyframes are in `base.css` beside `.brand`. */
2794
- .brand-turning { animation: brand-turn 5s linear infinite; }
2795
-
2796
- @media (prefers-reduced-motion: reduce) {
2797
- .brand-turning { animation: none; }
2798
- }
2799
-
2800
- .tf-dots { display: inline-flex; gap: var(--space-1); }
2801
-
2802
- .tf-dots i {
2803
- width: 5px; height: 5px; border-radius: 50%; background: currentColor;
2804
- animation: tf-bounce 1.1s ease-in-out infinite;
2805
- }
2806
-
2807
- .tf-dots i:nth-child(2) { animation-delay: 0.15s; }
2808
-
2809
- .tf-dots i:nth-child(3) { animation-delay: 0.3s; }
2810
-
2811
- @media (prefers-reduced-motion: reduce) {
2812
- .tf-dots i { animation: none; opacity: 0.6; }
2813
- }
2814
-
2815
2781
  /* ---- ./sizegrid.css ---- */
2816
2782
  /* Tile size, picked as a rectangle rather than read as a notation. */
2817
2783
  .size-grid-wrap { display: flex; align-items: center; gap: var(--space-2); }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wtfalch/design",
3
- "version": "0.1.0",
4
- "description": "The tf design system: themeable components on a fixed token vocabulary.",
3
+ "version": "0.2.0",
4
+ "description": "wtfalch's design system: themeable components on a fixed token vocabulary.",
5
5
  "keywords": [
6
6
  "design-system",
7
7
  "react",