@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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/components/Brand.d.ts +10 -0
- package/dist/components/Brand.js +212 -0
- package/dist/components/Button.d.ts +63 -0
- package/dist/components/Button.js +74 -0
- package/dist/components/Callout.d.ts +37 -0
- package/dist/components/Callout.js +71 -0
- package/dist/components/Card.d.ts +42 -0
- package/dist/components/Card.js +30 -0
- package/dist/components/Checkbox.d.ts +32 -0
- package/dist/components/Checkbox.js +31 -0
- package/dist/components/DangerZone.d.ts +59 -0
- package/dist/components/DangerZone.js +50 -0
- package/dist/components/Dialog.d.ts +28 -0
- package/dist/components/Dialog.js +29 -0
- package/dist/components/Empty.d.ts +45 -0
- package/dist/components/Empty.js +35 -0
- package/dist/components/Field.d.ts +58 -0
- package/dist/components/Field.js +46 -0
- package/dist/components/Icon.d.ts +64 -0
- package/dist/components/Icon.js +235 -0
- package/dist/components/Illustration.d.ts +36 -0
- package/dist/components/Illustration.js +48 -0
- package/dist/components/Input.d.ts +14 -0
- package/dist/components/Input.js +65 -0
- package/dist/components/Markdown.d.ts +21 -0
- package/dist/components/Markdown.js +29 -0
- package/dist/components/Modal.d.ts +59 -0
- package/dist/components/Modal.js +72 -0
- package/dist/components/Pill.d.ts +40 -0
- package/dist/components/Pill.js +41 -0
- package/dist/components/Progress.d.ts +35 -0
- package/dist/components/Progress.js +27 -0
- package/dist/components/Rows.d.ts +101 -0
- package/dist/components/Rows.js +55 -0
- package/dist/components/Select.d.ts +28 -0
- package/dist/components/Select.js +56 -0
- package/dist/components/SizeGrid.d.ts +34 -0
- package/dist/components/SizeGrid.js +41 -0
- package/dist/components/Skeleton.d.ts +45 -0
- package/dist/components/Skeleton.js +47 -0
- package/dist/components/Slider.d.ts +70 -0
- package/dist/components/Slider.js +100 -0
- package/dist/components/Table.d.ts +43 -0
- package/dist/components/Table.js +13 -0
- package/dist/components/Tabs.d.ts +72 -0
- package/dist/components/Tabs.js +82 -0
- package/dist/components/Textarea.d.ts +9 -0
- package/dist/components/Textarea.js +22 -0
- package/dist/components/Toast.d.ts +43 -0
- package/dist/components/Toast.js +78 -0
- package/dist/components/Toggle.d.ts +56 -0
- package/dist/components/Toggle.js +189 -0
- package/dist/components/Tooltip.d.ts +22 -0
- package/dist/components/Tooltip.js +62 -0
- package/dist/components/Tour.d.ts +33 -0
- package/dist/components/Tour.js +108 -0
- package/dist/components/iconNames.d.ts +18 -0
- package/dist/components/iconNames.js +60 -0
- package/dist/components/tourMarker.d.ts +29 -0
- package/dist/components/tourMarker.js +58 -0
- package/dist/contrast.d.ts +18 -0
- package/dist/contrast.js +27 -0
- package/dist/hooks/useTrapFocus.d.ts +24 -0
- package/dist/hooks/useTrapFocus.js +67 -0
- package/dist/illustrations.d.ts +11 -0
- package/dist/illustrations.js +55 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +65 -0
- package/dist/styles/index.css +3124 -0
- package/dist/themes.d.ts +210 -0
- package/dist/themes.js +300 -0
- package/dist/tokens.css +251 -0
- 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;
|
package/dist/contrast.js
ADDED
|
@@ -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;
|