@wtfalch/design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/components/Brand.d.ts +10 -0
  4. package/dist/components/Brand.js +212 -0
  5. package/dist/components/Button.d.ts +63 -0
  6. package/dist/components/Button.js +74 -0
  7. package/dist/components/Callout.d.ts +37 -0
  8. package/dist/components/Callout.js +71 -0
  9. package/dist/components/Card.d.ts +42 -0
  10. package/dist/components/Card.js +30 -0
  11. package/dist/components/Checkbox.d.ts +32 -0
  12. package/dist/components/Checkbox.js +31 -0
  13. package/dist/components/DangerZone.d.ts +59 -0
  14. package/dist/components/DangerZone.js +50 -0
  15. package/dist/components/Dialog.d.ts +28 -0
  16. package/dist/components/Dialog.js +29 -0
  17. package/dist/components/Empty.d.ts +45 -0
  18. package/dist/components/Empty.js +35 -0
  19. package/dist/components/Field.d.ts +58 -0
  20. package/dist/components/Field.js +46 -0
  21. package/dist/components/Icon.d.ts +64 -0
  22. package/dist/components/Icon.js +235 -0
  23. package/dist/components/Illustration.d.ts +36 -0
  24. package/dist/components/Illustration.js +48 -0
  25. package/dist/components/Input.d.ts +14 -0
  26. package/dist/components/Input.js +65 -0
  27. package/dist/components/Markdown.d.ts +21 -0
  28. package/dist/components/Markdown.js +29 -0
  29. package/dist/components/Modal.d.ts +59 -0
  30. package/dist/components/Modal.js +72 -0
  31. package/dist/components/Pill.d.ts +40 -0
  32. package/dist/components/Pill.js +41 -0
  33. package/dist/components/Progress.d.ts +35 -0
  34. package/dist/components/Progress.js +27 -0
  35. package/dist/components/Rows.d.ts +101 -0
  36. package/dist/components/Rows.js +55 -0
  37. package/dist/components/Select.d.ts +28 -0
  38. package/dist/components/Select.js +56 -0
  39. package/dist/components/SizeGrid.d.ts +34 -0
  40. package/dist/components/SizeGrid.js +41 -0
  41. package/dist/components/Skeleton.d.ts +45 -0
  42. package/dist/components/Skeleton.js +47 -0
  43. package/dist/components/Slider.d.ts +70 -0
  44. package/dist/components/Slider.js +100 -0
  45. package/dist/components/Table.d.ts +43 -0
  46. package/dist/components/Table.js +13 -0
  47. package/dist/components/Tabs.d.ts +72 -0
  48. package/dist/components/Tabs.js +82 -0
  49. package/dist/components/Textarea.d.ts +9 -0
  50. package/dist/components/Textarea.js +22 -0
  51. package/dist/components/Toast.d.ts +43 -0
  52. package/dist/components/Toast.js +78 -0
  53. package/dist/components/Toggle.d.ts +56 -0
  54. package/dist/components/Toggle.js +189 -0
  55. package/dist/components/Tooltip.d.ts +22 -0
  56. package/dist/components/Tooltip.js +62 -0
  57. package/dist/components/Tour.d.ts +33 -0
  58. package/dist/components/Tour.js +108 -0
  59. package/dist/components/iconNames.d.ts +18 -0
  60. package/dist/components/iconNames.js +60 -0
  61. package/dist/components/tourMarker.d.ts +29 -0
  62. package/dist/components/tourMarker.js +58 -0
  63. package/dist/contrast.d.ts +18 -0
  64. package/dist/contrast.js +27 -0
  65. package/dist/hooks/useTrapFocus.d.ts +24 -0
  66. package/dist/hooks/useTrapFocus.js +67 -0
  67. package/dist/illustrations.d.ts +11 -0
  68. package/dist/illustrations.js +55 -0
  69. package/dist/index.d.ts +72 -0
  70. package/dist/index.js +65 -0
  71. package/dist/styles/index.css +3124 -0
  72. package/dist/themes.d.ts +210 -0
  73. package/dist/themes.js +300 -0
  74. package/dist/tokens.css +251 -0
  75. package/package.json +74 -0
@@ -0,0 +1,189 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A setting that takes effect as it moves.
4
+ *
5
+ * **A checkbox and a switch are not the same control, and the difference is not
6
+ * visual.** A checkbox collects an answer: you tick it, and it applies when you
7
+ * press Save. A switch *is* the action — flipping it changes the thing, now,
8
+ * and there is nothing to confirm afterwards. That is why a switch reads as on
9
+ * or off rather than ticked or blank: the two states are both settled, and
10
+ * neither is a draft.
11
+ *
12
+ * Six settings in this app saved the moment they changed and were all drawn as
13
+ * tick boxes: whether an applet is enabled, whether it reaches the internet,
14
+ * whether a tool is granted, whether a server is trusted. Every one of them
15
+ * promised a Save button that does not
16
+ * exist. The header's status panel (gone since 2026-09-03) was the only one
17
+ * that had it right, in a comment nobody else read.
18
+ *
19
+ * **`role="switch"`, so it is announced as one.** A screen reader says "on" and
20
+ * "off" instead of "checked" and "not checked" — which is the same distinction
21
+ * in words, and the only signal a non-visual reader gets that pressing it does
22
+ * something immediately.
23
+ *
24
+ * **The label is the hit area.** A 30×18 target fails the minimum on its own,
25
+ * and reaching for the words is what people do anyway.
26
+ *
27
+ * **One switch, or several checkboxes.** A lone binary setting is a switch. A
28
+ * set you pick from is a list of checkboxes — fifteen switches in a column read
29
+ * as fifteen unrelated settings rather than one choice with fifteen parts. See
30
+ * `Checkbox`.
31
+ */
32
+ import { useEffect, useRef, useState } from 'react';
33
+ import { Switch } from 'react-aria-components';
34
+ /** The knob's diameter per size, as `toggle.css` draws it. The drag needs it
35
+ * to turn pixels into a position: the knob travels the track's inner width
36
+ * less itself and its 2px of margin at each end. */
37
+ const KNOB = { sm: 10, md: 12, lg: 16 };
38
+ /** Under this many pixels of horizontal travel a press is a tap. It was 3,
39
+ * and a plain click carries that much hand jitter -- so a click on the knob
40
+ * became a drag, the knob twitched toward the pointer and settled by which
41
+ * side it was on, and the switch felt as if it wanted to be dragged rather
42
+ * than pressed. Six is over the jitter and still a third of the way across
43
+ * the shortest track. */
44
+ const SLOP = 6;
45
+ export default function Toggle({ label, hint, checked, onChange, disabled, said, labelHidden, size = 'md', className, }) {
46
+ /* React Aria's `Switch` is the `<label>`: it owns a visually-hidden
47
+ `<input type="checkbox" role="switch">` and stamps `data-selected`,
48
+ `data-disabled`, `data-focus-visible` and `data-pressed` on the label. So
49
+ the track is no longer the input drawn with `appearance: none` -- it is a
50
+ plain span, and `toggle.css` reads the state off the row instead of off
51
+ `:checked`. The row is the label either way, which keeps the words as the
52
+ hit area. */
53
+ /* Dragging the knob.
54
+
55
+ React Aria's `Switch` is a press: down and up on the label toggles, and a
56
+ pointer that wanders in between is still a press. A switch drawn as a
57
+ track and a knob invites the other gesture -- drag the knob across -- and
58
+ nothing answered it. So the track handles its own pointer: on down it
59
+ stops the event before the label's press begins and captures the pointer.
60
+ Under `SLOP` pixels of movement it is a tap and toggles on release; past
61
+ it, the knob follows the pointer through `--knob-x` and the side it is on
62
+ at release is the answer. Either way the click the browser fires afterwards
63
+ is swallowed, so the label does not toggle it a second time -- and it has
64
+ to be handled here rather than left to the label, because once the press
65
+ is stopped at the track a tap on the knob no longer reaches the input by
66
+ itself (measured, in `keyboard.spec.ts`).
67
+
68
+ `onChange` is called once per gesture, or not at all if the knob was put
69
+ back where it started -- a consumer that saves on change must not see a
70
+ drag as two saves. The keyboard is untouched: the input is still the
71
+ switch. */
72
+ /* The optimistic half, copied from chef-monorepo's `Toggle` on 2026-09-05.
73
+
74
+ A switch applies as it moves, and what it applies is usually a request.
75
+ Waiting for the reply before moving the knob makes every switch feel
76
+ broken for the length of a round trip; moving it and forgetting makes a
77
+ failed request look like a success. So: if `onChange` returns a promise,
78
+ the knob moves now and the row is busy -- `aria-busy` on the input, chef's
79
+ sweep around the rim of the track (`.toggle-sweep`), no second press --
80
+ until it settles. A rejection
81
+ puts the knob back. A resolution HOLDS the optimistic value until `checked`
82
+ changes, because a resolved save does not mean the caller's state has
83
+ caught up (a refetch is a second round trip), and clearing on resolve
84
+ would snap the knob back and forward again. An optimistic value can outlive
85
+ a successful save and never a failed one. `onChange` returning nothing is
86
+ the old contract, untouched. */
87
+ const [optimistic, setOptimistic] = useState(null);
88
+ const [pending, setPending] = useState(false);
89
+ const [seen, setSeen] = useState(checked);
90
+ if (checked !== seen) {
91
+ setSeen(checked);
92
+ if (optimistic !== null)
93
+ setOptimistic(null);
94
+ }
95
+ const shown = optimistic ?? checked;
96
+ const commit = (on) => {
97
+ const result = onChange(on);
98
+ if (!(result instanceof Promise))
99
+ return;
100
+ setOptimistic(on);
101
+ setPending(true);
102
+ result.then(() => setPending(false), () => {
103
+ setPending(false);
104
+ setOptimistic(null);
105
+ });
106
+ };
107
+ /* `aria-busy` on the input and `data-pending` on the row, set on the elements
108
+ because React Aria's `filterDOMProps` drops both in silence -- the trap
109
+ `Button` documents for `aria-busy`. */
110
+ const rowRef = useRef(null);
111
+ useEffect(() => {
112
+ const row = rowRef.current;
113
+ const input = row?.querySelector('input');
114
+ if (!row || !input)
115
+ return;
116
+ if (pending) {
117
+ row.setAttribute('data-pending', 'true');
118
+ input.setAttribute('aria-busy', 'true');
119
+ }
120
+ else {
121
+ row.removeAttribute('data-pending');
122
+ input.removeAttribute('aria-busy');
123
+ }
124
+ }, [pending]);
125
+ const [knob, setKnob] = useState(null);
126
+ const [held, setHeld] = useState(false);
127
+ const gesture = useRef(null);
128
+ const swallowClick = useRef(false);
129
+ const position = (track, g, x) => {
130
+ const travel = track.clientWidth - KNOB[size] - 4;
131
+ return Math.min(1, Math.max(0, g.from + (x - g.startX) / travel));
132
+ };
133
+ return (_jsxs(Switch, { ref: rowRef, className: `switch-row switch-${size}${className ? ` ${className}` : ''}`, isSelected: shown, onChange: commit, isDisabled: disabled,
134
+ /* Read-only, not disabled, while a request is out: focus stays where it
135
+ is and the row does not dim, it just refuses a second answer until
136
+ the first has been taken. */
137
+ isReadOnly: pending, children: [_jsxs("span", { className: labelHidden ? 'sr-only' : 'switch-body', children: [_jsx("span", { className: "switch-label", children: label }), hint && _jsx("span", { className: "switch-hint", children: hint })] }), said && _jsx("span", { className: "switch-said mono", children: said }), _jsx("span", { className: "toggle", "aria-hidden": "true", "data-held": held || undefined, "data-dragging": knob === null ? undefined : true, style: knob === null ? undefined : { '--knob-x': knob }, onPointerDown: (e) => {
138
+ if (disabled || pending || e.button !== 0)
139
+ return;
140
+ e.stopPropagation();
141
+ e.currentTarget.setPointerCapture(e.pointerId);
142
+ gesture.current = {
143
+ id: e.pointerId,
144
+ startX: e.clientX,
145
+ startY: e.clientY,
146
+ from: shown ? 1 : 0,
147
+ moved: false,
148
+ };
149
+ setHeld(true);
150
+ }, onPointerMove: (e) => {
151
+ const g = gesture.current;
152
+ if (!g || e.pointerId !== g.id)
153
+ return;
154
+ if (!g.moved) {
155
+ const dx = e.clientX - g.startX;
156
+ const dy = e.clientY - g.startY;
157
+ /* Mostly sideways and past the slop, or it is still a press. A
158
+ drag begins where the threshold was crossed, not where the
159
+ pointer first landed, so the knob starts from rest instead of
160
+ jumping the slop's width the moment it engages. */
161
+ if (Math.abs(dx) < SLOP || Math.abs(dx) <= Math.abs(dy))
162
+ return;
163
+ g.moved = true;
164
+ g.startX = e.clientX;
165
+ }
166
+ setKnob(position(e.currentTarget, g, e.clientX));
167
+ }, onPointerUp: (e) => {
168
+ const g = gesture.current;
169
+ if (!g || e.pointerId !== g.id)
170
+ return;
171
+ gesture.current = null;
172
+ setHeld(false);
173
+ setKnob(null);
174
+ swallowClick.current = true;
175
+ const on = g.moved ? position(e.currentTarget, g, e.clientX) > 0.5 : !shown;
176
+ if (on !== shown)
177
+ commit(on);
178
+ }, onPointerCancel: () => {
179
+ gesture.current = null;
180
+ setHeld(false);
181
+ setKnob(null);
182
+ }, onClick: (e) => {
183
+ if (!swallowClick.current)
184
+ return;
185
+ swallowClick.current = false;
186
+ e.preventDefault();
187
+ e.stopPropagation();
188
+ }, children: pending && _jsx("span", { className: "toggle-sweep" }) })] }));
189
+ }
@@ -0,0 +1,22 @@
1
+ export default function Tooltip({ label, align, mark, className, children, }: {
2
+ label: string;
3
+ /** Which side the box extends towards. `right` for a mark on the left. */
4
+ align?: 'left' | 'right';
5
+ /**
6
+ * What you hover, when the `?` is not it.
7
+ *
8
+ * A capability mark on a model row is not a request for help about something
9
+ * else -- it *is* the thing, drawn small. So it becomes its own trigger, and
10
+ * the accessible name is the capability rather than "About …", which would
11
+ * announce a glyph as a footnote to itself.
12
+ *
13
+ * This exists because the native `title` attribute was not enough. The mark
14
+ * is an `<svg>` inside a `<span title>` inside a row that is a `<button>`;
15
+ * the pointer lands on a `<path>`, two tooltip sources compete, and what came
16
+ * up was nothing anybody could rely on. A tooltip the app draws itself shows
17
+ * on hover *and* on keyboard focus, which the native one never did.
18
+ */
19
+ mark?: React.ReactNode;
20
+ className?: string;
21
+ children: React.ReactNode;
22
+ }): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A tooltip. It was called `Explain`, which named the intention rather than the
4
+ * thing -- so it sat in a catalogue beside Button and Callout as though it were
5
+ * a category of its own, and nobody looking for a tooltip would have found it.
6
+ *
7
+ * A `?` beside a setting, and the box it shows on hover.
8
+ *
9
+ * Same idea as the status dot's tooltip and built the same way: a positioned
10
+ * element revealed on hover, rather than a `title` attribute. The native one
11
+ * needs about a second and a half of waiting, renders newlines inconsistently
12
+ * and cannot be styled.
13
+ *
14
+ * **It stays open while the pointer is inside it**, because a box that vanishes
15
+ * the moment you move toward it cannot be read. `focus-within` opens it too, so
16
+ * it is reachable from the keyboard.
17
+ *
18
+ * The mark is a `<span>` rather than a `<button>`: the rows these sit in are
19
+ * usually a `<label>` wrapping a checkbox, and anything focusable or clickable
20
+ * in there ends up toggling the setting behind it.
21
+ *
22
+ * `align` is which way the box grows, and it has to be said rather than
23
+ * guessed: a mark at the right edge of a row must grow left, and one beside a
24
+ * section heading must grow right. Getting it wrong does not just look off --
25
+ * the box runs under the settings rail and its first few words are cut away.
26
+ */
27
+ import { Tooltip as AriaTooltip, Focusable, TooltipTrigger } from 'react-aria-components';
28
+ export default function Tooltip({ label, align = 'right', mark, className, children, }) {
29
+ /* What was wrong, and what this fixes.
30
+
31
+ The old markup was `<span role="note" tabIndex={0} aria-label="About …">`
32
+ with the tip as a child span revealed by `:hover` and `:focus-within`. It
33
+ looked like a tooltip and was not one to anything that reads a page aloud:
34
+ `note` is not an interactive role, so a focusable note is a contradiction
35
+ the screen reader resolves by announcing the label and nothing else -- the
36
+ tip's text, the one thing worth reading, was never associated with the
37
+ trigger at all.
38
+
39
+ React Aria's `TooltipTrigger` wires `aria-describedby` from the trigger to
40
+ a `<div role="tooltip">`, opens it on hover with intent and on keyboard
41
+ focus, and closes it on Escape and on pointer-out with a grace period so
42
+ a box that vanishes as you move towards it cannot happen.
43
+
44
+ The mark stays a `<span>`, and `Focusable` is what makes that work. The
45
+ rows these sit in are `<label>`s wrapping a control, so anything that is a
46
+ `<button>` in there toggles the setting behind it -- the docblock's reason,
47
+ and still true. `Focusable` gives the span a tab stop and the focus and
48
+ hover handling a trigger needs without making it a button.
49
+
50
+ The tip is portalled to `document.body` and positioned by React Aria, which
51
+ is also why `overflow: hidden` on any ancestor no longer clips it -- the
52
+ Permissions pane's horizontal scrollbar, found by hiding one class at a
53
+ time, was this box sitting in the scrollable overflow. */
54
+ return (_jsxs(TooltipTrigger, { delay: 0, closeDelay: 150, children: [_jsx(Focusable, { children: _jsx("span", { className: `explain explain-${align}${className ? ` ${className}` : ''}`,
55
+ /* A role, because a name on a role-less span is prohibited -- axe's
56
+ `aria-prohibited-attr`, found the first time this was scanned. `img`
57
+ rather than `button`: the mark is a glyph that reveals help, not a
58
+ control that does something, and `button` would promise an action
59
+ and -- inside the `<label>` rows these sit in -- invite a click that
60
+ the label forwards to the setting behind it. */
61
+ role: "img", "aria-label": mark ? label : `About ${label}`, children: mark ?? (_jsx("span", { className: "explain-mark", "aria-hidden": "true", children: "?" })) }) }), _jsx(AriaTooltip, { className: "explain-tip", placement: align === 'left' ? 'bottom end' : 'bottom start', offset: 8, crossOffset: align === 'left' ? 6 : -6, children: children })] }));
62
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A short walk round the chrome, once, after onboarding.
3
+ *
4
+ * Onboarding answers "what does this need to run". It says nothing about the
5
+ * four controls somebody is then left alone with: two floating buttons in one
6
+ * corner, a cog in another, and a `+` beside the board name. Every one of them
7
+ * is an icon with a `title`, which is a tooltip you have to already suspect is
8
+ * there to go looking for.
9
+ *
10
+ * **It points at the real thing.** Each step finds its target by selector and
11
+ * cuts a hole in the scrim over it, so the control being described is the
12
+ * control you can see — not a screenshot of one, which goes stale the first
13
+ * time the button moves.
14
+ *
15
+ * **A step whose target is missing is skipped, not shown empty.** The chat
16
+ * launcher is not on the page while the dock is open, and the studio button
17
+ * changes what it does when a session is minimised. A tour that insists on
18
+ * pointing at something that is not there would be describing a different app.
19
+ *
20
+ * **Escape ends it and it never comes back on its own.** This is the least
21
+ * important thing on the screen and it is in the way of everything else; the
22
+ * one unforgivable version is the one you cannot get out of.
23
+ */
24
+ export interface TourStop {
25
+ /** Where it points. Missing from the page means the stop is skipped. */
26
+ target: string;
27
+ title: string;
28
+ body: React.ReactNode;
29
+ }
30
+ export default function Tour({ stops, onDone }: {
31
+ stops: TourStop[];
32
+ onDone: () => void;
33
+ }): import("react").ReactPortal | null;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A short walk round the chrome, once, after onboarding.
4
+ *
5
+ * Onboarding answers "what does this need to run". It says nothing about the
6
+ * four controls somebody is then left alone with: two floating buttons in one
7
+ * corner, a cog in another, and a `+` beside the board name. Every one of them
8
+ * is an icon with a `title`, which is a tooltip you have to already suspect is
9
+ * there to go looking for.
10
+ *
11
+ * **It points at the real thing.** Each step finds its target by selector and
12
+ * cuts a hole in the scrim over it, so the control being described is the
13
+ * control you can see — not a screenshot of one, which goes stale the first
14
+ * time the button moves.
15
+ *
16
+ * **A step whose target is missing is skipped, not shown empty.** The chat
17
+ * launcher is not on the page while the dock is open, and the studio button
18
+ * changes what it does when a session is minimised. A tour that insists on
19
+ * pointing at something that is not there would be describing a different app.
20
+ *
21
+ * **Escape ends it and it never comes back on its own.** This is the least
22
+ * important thing on the screen and it is in the way of everything else; the
23
+ * one unforgivable version is the one you cannot get out of.
24
+ */
25
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
26
+ import { createPortal } from 'react-dom';
27
+ import Button from './Button';
28
+ import { markTourSeen } from './tourMarker';
29
+ export default function Tour({ stops, onDone }) {
30
+ const [at, setAt] = useState(0);
31
+ const [box, setBox] = useState(null);
32
+ const card = useRef(null);
33
+ /* Only the stops whose target is actually on the page.
34
+
35
+ Keyed on `stops` rather than computed every render, because re-filtering
36
+ as the chrome moves would renumber "2 of 4" under somebody mid-walk -- the
37
+ chat launcher leaves the page when the dock opens, and the count must not
38
+ notice. `TOUR` is a module constant, so in a built app this runs once.
39
+
40
+ It was a `useRef` for that reason, and a ref is a snapshot: editing a stop's
41
+ copy hot-updated the module and the open tour went on rendering the text it
42
+ was mounted with, which made the copy look like it had not saved. A `useMemo`
43
+ keeps the guarantee and lets a new `stops` array through, which is exactly
44
+ what Fast Refresh hands us. */
45
+ const live = useMemo(() => stops.filter((s) => document.querySelector(s.target)), [stops]);
46
+ /* A shorter list must not strand the index past the end -- that would read as
47
+ "no stop", which ends the tour. Only reachable in development. */
48
+ const stop = live[Math.min(at, Math.max(0, live.length - 1))];
49
+ const finish = useCallback(() => {
50
+ markTourSeen();
51
+ onDone();
52
+ }, [onDone]);
53
+ useLayoutEffect(() => {
54
+ if (!stop)
55
+ return finish();
56
+ const el = document.querySelector(stop.target);
57
+ if (!el)
58
+ return;
59
+ /* Half this chrome is invisible until you touch it. `.view-add` sits at
60
+ `opacity: 0` until the top bar is hovered, so the spotlight cut a hole
61
+ round nothing and the card described a button that was not there -- the
62
+ one stop most in need of explaining was the one you could not see.
63
+
64
+ A class rather than an inline style: what "visible" means belongs to the
65
+ control, in the stylesheet with the rule that hid it. See `.tour-target`. */
66
+ el.classList.add('tour-target');
67
+ const measure = () => setBox(el.getBoundingClientRect());
68
+ measure();
69
+ // The chrome moves: the board scrolls, the window resizes, a FAB slides in.
70
+ window.addEventListener('resize', measure);
71
+ window.addEventListener('scroll', measure, true);
72
+ return () => {
73
+ // Before the next stop adds its own, and on the way out however we leave
74
+ // -- Escape and Skip included, or the button stays stuck on.
75
+ el.classList.remove('tour-target');
76
+ window.removeEventListener('resize', measure);
77
+ window.removeEventListener('scroll', measure, true);
78
+ };
79
+ }, [stop, finish]);
80
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `at` is the trigger, not a value the body reads -- the card takes focus again on every step.
81
+ useEffect(() => {
82
+ card.current?.focus();
83
+ }, [at]);
84
+ if (!stop || !box)
85
+ return null;
86
+ const next = () => (at + 1 < live.length ? setAt(at + 1) : finish());
87
+ /* Above the target if there is room, below if not. The two FABs sit at the
88
+ bottom of the window, where a card underneath them would be off screen. */
89
+ const below = box.top < window.innerHeight / 2;
90
+ const style = {
91
+ top: below ? box.bottom + 14 : undefined,
92
+ bottom: below ? undefined : window.innerHeight - box.top + 14,
93
+ left: Math.max(16, Math.min(box.left + box.width / 2 - 170, window.innerWidth - 356)),
94
+ };
95
+ return createPortal(_jsxs("div", { className: "tour", onKeyDown: (e) => {
96
+ if (e.key === 'Escape')
97
+ finish();
98
+ if (e.key === 'Enter' || e.key === 'ArrowRight')
99
+ next();
100
+ }, children: [_jsx("div", { className: "tour-hole", style: {
101
+ top: box.top - 6,
102
+ left: box.left - 6,
103
+ width: box.width + 12,
104
+ height: box.height + 12,
105
+ } }), _jsxs("div", { ref: card, className: "tour-card", style: style,
106
+ // biome-ignore lint/a11y/useSemanticElements: a <dialog> brings the top layer, ::backdrop and showModal() focus semantics; this is an anchored coach-mark card that positions itself and manages its own focus, so the role is the faithful choice.
107
+ role: "dialog", "aria-modal": "true", "aria-labelledby": "tour-title", tabIndex: -1, children: [_jsx("strong", { id: "tour-title", className: "tour-title", children: stop.title }), _jsx("p", { className: "tour-body", children: stop.body }), _jsxs("div", { className: "tour-actions", children: [_jsxs("span", { className: "tour-count mono", children: [at + 1, " of ", live.length] }), _jsx("span", { className: "grow" }), _jsx(Button, { kind: "ghost", size: "sm", onPress: finish, children: "Skip" }), _jsx(Button, { kind: "primary", size: "sm", onPress: next, children: at + 1 < live.length ? 'Next' : 'Done' })] })] })] }), document.body);
108
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Every icon name, in one place.
3
+ *
4
+ * **Its own module so `Icon.tsx` stays a Fast Refresh boundary.** React Fast
5
+ * Refresh can only swap a module in place when everything it exports is a
6
+ * component; one exported array is enough to disqualify the file, and Vite then
7
+ * pushes the update up to whoever imports it -- which for `Icon` is very nearly
8
+ * the whole app, so editing one glyph re-ran App. The names have no reason to
9
+ * sit beside the component anyway.
10
+ *
11
+ * The gallery used to hold its own list of fourteen and quietly omitted five --
12
+ * a catalogue page that documents less than the set it documents is how
13
+ * somebody concludes an icon does not exist and draws it again. `IconName` is
14
+ * derived from this, so the type, the glyph table and the gallery cannot
15
+ * disagree.
16
+ */
17
+ export declare const ICON_NAMES: readonly ["chat", "settings", "refresh", "minimize", "code", "wifi", "wifi-off", "check", "close", "info", "warning", "error", "expand", "download", "image", "bolt", "speaker", "mic", "back", "spinner", "folder", "book", "eye", "eye-off", "palette", "stars", "wrench", "cloud", "file"];
18
+ export type IconName = (typeof ICON_NAMES)[number];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Every icon name, in one place.
3
+ *
4
+ * **Its own module so `Icon.tsx` stays a Fast Refresh boundary.** React Fast
5
+ * Refresh can only swap a module in place when everything it exports is a
6
+ * component; one exported array is enough to disqualify the file, and Vite then
7
+ * pushes the update up to whoever imports it -- which for `Icon` is very nearly
8
+ * the whole app, so editing one glyph re-ran App. The names have no reason to
9
+ * sit beside the component anyway.
10
+ *
11
+ * The gallery used to hold its own list of fourteen and quietly omitted five --
12
+ * a catalogue page that documents less than the set it documents is how
13
+ * somebody concludes an icon does not exist and draws it again. `IconName` is
14
+ * derived from this, so the type, the glyph table and the gallery cannot
15
+ * disagree.
16
+ */
17
+ export const ICON_NAMES = [
18
+ 'chat',
19
+ 'settings',
20
+ 'refresh',
21
+ 'minimize',
22
+ 'code',
23
+ 'wifi',
24
+ 'wifi-off',
25
+ 'check',
26
+ 'close',
27
+ 'info',
28
+ 'warning',
29
+ 'error',
30
+ 'expand',
31
+ 'download',
32
+ 'image',
33
+ 'bolt',
34
+ 'speaker',
35
+ 'mic',
36
+ 'back',
37
+ 'spinner',
38
+ // Added for the Updates tab and the file-access card: `image` was standing
39
+ // in for npm packages and `code` for a folder of files, which are the wrong
40
+ // words with the right shape. Measured the same way as the rest -- see the
41
+ // `Glyph` docstring in `Icon.tsx`.
42
+ 'folder',
43
+ 'book',
44
+ // What a model can do, for the capability tags on a model row. Each one
45
+ // has to carry *direction*: `_tags` in `routes_models.py` records why
46
+ // `sees` and `draws` were rejected as names -- nobody could tell which
47
+ // way the picture went. An eye takes one in, a palette puts one out.
48
+ 'eye',
49
+ // The same eye, struck through: the other half of a show/hide toggle.
50
+ // Not for a capability tag -- a model that cannot see has no tag.
51
+ 'eye-off',
52
+ 'palette',
53
+ 'stars',
54
+ 'wrench',
55
+ 'cloud',
56
+ // The chat's attach button. A folder was the nearest thing in the set and
57
+ // is the wrong noun: you are handing over one file, not opening a place
58
+ // that holds several.
59
+ 'file',
60
+ ];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Whether this browser has been walked round the chrome.
3
+ *
4
+ * **Its own module so `Tour.tsx` stays a Fast Refresh boundary.** Three
5
+ * exported functions beside the component meant Vite could not swap the tour in
6
+ * place -- `Could not Fast Refresh ("markTourSeen" export is incompatible)` --
7
+ * and pushed every edit up to App instead. Editing the tour's copy is exactly
8
+ * the thing somebody does over and over, so it is the worst file in the app to
9
+ * have lost its boundary.
10
+ *
11
+ * These are also not React at all: three reads and writes of one string. They
12
+ * were only in the component file because that is where the tour was written.
13
+ */
14
+ export declare function tourSeen(): boolean;
15
+ /**
16
+ * Forget it, so the next completed onboarding shows it again.
17
+ *
18
+ * Called when the onboarding window *appears*, which is the whole fix: the
19
+ * marker lives in this browser and `config/` lives on disk, so clearing the
20
+ * config replayed onboarding and left the tour suppressed by a value nothing
21
+ * server-side could reach. Somebody re-bootstrapping to look at onboarding got
22
+ * the onboarding and not the thing that follows it, with no way to tell why.
23
+ *
24
+ * Keying off "onboarding is on screen" rather than off a stored timestamp keeps
25
+ * this a fact about one browser, which is what it is. If you are being asked
26
+ * these questions again, you have not seen the walk that comes after them.
27
+ */
28
+ export declare function forgetTour(): void;
29
+ export declare function markTourSeen(): void;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Whether this browser has been walked round the chrome.
3
+ *
4
+ * **Its own module so `Tour.tsx` stays a Fast Refresh boundary.** Three
5
+ * exported functions beside the component meant Vite could not swap the tour in
6
+ * place -- `Could not Fast Refresh ("markTourSeen" export is incompatible)` --
7
+ * and pushed every edit up to App instead. Editing the tour's copy is exactly
8
+ * the thing somebody does over and over, so it is the worst file in the app to
9
+ * have lost its boundary.
10
+ *
11
+ * These are also not React at all: three reads and writes of one string. They
12
+ * were only in the component file because that is where the tour was written.
13
+ */
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
+ const SEEN = 'tf-tour-seen';
21
+ export function tourSeen() {
22
+ try {
23
+ return localStorage.getItem(SEEN) === '1';
24
+ }
25
+ catch {
26
+ // A browser refusing storage is not a reason to refuse the tour.
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Forget it, so the next completed onboarding shows it again.
32
+ *
33
+ * Called when the onboarding window *appears*, which is the whole fix: the
34
+ * marker lives in this browser and `config/` lives on disk, so clearing the
35
+ * config replayed onboarding and left the tour suppressed by a value nothing
36
+ * server-side could reach. Somebody re-bootstrapping to look at onboarding got
37
+ * the onboarding and not the thing that follows it, with no way to tell why.
38
+ *
39
+ * Keying off "onboarding is on screen" rather than off a stored timestamp keeps
40
+ * this a fact about one browser, which is what it is. If you are being asked
41
+ * these questions again, you have not seen the walk that comes after them.
42
+ */
43
+ export function forgetTour() {
44
+ try {
45
+ localStorage.removeItem(SEEN);
46
+ }
47
+ catch {
48
+ /* nothing to do about it, and nothing that needs doing */
49
+ }
50
+ }
51
+ export function markTourSeen() {
52
+ try {
53
+ localStorage.setItem(SEEN, '1');
54
+ }
55
+ catch {
56
+ /* nothing to do about it, and nothing that needs doing */
57
+ }
58
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The contrast measurement, as a function rather than a rule.
3
+ *
4
+ * "4.5:1 for text, 3:1 for a boundary" has been the house rule for as long as
5
+ * there have been themes, and it was held by nobody: the Night theme shipped
6
+ * its primary button at 2.72:1 because the rule was written down and the
7
+ * measurement was not. `test/contrast.test.ts` measures every built-in theme
8
+ * with these two functions. They are exported so an app can measure its own --
9
+ * the package ships themes as examples, and a theme somebody writes for their
10
+ * app is the one nobody here will ever look at.
11
+ *
12
+ * WCAG 2 relative luminance and contrast ratio, on six-digit hex. Nothing
13
+ * here parses `color-mix()` or `var()`: the derived tokens are computed from
14
+ * these, so measuring the inputs is measuring them.
15
+ */
16
+ export declare function luminance(hex: string): number;
17
+ /** The ratio between two colours, 1:1 up to 21:1, whichever is on top. */
18
+ export declare function ratio(a: string, b: string): number;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The contrast measurement, as a function rather than a rule.
3
+ *
4
+ * "4.5:1 for text, 3:1 for a boundary" has been the house rule for as long as
5
+ * there have been themes, and it was held by nobody: the Night theme shipped
6
+ * its primary button at 2.72:1 because the rule was written down and the
7
+ * measurement was not. `test/contrast.test.ts` measures every built-in theme
8
+ * with these two functions. They are exported so an app can measure its own --
9
+ * the package ships themes as examples, and a theme somebody writes for their
10
+ * app is the one nobody here will ever look at.
11
+ *
12
+ * WCAG 2 relative luminance and contrast ratio, on six-digit hex. Nothing
13
+ * here parses `color-mix()` or `var()`: the derived tokens are computed from
14
+ * these, so measuring the inputs is measuring them.
15
+ */
16
+ export function luminance(hex) {
17
+ const channel = (i) => {
18
+ const v = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
19
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
20
+ };
21
+ return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
22
+ }
23
+ /** The ratio between two colours, 1:1 up to 21:1, whichever is on top. */
24
+ export function ratio(a, b) {
25
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
26
+ return (hi + 0.05) / (lo + 0.05);
27
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Keep focus inside a window, and put it back when the window goes away.
3
+ *
4
+ * `Modal` uses this, and so does anything that is modal without being shaped
5
+ * like a modal -- the applet studio is a full-screen workspace with its own
6
+ * header and its own idea of what closing means, and wrapping it in `Modal` to
7
+ * borrow the behaviour would mean pretending it is a dialog with a title and a
8
+ * footer. It is not. It just has the same obligation.
9
+ *
10
+ * **The obligation.** Everything behind a modal stays in the tab order:
11
+ * focusable, operable by keyboard, and invisible under the scrim. Tab past the
12
+ * last control and you are somewhere on the page you cannot see, pressing
13
+ * buttons you cannot read. No mouse ever finds this, which is why every one of
14
+ * the app's eight modals shipped without it.
15
+ */
16
+ import { type RefObject } from 'react';
17
+ export declare function useTrapFocus(box: RefObject<HTMLElement | null>, { active, onEscape, }?: {
18
+ /** False while the window is mounted but not showing -- the studio stays in
19
+ * the tree when minimised, and trapping focus inside something invisible
20
+ * is the same bug pointing the other way. */
21
+ active?: boolean;
22
+ onEscape?: () => void;
23
+ }): (e: React.KeyboardEvent) => void;
24
+ export default useTrapFocus;