@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,100 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useRef, useState } from 'react';
3
+ /**
4
+ * A number chosen from a range, by dragging.
5
+ *
6
+ * For a setting whose answer is "about this much" rather than one of a list:
7
+ * how much memory tf may take, say. A `Select` with eight sizes on it makes
8
+ * the person pick the nearest wrong one; a box you type a number into asks
9
+ * them to know the ceiling. A slider shows the ceiling, the floor, and where
10
+ * between them they are.
11
+ *
12
+ * **Bounded, always.** `min` and `max` are required rather than defaulted,
13
+ * because a slider with an invented ceiling lies about what the machine can
14
+ * do. **Stepped, optionally.** `step` given, the knob snaps to that interval
15
+ * -- half a gigabyte, five minutes -- and the value is always one of the
16
+ * marks. Without it the range is continuous (`step="any"`) and the value is
17
+ * wherever the knob stopped, which is right for a quantity nobody counts in
18
+ * units.
19
+ *
20
+ * **Applies on release, shows while dragging.** A setting that saved on
21
+ * every pixel would put a hundred writes behind one gesture, so `onChange`
22
+ * fires when the knob is let go and the value beside the knob follows the
23
+ * drag live. That is the *native* `change` event, listened for directly:
24
+ * React's `onChange` on an input is its `input` event under another name
25
+ * and fires per pixel, which is exactly what the first version did -- every
26
+ * pixel saved, every save handed the value back, and the knob snapped to
27
+ * wherever the last reply said while the pointer was still down. Nobody
28
+ * could drag it. The keyboard is the one place even the native event is too
29
+ * eager: an arrow key fires `change` per press, and the same replies landed
30
+ * between presses (measured: twenty-two presses from the floor landed at
31
+ * 2.5 rather than 12). So key presses settle for a third of a second before
32
+ * they are said, and while a drag or a settle is under way the prop is not
33
+ * allowed to overwrite what is on screen.
34
+ *
35
+ * A native `<input type="range">`, for the same reason `Toggle` is a native
36
+ * checkbox: the keyboard, the focus ring and the announcement come free, and
37
+ * `aria-valuetext` says the value in the caller's words ("12.0 GB") rather
38
+ * than as a bare number.
39
+ */
40
+ export default function Slider({ label, hint, value, min, max, step, onChange, format = (v) => String(v), disabled, labelHidden, size = 'md', danger, className, }) {
41
+ const id = useId();
42
+ // What the knob is at while it is being dragged. The prop is what was
43
+ // last settled; between the two the drag is shown and nothing is saved.
44
+ const [live, setLive] = useState(value);
45
+ const input = useRef(null);
46
+ const keyboard = useRef(false);
47
+ const dragging = useRef(false);
48
+ const settling = useRef(null);
49
+ // Read at commit time rather than closed over, so the native listener
50
+ // below is registered once and still calls the caller's latest handler.
51
+ const commit = useRef(onChange);
52
+ commit.current = onChange;
53
+ useEffect(() => {
54
+ if (!dragging.current && settling.current === null)
55
+ setLive(value);
56
+ }, [value]);
57
+ useEffect(() => {
58
+ const el = input.current;
59
+ if (!el)
60
+ return;
61
+ const settle = () => {
62
+ const v = Number(el.value);
63
+ if (settling.current !== null)
64
+ clearTimeout(settling.current);
65
+ if (!keyboard.current)
66
+ return commit.current(v);
67
+ settling.current = setTimeout(() => {
68
+ settling.current = null;
69
+ commit.current(v);
70
+ }, 350);
71
+ };
72
+ el.addEventListener('change', settle);
73
+ return () => {
74
+ el.removeEventListener('change', settle);
75
+ if (settling.current !== null)
76
+ clearTimeout(settling.current);
77
+ };
78
+ }, []);
79
+ const fill = max > min ? ((Math.min(max, Math.max(min, live)) - min) / (max - min)) * 100 : 0;
80
+ // How far into the risk the value is, 0..1: none up to `from`, all at `to`.
81
+ const risk = danger
82
+ ? Math.max(0, Math.min(1, (live - danger.from) / ((danger.to ?? max) - danger.from || 1)))
83
+ : 0;
84
+ return (_jsxs("div", { className: `slider-row slider-${size}${disabled ? ' is-disabled' : ''}${className ? ` ${className}` : ''}`, children: [_jsxs("label", { htmlFor: id, className: labelHidden ? 'sr-only' : 'slider-body', children: [_jsx("span", { className: "slider-label", children: label }), hint && _jsx("span", { className: "slider-hint", children: hint })] }), _jsxs("div", { className: "slider-control", children: [_jsxs("span", { className: "slider-box", children: [_jsx("span", { className: "slider-track", style: {
85
+ '--slider-f': String(fill / 100),
86
+ '--slider-risk': `${Math.round(risk * 100)}%`,
87
+ }, children: _jsx("input", { id: id, type: "range", className: "slider", min: min, max: max, step: step ?? 'any', value: live, disabled: disabled, "aria-valuetext": format(live), ref: input, onKeyDown: () => {
88
+ keyboard.current = true;
89
+ }, onPointerDown: () => {
90
+ keyboard.current = false;
91
+ dragging.current = true;
92
+ }, onPointerUp: () => {
93
+ dragging.current = false;
94
+ }, onPointerCancel: () => {
95
+ dragging.current = false;
96
+ },
97
+ // React's `onChange` is the per-pixel event; it only draws. The
98
+ // commit is the native `change`, wired above.
99
+ onChange: (e) => setLive(Number(e.target.value)) }) }), _jsx("output", { htmlFor: id, className: "slider-value mono", children: format(live) })] }), _jsx("span", { className: "slider-end mono", "aria-hidden": "true", children: format(min) }), _jsx("span", { className: "slider-end mono", "aria-hidden": "true", children: format(max) })] })] }));
100
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Data that genuinely has columns.
3
+ *
4
+ * **This is not `Rows`, and the difference is not cosmetic.** A `Row` is one
5
+ * thing with a name and some facts about it; you read down the names and stop
6
+ * at the one you want. A table is a grid you read *across* as well as down --
7
+ * comparing the same field between two things is the only reason to line them
8
+ * up in tracks. If nobody will ever compare column three between row two and
9
+ * row nine, it is a list, and a list is `Rows`.
10
+ *
11
+ * **A real `<table>`, because the semantics are the accessibility.** A grid of
12
+ * divs looks identical and announces as a wall of unrelated text: a screen
13
+ * reader reading a `<td>` says which column it is in, and `<th scope>` is what
14
+ * makes that possible. There is no ARIA that gets you back what a `<table>`
15
+ * gives for free, only ARIA that approximates it badly.
16
+ *
17
+ * **Numbers go right.** Not decoration -- digits are compared by their columns,
18
+ * and left-aligned numbers of different lengths cannot be. `align: 'end'` also
19
+ * switches the cell to tabular figures so the digits keep their tracks.
20
+ */
21
+ export interface Column<T> {
22
+ /** The heading. Every column has one; an unlabelled column is a column
23
+ * nobody can ask about. */
24
+ header: string;
25
+ /** What to draw in the cell. */
26
+ cell: (item: T) => React.ReactNode;
27
+ /** `end` for numbers and sizes, which compare by column. */
28
+ align?: 'start' | 'end';
29
+ /** Hides the heading visually and leaves it for a screen reader -- for the
30
+ * column of buttons at the end, where a visible "Actions" is noise. */
31
+ quiet?: boolean;
32
+ width?: string;
33
+ }
34
+ export default function Table<T>({ caption, columns, rows, keyOf, empty, className, }: {
35
+ /** What the table is. Rendered as a real `<caption>`, which is the one place
36
+ * a table's name belongs. */
37
+ caption: string;
38
+ columns: Column<T>[];
39
+ rows: T[];
40
+ keyOf: (item: T) => string;
41
+ empty?: React.ReactNode;
42
+ className?: string;
43
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export default function Table({ caption, columns, rows, keyOf, empty, className, }) {
3
+ if (!rows.length) {
4
+ return empty ? _jsx("div", { className: "set-hint rows-empty", children: empty }) : null;
5
+ }
6
+ return (_jsx("div", { className: `table-scroll${className ? ` ${className}` : ''}`, children: _jsxs("table", { className: "table", children: [_jsx("caption", { className: "sr-only", children: caption }), _jsx("thead", { children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { scope: "col", style: { width: c.width, textAlign: c.align === 'end' ? 'right' : undefined }, children: _jsx("span", { className: c.quiet ? 'sr-only' : undefined, children: c.header }) }, c.header))) }) }), _jsx("tbody", { children: rows.map((item) => (_jsx("tr", { children: columns.map((c, i) => {
7
+ /* The first cell is the row's name, so it is a `<th scope="row">`
8
+ -- that is what lets a reader ask "which row am I in" and get
9
+ an answer instead of a cell index. */
10
+ const Cell = i === 0 ? 'th' : 'td';
11
+ return (_jsx(Cell, { scope: i === 0 ? 'row' : undefined, className: c.align === 'end' ? 'num' : undefined, children: c.cell(item) }, c.header));
12
+ }) }, keyOf(item)))) })] }) }));
13
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * One row of choices where only one is showing.
3
+ *
4
+ * **The arrow keys are the point.** A tab list is a single tab stop: you Tab
5
+ * into it once and then move along it with arrows. Rendering tabs as ordinary
6
+ * buttons gives the opposite -- one stop per tab -- so a keyboard user pages
7
+ * through the whole strip to reach whatever is after it. That is invisible
8
+ * unless you put the mouse down, which is why it is worth having a component
9
+ * that cannot get it wrong.
10
+ *
11
+ * **Settings' rail is one of these, and I said it was not.** The earlier note
12
+ * here argued that a vertical list with an explanatory line under each name is
13
+ * "a different control that happens to select one of several panes". That was
14
+ * about how it looks. It is a tablist by every behavioural test -- one of ten
15
+ * is current, choosing one swaps the pane beside it, `aria-current` was already
16
+ * on it -- and being written as ten plain buttons cost exactly what this
17
+ * component exists to prevent: ten tab stops between the top of the window and
18
+ * the pane. `orientation="vertical"` is the smaller change.
19
+ *
20
+ * `ViewTabs` is still not one: it carries inline rename, drag-to-reorder and a
21
+ * cog per tab, and swapping it for this would delete features to gain a
22
+ * keyboard it can be given directly. The studio's "Source / Preview" is one
23
+ * button that changes its own label, which is a toggle.
24
+ *
25
+ * **Groups are headings, not tabs.** A vertical rail can put its tabs under
26
+ * group labels -- Settings has fifteen leaves and a flat list of fifteen is a
27
+ * list you read twice. The label is a heading with a hint under it and is not
28
+ * interactive: a button inside a tablist that is not a tab is a thing the
29
+ * arrow keys do not know about, and a collapsed group would hide tabs from a
30
+ * keyboard that expects to reach every one. So the rail shows everything and
31
+ * the group says what the tabs under it have in common; a tab inside a group
32
+ * shows no hint of its own, because the group's is the context and fifteen
33
+ * one-liners is the height the grouping was meant to save. Horizontal strips
34
+ * ignore groups -- there is no room above a tab for a heading.
35
+ */
36
+ import React from 'react';
37
+ export interface Tab {
38
+ id: string;
39
+ label: string;
40
+ /** Shown after the label, for a count or a state. */
41
+ badge?: string;
42
+ /** A line under the label, for a rail with room for one. Ignored in a
43
+ * horizontal strip, where there is none, and under a group, where the
44
+ * group's hint is the context. */
45
+ hint?: string;
46
+ disabled?: boolean;
47
+ /** The group this tab sits under in a vertical rail -- a `TabGroup.id`.
48
+ * Tabs of one group must be adjacent in `tabs`; the label is drawn above
49
+ * the first of them. */
50
+ group?: string;
51
+ }
52
+ export interface TabGroup {
53
+ id: string;
54
+ label: string;
55
+ /** What the tabs under this label have in common, in one line. */
56
+ hint?: string;
57
+ }
58
+ export default function Tabs({ tabs, value, onChange, label, orientation, groups, className, }: {
59
+ tabs: Tab[];
60
+ value: string;
61
+ onChange: (id: string) => void;
62
+ /** What this set of tabs is choosing between. */
63
+ label: string;
64
+ /** `vertical` for a rail down the side of a pane — Settings' sections,
65
+ * which are tabs in every way that matters and were separate tab stops
66
+ * because they were written as buttons. */
67
+ orientation?: 'horizontal' | 'vertical';
68
+ /** The headings a vertical rail draws above runs of tabs that name them in
69
+ * `Tab.group`. A group no tab names is not drawn. */
70
+ groups?: TabGroup[];
71
+ className?: string;
72
+ }): React.JSX.Element;
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * One row of choices where only one is showing.
4
+ *
5
+ * **The arrow keys are the point.** A tab list is a single tab stop: you Tab
6
+ * into it once and then move along it with arrows. Rendering tabs as ordinary
7
+ * buttons gives the opposite -- one stop per tab -- so a keyboard user pages
8
+ * through the whole strip to reach whatever is after it. That is invisible
9
+ * unless you put the mouse down, which is why it is worth having a component
10
+ * that cannot get it wrong.
11
+ *
12
+ * **Settings' rail is one of these, and I said it was not.** The earlier note
13
+ * here argued that a vertical list with an explanatory line under each name is
14
+ * "a different control that happens to select one of several panes". That was
15
+ * about how it looks. It is a tablist by every behavioural test -- one of ten
16
+ * is current, choosing one swaps the pane beside it, `aria-current` was already
17
+ * on it -- and being written as ten plain buttons cost exactly what this
18
+ * component exists to prevent: ten tab stops between the top of the window and
19
+ * the pane. `orientation="vertical"` is the smaller change.
20
+ *
21
+ * `ViewTabs` is still not one: it carries inline rename, drag-to-reorder and a
22
+ * cog per tab, and swapping it for this would delete features to gain a
23
+ * keyboard it can be given directly. The studio's "Source / Preview" is one
24
+ * button that changes its own label, which is a toggle.
25
+ *
26
+ * **Groups are headings, not tabs.** A vertical rail can put its tabs under
27
+ * group labels -- Settings has fifteen leaves and a flat list of fifteen is a
28
+ * list you read twice. The label is a heading with a hint under it and is not
29
+ * interactive: a button inside a tablist that is not a tab is a thing the
30
+ * arrow keys do not know about, and a collapsed group would hide tabs from a
31
+ * keyboard that expects to reach every one. So the rail shows everything and
32
+ * the group says what the tabs under it have in common; a tab inside a group
33
+ * shows no hint of its own, because the group's is the context and fifteen
34
+ * one-liners is the height the grouping was meant to save. Horizontal strips
35
+ * ignore groups -- there is no room above a tab for a heading.
36
+ */
37
+ import React, { useRef } from 'react';
38
+ export default function Tabs({ tabs, value, onChange, label, orientation = 'horizontal', groups = [], className, }) {
39
+ const strip = useRef(null);
40
+ const grouped = orientation === 'vertical' && groups.length > 0;
41
+ const move = (from, by) => {
42
+ const n = tabs.length;
43
+ for (let i = 1; i <= n; i++) {
44
+ const at = (from + by * i + n * n) % n;
45
+ if (!tabs[at].disabled) {
46
+ onChange(tabs[at].id);
47
+ // Focus follows selection, which is the pattern for tabs whose panels
48
+ // are cheap to show. The alternative -- move focus, select on Enter --
49
+ // is for tab panels that cost something to render, and none here do.
50
+ strip.current?.querySelectorAll('[role="tab"]')[at]?.focus();
51
+ return;
52
+ }
53
+ }
54
+ };
55
+ return (_jsxs("div", { className: `tabs tabs-${orientation}${className ? ` ${className}` : ''}`, role: "tablist", "aria-label": label, "aria-orientation": orientation, ref: strip, children: [tabs.map((t, i) => (_jsxs(React.Fragment, { children: [grouped &&
56
+ t.group &&
57
+ tabs[i - 1]?.group !== t.group &&
58
+ (() => {
59
+ const g = groups.find((x) => x.id === t.group);
60
+ return g ? (_jsxs("div", { className: "tab-group", role: "presentation", children: [_jsx("span", { className: "tab-group-label", children: g.label }), g.hint && _jsx("span", { className: "tab-hint", children: g.hint })] })) : null;
61
+ })(), _jsxs("button", { role: "tab", type: "button", className: `tab${t.id === value ? ' on' : ''}${grouped && t.group ? ' in-group' : ''}`, "aria-selected": t.id === value, disabled: t.disabled,
62
+ /* One tab stop for the whole strip: everything but the current tab is
63
+ taken out of the tab order, and the arrows move between them. */
64
+ tabIndex: t.id === value ? 0 : -1, onClick: () => onChange(t.id), onKeyDown: (e) => {
65
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
66
+ e.preventDefault();
67
+ move(i, 1);
68
+ }
69
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
70
+ e.preventDefault();
71
+ move(i, -1);
72
+ }
73
+ if (e.key === 'Home') {
74
+ e.preventDefault();
75
+ move(-1, 1);
76
+ }
77
+ if (e.key === 'End') {
78
+ e.preventDefault();
79
+ move(tabs.length, -1);
80
+ }
81
+ }, children: [_jsx("span", { className: "tab-label", children: t.label }), t.badge && _jsx("span", { className: "tab-badge", children: t.badge }), t.hint && orientation === 'vertical' && !(grouped && t.group) && (_jsx("span", { className: "tab-hint", children: t.hint }))] })] }, t.id))), _jsx("span", { className: "tabs-rule", "aria-hidden": "true" })] }));
82
+ }
@@ -0,0 +1,9 @@
1
+ export interface Props extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
2
+ size?: 'sm' | 'md' | 'lg';
3
+ /** Monospace — a system prompt, an environment block, anything where the
4
+ * characters are the content. */
5
+ mono?: boolean;
6
+ className?: string;
7
+ }
8
+ declare const Textarea: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLTextAreaElement>>;
9
+ export default Textarea;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The other bare control `Field` wraps.
4
+ *
5
+ * Not resizable by hand. The chat composer grows with what you type, and the
6
+ * browser's drag handle fought it — you would size it, type a line, and it
7
+ * would snap back. `resize: none` lives in the stylesheet on the element, so
8
+ * this component keeps it by rendering the element.
9
+ *
10
+ * Same contract as `Input`: a plain `<textarea>` so every element rule in the
11
+ * stylesheet keeps working, and its `id`, `aria-describedby` and `aria-invalid`
12
+ * come from `Field`'s render prop rather than from a context. On its own it
13
+ * needs an `aria-label`, for the same reason `Input` does.
14
+ */
15
+ import { forwardRef } from 'react';
16
+ const Textarea = forwardRef(function Textarea({ size = 'md', mono, className, ...rest }, ref) {
17
+ const classes = [size === 'md' ? '' : `size-${size}`, mono ? 'mono' : '', className ?? '']
18
+ .filter(Boolean)
19
+ .join(' ');
20
+ return _jsx("textarea", { ref: ref, className: classes || undefined, ...rest });
21
+ });
22
+ export default Textarea;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Something happened, and it does not need answering.
3
+ *
4
+ * There is no transient feedback in this app at all. Every outcome is either a
5
+ * `.callout` that stays until the page changes, or nothing -- so "saved",
6
+ * "copied", "model removed" and "conversation deleted" are all silent, and the
7
+ * only way to know a button worked is that something else moved.
8
+ *
9
+ * **A toast is for the outcome you would not have chased.** If the reader has to
10
+ * act on it, it is a callout and belongs on the page. If they have to decide, it
11
+ * is a dialog. This is the third case: it worked, you may carry on, and in four
12
+ * seconds there will be no trace.
13
+ *
14
+ * **`role="status"`, not `alert`.** An alert interrupts whatever a screen reader
15
+ * is saying, which is right for "the download failed" and rude for "copied".
16
+ * The failing case passes `tone="bad"`, which is the one that promotes itself.
17
+ */
18
+ export interface Toast {
19
+ text: string;
20
+ /** The same four words `Callout` and `Pill` use. `warn` was missing here
21
+ * until 2026-09-05 -- three tones where the rest of the system has four. */
22
+ tone: 'info' | 'good' | 'warn' | 'bad';
23
+ }
24
+ export declare function useToast(): (text: string, tone?: Toast["tone"]) => void;
25
+ /**
26
+ * The queue and the region, once, at the root.
27
+ *
28
+ * React Aria's `ToastQueue` owns what a `useState` list and two `setTimeout`s
29
+ * did by hand, and three things they did not: the timeout pauses while the
30
+ * pointer or focus is on the toast; the region is a landmark, so a screen
31
+ * reader can reach it with F6 rather than only hearing it; and dismissing one
32
+ * puts focus back where it was, instead of dropping it on the body.
33
+ *
34
+ * The role changes, and on purpose. Each toast was `role="status"` -- or
35
+ * `alert` for the failing tone -- which is right for text that only has to be
36
+ * heard. These carry a Dismiss button, and a live region's contents are not
37
+ * reachable; React Aria makes each toast an `alertdialog` inside a live
38
+ * `region` for that reason, so the announcement still happens and the button
39
+ * can still be reached.
40
+ */
41
+ export declare function ToastHost({ children }: {
42
+ children: React.ReactNode;
43
+ }): import("react").JSX.Element;
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Something happened, and it does not need answering.
4
+ *
5
+ * There is no transient feedback in this app at all. Every outcome is either a
6
+ * `.callout` that stays until the page changes, or nothing -- so "saved",
7
+ * "copied", "model removed" and "conversation deleted" are all silent, and the
8
+ * only way to know a button worked is that something else moved.
9
+ *
10
+ * **A toast is for the outcome you would not have chased.** If the reader has to
11
+ * act on it, it is a callout and belongs on the page. If they have to decide, it
12
+ * is a dialog. This is the third case: it worked, you may carry on, and in four
13
+ * seconds there will be no trace.
14
+ *
15
+ * **`role="status"`, not `alert`.** An alert interrupts whatever a screen reader
16
+ * is saying, which is right for "the download failed" and rude for "copied".
17
+ * The failing case passes `tone="bad"`, which is the one that promotes itself.
18
+ */
19
+ import { createContext, useContext, useMemo, useState } from 'react';
20
+ import Icon from './Icon';
21
+ import { UNSTABLE_Toast as AriaToast, Button, Text, UNSTABLE_ToastContent as ToastContent, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastRegion as ToastRegion, } from 'react-aria-components';
22
+ const MARK = {
23
+ info: 'info',
24
+ good: 'check',
25
+ warn: 'warning',
26
+ bad: 'error',
27
+ };
28
+ /** How long one stays. Long enough to read twice, short enough that a run of
29
+ * them does not pile up -- and paused while the pointer or focus is on it,
30
+ * because a message that expires while you are reading it was never shown. */
31
+ const LINGER = 4000;
32
+ const Ctx = createContext(() => { });
33
+ export function useToast() {
34
+ return useContext(Ctx);
35
+ }
36
+ /**
37
+ * The queue and the region, once, at the root.
38
+ *
39
+ * React Aria's `ToastQueue` owns what a `useState` list and two `setTimeout`s
40
+ * did by hand, and three things they did not: the timeout pauses while the
41
+ * pointer or focus is on the toast; the region is a landmark, so a screen
42
+ * reader can reach it with F6 rather than only hearing it; and dismissing one
43
+ * puts focus back where it was, instead of dropping it on the body.
44
+ *
45
+ * The role changes, and on purpose. Each toast was `role="status"` -- or
46
+ * `alert` for the failing tone -- which is right for text that only has to be
47
+ * heard. These carry a Dismiss button, and a live region's contents are not
48
+ * reachable; React Aria makes each toast an `alertdialog` inside a live
49
+ * `region` for that reason, so the announcement still happens and the button
50
+ * can still be reached.
51
+ */
52
+ export function ToastHost({ children }) {
53
+ const queue = useMemo(() => new ToastQueue({ maxVisibleToasts: 4 }), []);
54
+ /* What gets read aloud, and it is not the toast.
55
+
56
+ The old region was `aria-live="polite"` around the toasts themselves, so a
57
+ screen reader heard "Settings saved" the moment it appeared. React Aria's
58
+ region is a landmark instead -- reachable, labelled "1 notification." --
59
+ and after a push there is no live region anywhere in the document: that
60
+ was measured, not assumed, and it would have shipped "saved", "copied" and
61
+ "the download failed" in silence to anyone not looking.
62
+
63
+ So the text is mirrored into two visually-hidden live regions of our own:
64
+ polite for the ordinary case and assertive for a failure, the same
65
+ distinction `role="status"` and `role="alert"` used to draw. An alert
66
+ interrupts whatever is being read, right for bad news and rude for
67
+ "copied". */
68
+ const [polite, setPolite] = useState('');
69
+ const [assertive, setAssertive] = useState('');
70
+ const push = useMemo(() => (text, tone = 'info') => {
71
+ queue.add({ text, tone }, { timeout: LINGER });
72
+ const say = tone === 'bad' ? setAssertive : setPolite;
73
+ // Cleared and re-set, so the same message twice is announced twice.
74
+ say('');
75
+ requestAnimationFrame(() => say(text));
76
+ }, [queue]);
77
+ return (_jsxs(Ctx.Provider, { value: push, children: [children, _jsx("div", { className: "sr-only", "aria-live": "polite", "aria-atomic": "true", children: polite }), _jsx("div", { className: "sr-only", "aria-live": "assertive", "aria-atomic": "true", children: assertive }), _jsx(ToastRegion, { queue: queue, className: "toasts", children: ({ toast }) => (_jsxs(AriaToast, { toast: toast, className: `toast toast-${toast.content.tone}`, children: [_jsx(Icon, { name: MARK[toast.content.tone], size: 16, className: "toast-mark" }), _jsx(ToastContent, { className: "grow", children: _jsx(Text, { slot: "title", children: toast.content.text }) }), _jsx(Button, { slot: "close", className: "ghost size-sm", "aria-label": "Dismiss", children: _jsx(Icon, { name: "close", size: 14 }) })] })) })] }));
78
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A setting that takes effect as it moves.
3
+ *
4
+ * **A checkbox and a switch are not the same control, and the difference is not
5
+ * visual.** A checkbox collects an answer: you tick it, and it applies when you
6
+ * press Save. A switch *is* the action — flipping it changes the thing, now,
7
+ * and there is nothing to confirm afterwards. That is why a switch reads as on
8
+ * or off rather than ticked or blank: the two states are both settled, and
9
+ * neither is a draft.
10
+ *
11
+ * Six settings in this app saved the moment they changed and were all drawn as
12
+ * tick boxes: whether an applet is enabled, whether it reaches the internet,
13
+ * whether a tool is granted, whether a server is trusted. Every one of them
14
+ * promised a Save button that does not
15
+ * exist. The header's status panel (gone since 2026-09-03) was the only one
16
+ * that had it right, in a comment nobody else read.
17
+ *
18
+ * **`role="switch"`, so it is announced as one.** A screen reader says "on" and
19
+ * "off" instead of "checked" and "not checked" — which is the same distinction
20
+ * in words, and the only signal a non-visual reader gets that pressing it does
21
+ * something immediately.
22
+ *
23
+ * **The label is the hit area.** A 30×18 target fails the minimum on its own,
24
+ * and reaching for the words is what people do anyway.
25
+ *
26
+ * **One switch, or several checkboxes.** A lone binary setting is a switch. A
27
+ * set you pick from is a list of checkboxes — fifteen switches in a column read
28
+ * as fifteen unrelated settings rather than one choice with fifteen parts. See
29
+ * `Checkbox`.
30
+ */
31
+ export default function Toggle({ label, hint, checked, onChange, disabled, said, labelHidden, size, className, }: {
32
+ label: React.ReactNode;
33
+ /** What it does, or what turning it off costs. Under the label, in the same
34
+ * column, so a long explanation does not push the switch off its row. */
35
+ hint?: React.ReactNode;
36
+ checked: boolean;
37
+ /** Returning a promise moves the knob at once and marks the row busy until it
38
+ * settles: a rejection puts the knob back, a resolution holds it until
39
+ * `checked` catches up. A plain return leaves everything to the caller. */
40
+ onChange: (on: boolean) => void | Promise<void>;
41
+ disabled?: boolean;
42
+ /** The state in words, beside the switch.
43
+ *
44
+ * Only where nothing else already says it. A switch that is a `Card`'s
45
+ * action has a title above it naming what it governs and a description
46
+ * saying what happens either way — a third word beside the switch is the
47
+ * same fact a third time, in the one place it does not fit. The header's
48
+ * status panel kept it because there the row was the whole component; it
49
+ * is gone, and the gallery's specimen is the one caller left. */
50
+ said?: React.ReactNode;
51
+ labelHidden?: boolean;
52
+ /** The same three the rest of the controls take. `sm` for a switch in a
53
+ * toolbar or a dense row; `lg` where it is the only thing on the screen. */
54
+ size?: 'sm' | 'md' | 'lg';
55
+ className?: string;
56
+ }): import("react").JSX.Element;