@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 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
@@ -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
- ## Writing a theme
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 { applyTheme, defineTheme } from '@wtfalch/design'
34
-
35
- export const brand = defineTheme({
36
- name: 'Brand',
37
- note: 'Warm, roomy, and slower than the default',
38
- scheme: 'light',
39
- tokens: {
40
- '--bg': '#faf7f2',
41
- '--panel': '#ffffff',
42
- '--accent': '#7c3aed',
43
- '--on-accent': '#ffffff',
44
- '--density': '1.15', // every --space-* step follows
45
- '--font-size': '15px', // every --text-* step follows
46
- '--dur-md': '320ms',
47
- },
48
- })
49
-
50
- applyTheme(brand) // the object, straight from defineTheme
51
- applyTheme(brand, myEl) // or on a subtree
52
- applyTheme('paper') // or a built-in, by name
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
- Name the tokens you change; the rest inherit from `tokens.css`. A theme naming
56
- three tokens is valid.
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. This is the main
61
- reason the type is exported at all.
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
- React mounts after the stylesheet, so a theme applied in an effect flashes the
105
- default. Cache the name and apply it from a blocking script before the bundle
106
- loads:
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
- ## Built-ins
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
- `system`, `night` and `paper` ship as **examples, not as the menu** an app
122
- that installs this is expected to bring its own. `system` is a theme rather than
123
- a mode: it is the only one scoped to `prefers-color-scheme`, so choosing a dark
124
- theme on a light-mode laptop is not silently repainted.
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.1.0`. Twenty-eight components, every one of the 70 gallery specimens
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
- The first consumer is [tf](https://github.com/wtfalch/tf), the app it came from.
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
- 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
+ * 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
- /** 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;
38
+ className?: string;
10
39
  }): import("react").JSX.Element;
@@ -1,212 +1,37 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from 'react';
2
+ import { BRAND_MARKS, BRAND_NAMES } 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.
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
- 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" }))] }));
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;