@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 +5 -4
- package/dist/components/Brand.d.ts +24 -6
- package/dist/components/Brand.js +14 -201
- package/dist/components/Tour.d.ts +3 -1
- package/dist/components/Tour.js +3 -3
- package/dist/components/brandMarks.d.ts +32 -0
- package/dist/components/brandMarks.js +31 -0
- package/dist/components/tourMarker.d.ts +14 -3
- package/dist/components/tourMarker.js +11 -7
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/styles/index.css +23 -57
- package/package.json +2 -2
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
|
|
@@ -125,10 +125,11 @@ theme on a light-mode laptop is not silently repainted.
|
|
|
125
125
|
|
|
126
126
|
## Status
|
|
127
127
|
|
|
128
|
-
`0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
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;
|
package/dist/components/Brand.js
CHANGED
|
@@ -1,212 +1,25 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { BRAND_MARKS } 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
|
-
* `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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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;
|
package/dist/components/Tour.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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. */
|
package/dist/styles/index.css
CHANGED
|
@@ -329,10 +329,10 @@ button[aria-busy='true']::after {
|
|
|
329
329
|
width: 40%;
|
|
330
330
|
background: currentColor;
|
|
331
331
|
opacity: 0.55;
|
|
332
|
-
animation:
|
|
332
|
+
animation: ds-busy 1.1s var(--ease) infinite;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
-
@keyframes
|
|
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
|
-
/*
|
|
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
|
|
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
|
|
517
|
+
@keyframes ds-bar {
|
|
519
518
|
0% { transform: translateX(-110%); }
|
|
520
519
|
100% { transform: translateX(320%); }
|
|
521
520
|
}
|
|
522
521
|
|
|
523
|
-
@keyframes
|
|
522
|
+
@keyframes ds-toast-in {
|
|
524
523
|
from { opacity: 0; transform: translateY(8px); }
|
|
525
524
|
}
|
|
526
525
|
|
|
527
|
-
@keyframes
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
739
|
+
animation: ds-spin 900ms linear infinite;
|
|
741
740
|
}
|
|
742
741
|
|
|
743
|
-
@keyframes
|
|
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
|
|
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:
|
|
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:
|
|
1563
|
+
animation: ds-spin 1s linear infinite;
|
|
1565
1564
|
}
|
|
1566
1565
|
|
|
1567
|
-
@keyframes
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|