@wtfalch/design 0.7.0 → 0.8.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.
@@ -1,21 +1,5 @@
1
- export interface FieldWiring {
2
- id: string;
3
- 'aria-describedby': string | undefined;
4
- 'aria-invalid': boolean | undefined;
5
- /** The label element's own id, for a control a `<label for>` cannot name.
6
- *
7
- * `htmlFor` is enough for an `<input>`, which is what almost every caller
8
- * wraps. It is not enough for `Select`, or for anything else built on a
9
- * `<button>`: a button takes its accessible name from its *contents*, and
10
- * a `<label for>` pointing at one is ignored by the name computation. A
11
- * caller that put a `Select` in a `Field` got a label on screen and a
12
- * control announcing only its current value — and the workaround was an
13
- * `aria-label` repeating the label string, which is two literals and two
14
- * chances to drift apart. That drift is exactly the bug this component
15
- * exists to prevent, so: pass this as `aria-labelledby` and there is one
16
- * string in one place. */
17
- labelId: string;
18
- }
1
+ import { type FieldWiring } from './fieldWiring.js';
2
+ export type { FieldWiring } from './fieldWiring.js';
19
3
  export default function Field({ label, hint, error, required, children, labelHidden, layout, className, }: {
20
4
  /** What the field is. Always given -- there is no unlabelled case, only
21
5
  * fields whose label is hidden. */
@@ -35,6 +19,25 @@ export default function Field({ label, hint, error, required, children, labelHid
35
19
  * form to fill in. Same element, same wiring, same guarantees -- the only
36
20
  * thing that changes is where the label sits. */
37
21
  layout?: 'stack' | 'row';
38
- children: (field: FieldWiring) => React.ReactNode;
22
+ /**
23
+ * The control.
24
+ *
25
+ * **Elements, or a function.** Elements are the shape to reach for: a
26
+ * function cannot cross the server boundary, so a render prop made every
27
+ * page with a form a client component whether or not it needed to be, and
28
+ * all three apps' `design.ts` say so in the same sentence. Plain children
29
+ * read the wiring from context instead, and the package's own controls
30
+ * apply it when the caller has not named an `id`:
31
+ *
32
+ * <Field label="Region" hint="Cannot be changed later">
33
+ * <Select name="region" defaultValue="sg">…</Select>
34
+ * </Field>
35
+ *
36
+ * The function form stays, unchanged and not deprecated. It is still the
37
+ * answer for a control the package does not own, or for a caller that
38
+ * needs the ids for something else -- a `<datalist>` to point at, a label
39
+ * rendered somewhere the provider does not reach.
40
+ */
41
+ children: React.ReactNode | ((field: FieldWiring) => React.ReactNode);
39
42
  className?: string;
40
43
  }): import("react").JSX.Element;
@@ -38,18 +38,20 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
38
38
  * are breaking it.
39
39
  */
40
40
  import { useId } from 'react';
41
+ import { FieldWiringContext } from './fieldWiring.js';
41
42
  export default function Field({ label, hint, error, required, children, labelHidden, layout = 'stack', className, }) {
42
43
  const id = useId();
43
44
  const hintId = `${id}-hint`;
44
45
  const errorId = `${id}-error`;
45
46
  const labelId = `${id}-label`;
46
- return (_jsxs("div", { className: `field field-${layout}-layout${error ? ' field-bad' : ''}${className ? ` ${className}` : ''}`, children: [_jsxs("label", { id: labelId, className: labelHidden ? 'sr-only' : 'field-label', htmlFor: id, children: [label, required && (_jsxs("span", { className: "field-required", "aria-label": "required", children: [' ', "*"] }))] }), hint && (_jsx("p", { className: "field-hint", id: hintId, children: hint })), children({
47
- id,
48
- labelId,
49
- /* Both, in reading order, when both are there. A field that has a rule
50
- and has broken it needs to say the rule too -- "must be a URL" on its
51
- own does not tell you what shape of URL. */
52
- 'aria-describedby': [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined,
53
- 'aria-invalid': error ? true : undefined,
54
- }), error && (_jsx("p", { className: "field-error", id: errorId, role: "alert", children: error }))] }));
47
+ const wiring = {
48
+ id,
49
+ labelId,
50
+ /* Both, in reading order, when both are there. A field that has a rule
51
+ and has broken it needs to say the rule too -- "must be a URL" on its
52
+ own does not tell you what shape of URL. */
53
+ 'aria-describedby': [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined,
54
+ 'aria-invalid': error ? true : undefined,
55
+ };
56
+ return (_jsxs("div", { className: `field field-${layout}-layout${error ? ' field-bad' : ''}${className ? ` ${className}` : ''}`, children: [_jsxs("label", { id: labelId, className: labelHidden ? 'sr-only' : 'field-label', htmlFor: id, children: [label, required && (_jsxs("span", { className: "field-required", "aria-label": "required", children: [' ', "*"] }))] }), hint && (_jsx("p", { className: "field-hint", id: hintId, children: hint })), typeof children === 'function' ? (children(wiring)) : (_jsx(FieldWiringContext.Provider, { value: wiring, children: children })), error && (_jsx("p", { className: "field-error", id: errorId, role: "alert", children: error }))] }));
55
57
  }
@@ -1,3 +1,4 @@
1
+ import { type IconName } from './Icon.js';
1
2
  export interface Props extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'className'> {
2
3
  /** The same three every control takes, so a row of mixed controls lines up
3
4
  * without anyone measuring. */
@@ -8,6 +9,15 @@ export interface Props extends Omit<React.InputHTMLAttributes<HTMLInputElement>,
8
9
  /** Full width of whatever holds it. Inputs already are, by default; this is
9
10
  * for the `size`d ones, which are not. */
10
11
  block?: boolean;
12
+ /** A glyph inside the left edge. Decorative: it is `aria-hidden`, because
13
+ * a magnifier beside a field called "Search" is the label said twice. */
14
+ icon?: IconName;
15
+ /** Empty it. Given, a clear button appears inside the right edge whenever
16
+ * the field has a value; the caller owns the value and does the clearing. */
17
+ onClear?: () => void;
18
+ /** Inside the right edge, before the clear button. A `Kbd` saying what
19
+ * opens this, which is what the mail client's `.mail-search-key` was. */
20
+ trailing?: React.ReactNode;
11
21
  className?: string;
12
22
  }
13
23
  declare const Input: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLInputElement>>;
@@ -48,7 +48,18 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
48
48
  import { forwardRef, useState } from 'react';
49
49
  import { ToggleButton } from 'react-aria-components';
50
50
  import Icon from './Icon.js';
51
- const Input = forwardRef(function Input({ size = 'md', mono, block, className, type = 'text', ...rest }, ref) {
51
+ import { useFieldWiring } from './fieldWiring.js';
52
+ const Input = forwardRef(function Input({ size = 'md', mono, block, className, type = 'text', icon, onClear, trailing, ...rest }, ref) {
53
+ /* What the `Field` above wired, when the caller did not thread it by hand.
54
+ Explicit props win: a caller that named an `id` meant that id, and a
55
+ render-prop `Field` spreading its wiring is passing the same values in
56
+ anyway. This is the fallback, not an override. */
57
+ const field = useFieldWiring();
58
+ const wired = {
59
+ id: rest.id ?? field?.id,
60
+ 'aria-describedby': rest['aria-describedby'] ?? field?.['aria-describedby'],
61
+ 'aria-invalid': rest['aria-invalid'] ?? field?.['aria-invalid'],
62
+ };
52
63
  const classes = [
53
64
  size === 'md' ? '' : `size-${size}`,
54
65
  mono ? 'mono' : '',
@@ -61,12 +72,22 @@ const Input = forwardRef(function Input({ size = 'md', mono, block, className, t
61
72
  run time does not remount it -- a hook count that changes with a prop is a
62
73
  React error, and a remount would drop the caret. */
63
74
  const [shown, setShown] = useState(false);
75
+ const glyph = { sm: 14, md: 16, lg: 18 }[size];
64
76
  if (type !== 'password') {
65
- return _jsx("input", { ref: ref, type: type, className: classes || undefined, ...rest });
77
+ const box = (_jsx("input", { ref: ref, type: type, className: classes || undefined, ...rest, ...wired }));
78
+ if (!icon && !onClear && !trailing)
79
+ return box;
80
+ /* Something to clear, rather than something that could be cleared: a
81
+ clear button over an empty field is a control that does nothing. Both
82
+ shapes of value are checked because either may be the caller's. */
83
+ const filled = rest.value !== undefined
84
+ ? String(rest.value).length > 0
85
+ : String(rest.defaultValue ?? '').length > 0;
86
+ return (_jsxs("span", { className: `adorned adorned-${size}${block ? ' block' : ''}${icon ? ' adorned-icon' : ''}`, children: [icon && _jsx(Icon, { name: icon, size: glyph, className: "adorned-mark", "aria-hidden": true }), box, (trailing || (onClear && filled)) && (_jsxs("span", { className: "adorned-end", children: [trailing, onClear && filled && (_jsx("button", { type: "button", className: "icon-btn ghost adorned-clear", "aria-label": "Clear", onClick: onClear, disabled: rest.disabled, children: _jsx(Icon, { name: "close", size: glyph }) }))] }))] }));
66
87
  }
67
88
  return (_jsxs("span", { className: `secret secret-${size}${block ? ' block' : ''}`, children: [_jsx("input", { ref: ref, type: shown ? 'text' : 'password', className: classes || undefined,
68
89
  /* Only while revealed: as a password these are moot, and setting them
69
90
  on a password field makes some browsers stop offering to fill it. */
70
- autoCapitalize: shown ? 'off' : undefined, autoCorrect: shown ? 'off' : undefined, spellCheck: shown ? false : undefined, ...rest }), _jsx(ToggleButton, { className: `icon-btn ghost secret-eye${size === 'md' ? '' : ` size-${size}`}`, "aria-label": "Show password", isSelected: shown, onChange: setShown, isDisabled: rest.disabled, children: _jsx(Icon, { name: shown ? 'eye-off' : 'eye', size: { sm: 14, md: 16, lg: 18 }[size] }) })] }));
91
+ autoCapitalize: shown ? 'off' : undefined, autoCorrect: shown ? 'off' : undefined, spellCheck: shown ? false : undefined, ...rest, ...wired }), _jsx(ToggleButton, { className: `icon-btn ghost secret-eye${size === 'md' ? '' : ` size-${size}`}`, "aria-label": "Show password", isSelected: shown, onChange: setShown, isDisabled: rest.disabled, children: _jsx(Icon, { name: shown ? 'eye-off' : 'eye', size: glyph }) })] }));
71
92
  });
72
93
  export default Input;
@@ -12,6 +12,8 @@ interface Props {
12
12
  'aria-label'?: string;
13
13
  'aria-labelledby'?: string;
14
14
  disabled?: boolean;
15
+ 'aria-describedby'?: string;
16
+ 'aria-invalid'?: boolean;
15
17
  /** What the form submits this under. Without it there is nothing to post,
16
18
  * which is what sent callers back to a mirrored hidden input. */
17
19
  name?: string;
@@ -29,5 +31,5 @@ interface Props {
29
31
  /** Something to the right of an option -- a play button beside a voice. */
30
32
  aside?: (value: string) => ReactNode;
31
33
  }
32
- export default function Select({ block, size, className, children, value, defaultValue, onChange, disabled, name, form, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, }: Props): import("react").JSX.Element;
34
+ export default function Select({ block, size, className, children, value, defaultValue, onChange, disabled, name, form, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: Props): import("react").JSX.Element;
33
35
  export {};
@@ -29,13 +29,26 @@ function readOptions(children) {
29
29
  free when that element was a `<select>` and is wrong now that it is a
30
30
  `<button>` -- `onCopy` alone is typed against a different element. Call sites
31
31
  pass five things between them, so five is what this takes. */
32
+ import { useFieldWiring } from './fieldWiring.js';
32
33
  import { Select as AriaSelect, Button, ListBox, ListBoxItem, Popover, SelectValue, } from 'react-aria-components';
33
- export default function Select({ block = false, size, className, children, value, defaultValue, onChange, disabled, name, form, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, }) {
34
+ export default function Select({ block = false, size, className, children, value, defaultValue, onChange, disabled, name, form, id, title, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, aside, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
34
35
  const options = readOptions(children);
35
- /* `title`, set on the element: React Aria's `Button` takes `id` and the
36
- `aria-*` labelling props and filters the rest, the same `filterDOMProps`
37
- that dropped `aria-busy` on `Button` and `aria-modal` on `Modal`. */
36
+ /* A `Select` is a `<button>`, and a button takes its accessible name from
37
+ its contents -- so `Field`'s `htmlFor` does not name it and the caller
38
+ used to repeat the label in an `aria-label`. Reading the wiring here is
39
+ what removes the second literal. Explicit props win throughout. */
40
+ const field = useFieldWiring();
41
+ const wiredId = id ?? field?.id;
42
+ const wiredLabelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : field?.labelId);
43
+ /* `title` and `aria-invalid`, set on the element: React Aria's `Button`
44
+ takes `id` and the *labelling* aria props -- label, labelledby,
45
+ describedby, details -- and filters the rest, the same `filterDOMProps`
46
+ that dropped `aria-busy` on `Button` and `aria-modal` on `Modal`.
47
+ `aria-invalid` is not on that list, so passing it as a prop typechecks,
48
+ reads correctly and does nothing at all. That is the third time this
49
+ has caught someone, which is why it is written down here too. */
38
50
  const control = useRef(null);
51
+ const invalid = ariaInvalid ?? field?.['aria-invalid'];
39
52
  useEffect(() => {
40
53
  const el = control.current;
41
54
  if (!el)
@@ -45,6 +58,15 @@ export default function Select({ block = false, size, className, children, value
45
58
  else
46
59
  el.removeAttribute('title');
47
60
  }, [title]);
61
+ useEffect(() => {
62
+ const el = control.current;
63
+ if (!el)
64
+ return;
65
+ if (invalid)
66
+ el.setAttribute('aria-invalid', 'true');
67
+ else
68
+ el.removeAttribute('aria-invalid');
69
+ }, [invalid]);
48
70
  /* React Aria owns what was 13 KB of hand-rolled behaviour: the popover is
49
71
  positioned against the button and flips when the edge is near, which the
50
72
  measured-rectangle `top`/`left` could not; typeahead, Home and End, the
@@ -59,5 +81,5 @@ export default function Select({ block = false, size, className, children, value
59
81
  return (_jsxs(AriaSelect, { className: `sel${block ? ' block' : ''}${className ? ` ${className}` : ''}`, selectedKey: value !== undefined ? String(value) : undefined, defaultSelectedKey: defaultValue !== undefined ? String(defaultValue) : (options[0]?.value ?? undefined), onSelectionChange: (key) => {
60
82
  if (key !== null)
61
83
  onChange?.({ target: { value: String(key) } });
62
- }, isDisabled: disabled, name: name, form: form, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, children: [_jsxs(Button, { ref: control, id: id, className: `sel-control${size ? ` size-${size}` : ''}`, children: [_jsx(SelectValue, { className: "sel-value", children: ({ selectedText, defaultChildren }) => selectedText ?? defaultChildren }), _jsx("svg", { className: "sel-caret", viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "m6 9 6 6 6-6" }) })] }), _jsx(Popover, { className: "sel-list", placement: "bottom start", offset: 4, maxHeight: 280, children: _jsx(ListBox, { className: "sel-listbox", children: options.map((o) => (_jsxs(ListBoxItem, { id: o.value, textValue: o.text, isDisabled: o.disabled, className: "sel-item", children: [_jsx("span", { className: "sel-item-label", children: o.label }), _jsx("svg", { className: "sel-tick", viewBox: "0 0 24 24", width: "13", height: "13", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", d: "m5 13 4 4 10-10" }) }), aside?.(o.value)] }, o.value))) }) })] }));
84
+ }, isDisabled: disabled, name: name, form: form, "aria-label": ariaLabel, "aria-labelledby": wiredLabelledBy, children: [_jsxs(Button, { ref: control, id: wiredId, "aria-describedby": ariaDescribedBy ?? field?.['aria-describedby'], className: `sel-control${size ? ` size-${size}` : ''}`, children: [_jsx(SelectValue, { className: "sel-value", children: ({ selectedText, defaultChildren }) => selectedText ?? defaultChildren }), _jsx("svg", { className: "sel-caret", viewBox: "0 0 24 24", width: "14", height: "14", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "m6 9 6 6 6-6" }) })] }), _jsx(Popover, { className: "sel-list", placement: "bottom start", offset: 4, maxHeight: 280, children: _jsx(ListBox, { className: "sel-listbox", children: options.map((o) => (_jsxs(ListBoxItem, { id: o.value, textValue: o.text, isDisabled: o.disabled, className: "sel-item", children: [_jsx("span", { className: "sel-item-label", children: o.label }), _jsx("svg", { className: "sel-tick", viewBox: "0 0 24 24", width: "13", height: "13", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", d: "m5 13 4 4 10-10" }) }), aside?.(o.value)] }, o.value))) }) })] }));
63
85
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The frame a signed-in surface stands in: a header, and the page under it.
3
+ *
4
+ * Three apps have one, all called `shell.tsx`, all the same six elements. The
5
+ * differences were a wider variant in manage and where each put the theme
6
+ * control, which is a prop and a slot, not three components.
7
+ *
8
+ * **It gates nothing, and that is worth stating because a frame looks like
9
+ * the place to.** A layout cannot reliably stop the page beneath it
10
+ * rendering, so every page decides for itself who may see it. Putting a check
11
+ * here would read as protection and provide none. All three app docblocks say
12
+ * this; it moves with the component.
13
+ *
14
+ * **A `<header>` and a `<main>`, so the landmarks exist.** Hand-built
15
+ * versions reached for `<div>` often enough that this is the second reason to
16
+ * have one: "skip to content" and a screen reader's landmark list both come
17
+ * from the elements, and neither is visible to the person who wrote the div.
18
+ *
19
+ * Server-renderable: no hooks, no handlers. `who` and `brand` are slots, so
20
+ * the client parts an app needs -- a user menu, the theme switch -- are the
21
+ * app's to pass and stay its own client boundaries.
22
+ */
23
+ export default function Shell({ brand, who, nav, wide, children, className, }: {
24
+ /** Top left: the product's name or mark, usually a link home. */
25
+ brand?: React.ReactNode;
26
+ /** Top right: who is signed in, the theme, whatever else is chrome. */
27
+ who?: React.ReactNode;
28
+ /** A row under the header, for an app whose pages hang off a context --
29
+ * manage's organisation nav is this. */
30
+ nav?: React.ReactNode;
31
+ /** A wider measure, for a page that is a table rather than a form. */
32
+ wide?: boolean;
33
+ children: React.ReactNode;
34
+ className?: string;
35
+ }): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The frame a signed-in surface stands in: a header, and the page under it.
4
+ *
5
+ * Three apps have one, all called `shell.tsx`, all the same six elements. The
6
+ * differences were a wider variant in manage and where each put the theme
7
+ * control, which is a prop and a slot, not three components.
8
+ *
9
+ * **It gates nothing, and that is worth stating because a frame looks like
10
+ * the place to.** A layout cannot reliably stop the page beneath it
11
+ * rendering, so every page decides for itself who may see it. Putting a check
12
+ * here would read as protection and provide none. All three app docblocks say
13
+ * this; it moves with the component.
14
+ *
15
+ * **A `<header>` and a `<main>`, so the landmarks exist.** Hand-built
16
+ * versions reached for `<div>` often enough that this is the second reason to
17
+ * have one: "skip to content" and a screen reader's landmark list both come
18
+ * from the elements, and neither is visible to the person who wrote the div.
19
+ *
20
+ * Server-renderable: no hooks, no handlers. `who` and `brand` are slots, so
21
+ * the client parts an app needs -- a user menu, the theme switch -- are the
22
+ * app's to pass and stay its own client boundaries.
23
+ */
24
+ export default function Shell({ brand, who, nav, wide = false, children, className, }) {
25
+ return (_jsxs("div", { className: ['shell', className].filter(Boolean).join(' '), children: [_jsxs("header", { className: "shell-head", children: [_jsxs("div", { className: `shell-head-inner${wide ? ' shell-wide' : ''}`, children: [brand, who && _jsx("div", { className: "shell-who", children: who })] }), nav && _jsx("div", { className: `shell-nav${wide ? ' shell-wide' : ''}`, children: nav })] }), _jsx("main", { className: `shell-main${wide ? ' shell-wide' : ''}`, children: children })] }));
26
+ }
@@ -8,15 +8,25 @@ import { jsx as _jsx } from "react/jsx-runtime";
8
8
  * this component keeps it by rendering the element.
9
9
  *
10
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.
11
+ * stylesheet keeps working, and its `id`, `aria-describedby` and
12
+ * `aria-invalid` come from the `Field` above it -- handed in by a render
13
+ * prop, or read from context when the caller passed plain children. On its
14
+ * own it needs an `aria-label`, for the same reason `Input` does.
14
15
  */
15
16
  import { forwardRef } from 'react';
17
+ import { useFieldWiring } from './fieldWiring.js';
16
18
  const Textarea = forwardRef(function Textarea({ size = 'md', mono, className, ...rest }, ref) {
17
19
  const classes = [size === 'md' ? '' : `size-${size}`, mono ? 'mono' : '', className ?? '']
18
20
  .filter(Boolean)
19
21
  .join(' ');
20
- return _jsx("textarea", { ref: ref, className: classes || undefined, ...rest });
22
+ /* Explicit props win; this is the fallback. After `rest` in the spread,
23
+ because a key present with an undefined value still overwrites. */
24
+ const field = useFieldWiring();
25
+ const wired = {
26
+ id: rest.id ?? field?.id,
27
+ 'aria-describedby': rest['aria-describedby'] ?? field?.['aria-describedby'],
28
+ 'aria-invalid': rest['aria-invalid'] ?? field?.['aria-invalid'],
29
+ };
30
+ return _jsx("textarea", { ref: ref, className: classes || undefined, ...rest, ...wired });
21
31
  });
22
32
  export default Textarea;
@@ -0,0 +1,19 @@
1
+ import type { Product } from '../products/index.js';
2
+ import type { BrandName } from './brandMarks.js';
3
+ export default function ThemeSwitch({ product, only, label, labelHidden, size, storageKey, onChange, className, }: {
4
+ /** Whose themes to offer. A registered name, or a `Product` from
5
+ * `defineProduct` for an app with its own palette. */
6
+ product: BrandName | Product;
7
+ /** A subset of the product's themes, in the order to show them. */
8
+ only?: readonly string[];
9
+ label?: string;
10
+ /** The label becomes the accessible name and is not drawn, for a header
11
+ * where the control's meaning is obvious from its contents. */
12
+ labelHidden?: boolean;
13
+ size?: 'sm' | 'md' | 'lg';
14
+ storageKey?: string;
15
+ /** For an app that has something else to update. The theme is already
16
+ * applied by the time this runs. */
17
+ onChange?: (theme: string) => void;
18
+ className?: string;
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ /* Client, because this module's own JSX attaches handlers or calls hooks. */
4
+ /**
5
+ * The control that changes the theme.
6
+ *
7
+ * All three apps have one and the first forty lines are the same in each: the
8
+ * guarded `localStorage` read, the guarded write, the assignment to
9
+ * `document.documentElement.dataset.theme`. What differed was the wrapper and
10
+ * the label -- one says "Appearance", one says "Theme" -- which is drift, not
11
+ * design.
12
+ *
13
+ * **The choice lives in the browser, not the account.** It is a preference
14
+ * about this screen rather than a fact about the person: the same account on
15
+ * a laptop in a bright room and a phone at night wants two answers. That is
16
+ * also why it needs no server round trip and no session.
17
+ *
18
+ * **The default is not stored.** A browser that never chose follows whatever
19
+ * the default becomes, so changing a product's default reaches everyone who
20
+ * never expressed an opinion, rather than only new visitors.
21
+ *
22
+ * **It writes `data-theme` and does not call `applyTheme`.** The palettes are
23
+ * already on the page as CSS from `productCss`; the attribute is the switch.
24
+ * `applyTheme` writes custom properties one by one and is for the case with
25
+ * no stylesheet to lean on.
26
+ *
27
+ * **`aria-labelledby`, or an `aria-label`, but never both and never neither.**
28
+ * The apps passed `aria-label="Appearance"` beside a visible "Appearance"
29
+ * span, which is the same string written twice -- exactly the drift `Field`
30
+ * exists to stop. Given `label`, this renders it and points the control at
31
+ * it; given `labelHidden`, the string becomes the accessible name and is not
32
+ * drawn.
33
+ */
34
+ import { useEffect, useId, useState } from 'react';
35
+ import { THEME_STORAGE_KEY, storedTheme, themeChoices } from '../themes/choice.js';
36
+ import Select from './Select.js';
37
+ export default function ThemeSwitch({ product, only, label = 'Appearance', labelHidden = false, size = 'sm', storageKey = THEME_STORAGE_KEY, onChange, className, }) {
38
+ const choices = themeChoices(product, only);
39
+ const labelId = useId();
40
+ /* The server cannot know what this browser stored, so the first render has
41
+ to match what the server sent -- the attribute the blocking script wrote
42
+ is on `<html>` already, and reading `localStorage` during render would
43
+ be a hydration mismatch. The effect catches the control up. */
44
+ const [choice, setChoice] = useState(() => choices[0]?.id ?? '');
45
+ useEffect(() => {
46
+ setChoice(storedTheme(product, { only, storageKey }));
47
+ // The product object is rebuilt per render when passed inline; its
48
+ // identity is not the dependency, what it resolves to is.
49
+ }, [product, only, storageKey]);
50
+ const choose = (next) => {
51
+ if (!choices.some((c) => c.id === next))
52
+ return;
53
+ setChoice(next);
54
+ try {
55
+ localStorage.setItem(storageKey, next);
56
+ }
57
+ catch {
58
+ /* A browser blocking site data still gets the theme, just not the
59
+ memory of it. Failing the change over the storage would be worse. */
60
+ }
61
+ document.documentElement.dataset.theme = next;
62
+ onChange?.(next);
63
+ };
64
+ return (_jsxs("div", { className: ['theme-switch', className].filter(Boolean).join(' '), children: [_jsx("span", { className: "theme-switch-label", id: labelId, hidden: labelHidden, children: label }), _jsx(Select, { "aria-labelledby": labelHidden ? undefined : labelId, "aria-label": labelHidden ? label : undefined, size: size, value: choice, onChange: (e) => choose(e.target.value), children: choices.map((c) => (_jsx("option", { value: c.id, children: c.label }, c.id))) })] }));
65
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What a `Field` tells the control inside it.
3
+ *
4
+ * Its own module for the reason `iconNames.ts` and `tourMarker.ts` are:
5
+ * `fastRefresh.test.ts` holds every component module to exporting its
6
+ * component and nothing else, because a module exporting a value beside a
7
+ * component loses its Fast Refresh boundary and editing it re-runs every
8
+ * importer instead of swapping the component in place.
9
+ *
10
+ * The context exists so `Field`'s children can be elements rather than a
11
+ * function. A function cannot cross the server boundary, so the render prop
12
+ * made every page with a form a client component whether or not it needed to
13
+ * be -- which is the line all three consuming apps have in their
14
+ * `design.ts`.
15
+ */
16
+ export interface FieldWiring {
17
+ id: string;
18
+ 'aria-describedby': string | undefined;
19
+ 'aria-invalid': boolean | undefined;
20
+ /** The label element's own id, for a control a `<label for>` cannot name.
21
+ *
22
+ * `htmlFor` is enough for an `<input>`, which is what almost every caller
23
+ * wraps. It is not enough for `Select`, or for anything else built on a
24
+ * `<button>`: a button takes its accessible name from its *contents*, and
25
+ * a `<label for>` pointing at one is ignored by the name computation. A
26
+ * caller that put a `Select` in a `Field` got a label on screen and a
27
+ * control announcing only its current value — and the workaround was an
28
+ * `aria-label` repeating the label string, which is two literals and two
29
+ * chances to drift apart. That drift is exactly the bug `Field` exists to
30
+ * prevent, so: pass this as `aria-labelledby` and there is one string. */
31
+ labelId: string;
32
+ }
33
+ /** `null` outside a `Field`, so a control can tell "no field around me" from
34
+ * "a field that wired me with nothing". */
35
+ export declare const FieldWiringContext: import("react").Context<FieldWiring | null>;
36
+ /**
37
+ * What the `Field` above this control wired, if there is one.
38
+ *
39
+ * For the package's own controls, which apply it when the caller has not
40
+ * named an `id` themselves. A consumer building its own control can use it
41
+ * for the same reason: it is how a control gets a label without the caller
42
+ * threading four attributes by hand.
43
+ */
44
+ export declare function useFieldWiring(): FieldWiring | null;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What a `Field` tells the control inside it.
3
+ *
4
+ * Its own module for the reason `iconNames.ts` and `tourMarker.ts` are:
5
+ * `fastRefresh.test.ts` holds every component module to exporting its
6
+ * component and nothing else, because a module exporting a value beside a
7
+ * component loses its Fast Refresh boundary and editing it re-runs every
8
+ * importer instead of swapping the component in place.
9
+ *
10
+ * The context exists so `Field`'s children can be elements rather than a
11
+ * function. A function cannot cross the server boundary, so the render prop
12
+ * made every page with a form a client component whether or not it needed to
13
+ * be -- which is the line all three consuming apps have in their
14
+ * `design.ts`.
15
+ */
16
+ import { createContext, useContext } from 'react';
17
+ /** `null` outside a `Field`, so a control can tell "no field around me" from
18
+ * "a field that wired me with nothing". */
19
+ export const FieldWiringContext = createContext(null);
20
+ /**
21
+ * What the `Field` above this control wired, if there is one.
22
+ *
23
+ * For the package's own controls, which apply it when the caller has not
24
+ * named an `id` themselves. A consumer building its own control can use it
25
+ * for the same reason: it is how a control gets a label without the caller
26
+ * threading four attributes by hand.
27
+ */
28
+ export function useFieldWiring() {
29
+ return useContext(FieldWiringContext);
30
+ }
package/dist/index.d.ts CHANGED
@@ -36,7 +36,10 @@ export { DangerAction, default as DangerZone } from './components/DangerZone.js'
36
36
  export { default as Dialog } from './components/Dialog.js';
37
37
  export { default as Empty } from './components/Empty.js';
38
38
  export { default as Field } from './components/Field.js';
39
- export type { FieldWiring } from './components/Field.js';
39
+ /** The wiring a `Field` hands its control, and the hook that reads it —
40
+ * its own module, so `Field.tsx` keeps its Fast Refresh boundary. */
41
+ export { useFieldWiring } from './components/fieldWiring.js';
42
+ export type { FieldWiring } from './components/fieldWiring.js';
40
43
  export { default as Icon } from './components/Icon.js';
41
44
  /** A person in one line -- initials, name, address -- and the two pure
42
45
  * functions behind the disc, exported so a caller colouring something else
@@ -72,6 +75,8 @@ export { default as ScrollArea } from './components/ScrollArea.js';
72
75
  export type { Props as ScrollAreaProps } from './components/ScrollArea.js';
73
76
  export { default as Select } from './components/Select.js';
74
77
  export { default as SizeGrid } from './components/SizeGrid.js';
78
+ /** The frame a signed-in surface stands in. Gates nothing, on purpose. */
79
+ export { default as Shell } from './components/Shell.js';
75
80
  export { default as Skeleton } from './components/Skeleton.js';
76
81
  /** One figure, with what it counts over it. Three apps drew this before it
77
82
  * was here. */
@@ -86,6 +91,10 @@ export type { Column } from './components/Table.js';
86
91
  export { default as Tabs } from './components/Tabs.js';
87
92
  export type { Tab, TabGroup } from './components/Tabs.js';
88
93
  export { default as Textarea } from './components/Textarea.js';
94
+ /** The theme control, and the blocking script that beats it to the paint. */
95
+ export { default as ThemeSwitch } from './components/ThemeSwitch.js';
96
+ export { THEME_STORAGE_KEY, themeChoiceScript, themeChoices, storedTheme } from './themes/choice.js';
97
+ export type { ThemeChoice } from './themes/choice.js';
89
98
  export type { Props as TextareaProps } from './components/Textarea.js';
90
99
  /** `ToastHost` nests: one inside another renders through, so a package can
91
100
  * wrap itself and still leave an application with a single region. */
package/dist/index.js CHANGED
@@ -33,6 +33,9 @@ export { DangerAction, default as DangerZone } from './components/DangerZone.js'
33
33
  export { default as Dialog } from './components/Dialog.js';
34
34
  export { default as Empty } from './components/Empty.js';
35
35
  export { default as Field } from './components/Field.js';
36
+ /** The wiring a `Field` hands its control, and the hook that reads it —
37
+ * its own module, so `Field.tsx` keeps its Fast Refresh boundary. */
38
+ export { useFieldWiring } from './components/fieldWiring.js';
36
39
  export { default as Icon } from './components/Icon.js';
37
40
  /** A person in one line -- initials, name, address -- and the two pure
38
41
  * functions behind the disc, exported so a caller colouring something else
@@ -64,6 +67,8 @@ export { Row, Rows } from './components/Rows.js';
64
67
  export { default as ScrollArea } from './components/ScrollArea.js';
65
68
  export { default as Select } from './components/Select.js';
66
69
  export { default as SizeGrid } from './components/SizeGrid.js';
70
+ /** The frame a signed-in surface stands in. Gates nothing, on purpose. */
71
+ export { default as Shell } from './components/Shell.js';
67
72
  export { default as Skeleton } from './components/Skeleton.js';
68
73
  /** One figure, with what it counts over it. Three apps drew this before it
69
74
  * was here. */
@@ -75,6 +80,9 @@ export { default as SplitPane } from './components/SplitPane.js';
75
80
  export { default as Table } from './components/Table.js';
76
81
  export { default as Tabs } from './components/Tabs.js';
77
82
  export { default as Textarea } from './components/Textarea.js';
83
+ /** The theme control, and the blocking script that beats it to the paint. */
84
+ export { default as ThemeSwitch } from './components/ThemeSwitch.js';
85
+ export { THEME_STORAGE_KEY, themeChoiceScript, themeChoices, storedTheme } from './themes/choice.js';
78
86
  /** `ToastHost` nests: one inside another renders through, so a package can
79
87
  * wrap itself and still leave an application with a single region. */
80
88
  export { ToastHost, useHasToastHost, useToast } from './components/Toast.js';
@@ -3118,6 +3118,70 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3118
3118
 
3119
3119
  .secret-lg > .secret-eye { padding: var(--space-2); }
3120
3120
 
3121
+ /* An input with something inside its edges: a glyph on the left, a hint or a
3122
+ clear button on the right. Same arrangement as `.secret` above, and for the
3123
+ same reason -- one grid cell with everything stacked in it, and the room
3124
+ reserved in the input's own padding, so nothing is positioned against a
3125
+ height that changes with `size`. */
3126
+ .adorned { display: grid; align-items: center; }
3127
+
3128
+ .adorned > input,
3129
+ .adorned > .adorned-mark,
3130
+ .adorned > .adorned-end { grid-area: 1 / 1; }
3131
+
3132
+ .adorned.block { width: 100%; }
3133
+
3134
+ .adorned > .adorned-mark {
3135
+ justify-self: start;
3136
+ margin-left: var(--space-2);
3137
+ color: var(--muted);
3138
+ /* The glyph is decoration over the box; clicks belong to the input under
3139
+ it, which is otherwise unreachable in its own left edge. */
3140
+ pointer-events: none;
3141
+ /* Over the input, not under it. Both share one grid cell, the input has an
3142
+ opaque `--control` background, and it comes later in the DOM -- so
3143
+ without this the glyph is painted and then covered, which looks exactly
3144
+ like an icon that failed to render. `.secret` never hit this because its
3145
+ button is written after the input. */
3146
+ z-index: 1;
3147
+ }
3148
+
3149
+ /* Room for the glyph, on the scale, so a density change moves the text with
3150
+ the mark rather than leaving one behind. `--space-8` is what `.secret`
3151
+ reserves on the right for the same reason; the scale has no 7, and an
3152
+ undefined custom property does not fall back -- the declaration computes
3153
+ to 0 and the text sits under the glyph. */
3154
+ .adorned-icon > input { padding-left: var(--space-8); }
3155
+
3156
+ /* No `--space-12` on the scale; two steps that are, added. Same arithmetic
3157
+ as `.secret-lg`. */
3158
+ .adorned-icon.adorned-lg > input { padding-left: calc(var(--space-10) + var(--space-1)); }
3159
+
3160
+ .adorned > .adorned-end {
3161
+ justify-self: end;
3162
+ display: flex;
3163
+ align-items: center;
3164
+ gap: var(--space-1);
3165
+ margin-right: var(--space-1);
3166
+ }
3167
+
3168
+ /* `:has`, not a sibling combinator: the input comes *before* the end cap in
3169
+ the DOM, so `~` reads the wrong way and matches nothing. */
3170
+ .adorned:has(> .adorned-end) > input { padding-right: var(--space-8); }
3171
+ .adorned-lg:has(> .adorned-end) > input {
3172
+ padding-right: calc(var(--space-10) + var(--space-1));
3173
+ }
3174
+
3175
+ /* Sized from its glyph, not `.icon-btn`'s 34px tile, which would be taller
3176
+ than a small input -- the note above `.secret-eye` in full. */
3177
+ .adorned-clear {
3178
+ width: auto; height: auto;
3179
+ padding: var(--space-1);
3180
+ opacity: 1;
3181
+ }
3182
+
3183
+ .adorned-lg .adorned-clear { padding: var(--space-2); }
3184
+
3121
3185
  /* ---- ./tour.css ---- */
3122
3186
  /* tour ----------------------------------------------------------------- */
3123
3187
 
@@ -3251,6 +3315,84 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3251
3315
  .stat[data-tone='warn'] .stat-value { color: var(--warn); }
3252
3316
  .stat[data-tone='bad'] .stat-value { color: var(--bad); }
3253
3317
 
3318
+ /* ---- ./shell.css ---- */
3319
+ /* The frame. Layout only: every colour and every step is a token, and the
3320
+ page inside it owns its own spacing. */
3321
+ .shell {
3322
+ display: flex;
3323
+ flex-direction: column;
3324
+ min-height: 100vh;
3325
+ background: var(--bg);
3326
+ color: var(--text);
3327
+ }
3328
+
3329
+ .shell-head {
3330
+ border-bottom: var(--border-width) solid var(--border);
3331
+ background: var(--panel);
3332
+ /* `flex: none` so a long page does not squeeze the header. */
3333
+ flex: none;
3334
+ }
3335
+
3336
+ /* The measure, said once. `--shell-measure` rather than a literal so an app
3337
+ with a different one sets a variable instead of overriding three rules and
3338
+ missing the fourth -- which is how manage ended up with `head-inner-wide`
3339
+ and `main-wide` as separate classes. */
3340
+ .shell {
3341
+ --shell-measure: 64rem;
3342
+ --shell-measure-wide: 90rem;
3343
+ }
3344
+
3345
+ .shell-head-inner,
3346
+ .shell-nav,
3347
+ .shell-main {
3348
+ width: 100%;
3349
+ max-width: var(--shell-measure);
3350
+ margin-inline: auto;
3351
+ padding-inline: var(--space-4);
3352
+ }
3353
+
3354
+ .shell-wide { max-width: var(--shell-measure-wide); }
3355
+
3356
+ .shell-head-inner {
3357
+ display: flex;
3358
+ align-items: center;
3359
+ justify-content: space-between;
3360
+ gap: var(--space-4);
3361
+ padding-block: var(--space-3);
3362
+ min-width: 0;
3363
+ }
3364
+
3365
+ .shell-who {
3366
+ display: flex;
3367
+ align-items: center;
3368
+ gap: var(--space-3);
3369
+ min-width: 0;
3370
+ }
3371
+
3372
+ .shell-nav { padding-bottom: var(--space-2); }
3373
+
3374
+ .shell-main {
3375
+ flex: 1;
3376
+ padding-block: var(--space-6);
3377
+ /* `min-width: 0` because a flex child will not shrink below its content,
3378
+ and one wide table inside then pushes the whole page sideways. */
3379
+ min-width: 0;
3380
+ }
3381
+
3382
+ /* The label beside the theme control, when it is drawn. */
3383
+ .theme-switch {
3384
+ display: flex;
3385
+ align-items: center;
3386
+ gap: var(--space-2);
3387
+ min-width: 0;
3388
+ }
3389
+
3390
+ .theme-switch-label {
3391
+ color: var(--muted);
3392
+ font-size: var(--text-sm);
3393
+ white-space: nowrap;
3394
+ }
3395
+
3254
3396
  /* ---- ./scrollarea.css ---- */
3255
3397
  /* A box that scrolls, with its edges drawn.
3256
3398
 
package/dist/tf.css CHANGED
@@ -3388,6 +3388,70 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3388
3388
 
3389
3389
  .secret-lg > .secret-eye { padding: var(--space-2); }
3390
3390
 
3391
+ /* An input with something inside its edges: a glyph on the left, a hint or a
3392
+ clear button on the right. Same arrangement as `.secret` above, and for the
3393
+ same reason -- one grid cell with everything stacked in it, and the room
3394
+ reserved in the input's own padding, so nothing is positioned against a
3395
+ height that changes with `size`. */
3396
+ .adorned { display: grid; align-items: center; }
3397
+
3398
+ .adorned > input,
3399
+ .adorned > .adorned-mark,
3400
+ .adorned > .adorned-end { grid-area: 1 / 1; }
3401
+
3402
+ .adorned.block { width: 100%; }
3403
+
3404
+ .adorned > .adorned-mark {
3405
+ justify-self: start;
3406
+ margin-left: var(--space-2);
3407
+ color: var(--muted);
3408
+ /* The glyph is decoration over the box; clicks belong to the input under
3409
+ it, which is otherwise unreachable in its own left edge. */
3410
+ pointer-events: none;
3411
+ /* Over the input, not under it. Both share one grid cell, the input has an
3412
+ opaque `--control` background, and it comes later in the DOM -- so
3413
+ without this the glyph is painted and then covered, which looks exactly
3414
+ like an icon that failed to render. `.secret` never hit this because its
3415
+ button is written after the input. */
3416
+ z-index: 1;
3417
+ }
3418
+
3419
+ /* Room for the glyph, on the scale, so a density change moves the text with
3420
+ the mark rather than leaving one behind. `--space-8` is what `.secret`
3421
+ reserves on the right for the same reason; the scale has no 7, and an
3422
+ undefined custom property does not fall back -- the declaration computes
3423
+ to 0 and the text sits under the glyph. */
3424
+ .adorned-icon > input { padding-left: var(--space-8); }
3425
+
3426
+ /* No `--space-12` on the scale; two steps that are, added. Same arithmetic
3427
+ as `.secret-lg`. */
3428
+ .adorned-icon.adorned-lg > input { padding-left: calc(var(--space-10) + var(--space-1)); }
3429
+
3430
+ .adorned > .adorned-end {
3431
+ justify-self: end;
3432
+ display: flex;
3433
+ align-items: center;
3434
+ gap: var(--space-1);
3435
+ margin-right: var(--space-1);
3436
+ }
3437
+
3438
+ /* `:has`, not a sibling combinator: the input comes *before* the end cap in
3439
+ the DOM, so `~` reads the wrong way and matches nothing. */
3440
+ .adorned:has(> .adorned-end) > input { padding-right: var(--space-8); }
3441
+ .adorned-lg:has(> .adorned-end) > input {
3442
+ padding-right: calc(var(--space-10) + var(--space-1));
3443
+ }
3444
+
3445
+ /* Sized from its glyph, not `.icon-btn`'s 34px tile, which would be taller
3446
+ than a small input -- the note above `.secret-eye` in full. */
3447
+ .adorned-clear {
3448
+ width: auto; height: auto;
3449
+ padding: var(--space-1);
3450
+ opacity: 1;
3451
+ }
3452
+
3453
+ .adorned-lg .adorned-clear { padding: var(--space-2); }
3454
+
3391
3455
  /* ---- ./tour.css ---- */
3392
3456
  /* tour ----------------------------------------------------------------- */
3393
3457
 
@@ -3521,6 +3585,84 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3521
3585
  .stat[data-tone='warn'] .stat-value { color: var(--warn); }
3522
3586
  .stat[data-tone='bad'] .stat-value { color: var(--bad); }
3523
3587
 
3588
+ /* ---- ./shell.css ---- */
3589
+ /* The frame. Layout only: every colour and every step is a token, and the
3590
+ page inside it owns its own spacing. */
3591
+ .shell {
3592
+ display: flex;
3593
+ flex-direction: column;
3594
+ min-height: 100vh;
3595
+ background: var(--bg);
3596
+ color: var(--text);
3597
+ }
3598
+
3599
+ .shell-head {
3600
+ border-bottom: var(--border-width) solid var(--border);
3601
+ background: var(--panel);
3602
+ /* `flex: none` so a long page does not squeeze the header. */
3603
+ flex: none;
3604
+ }
3605
+
3606
+ /* The measure, said once. `--shell-measure` rather than a literal so an app
3607
+ with a different one sets a variable instead of overriding three rules and
3608
+ missing the fourth -- which is how manage ended up with `head-inner-wide`
3609
+ and `main-wide` as separate classes. */
3610
+ .shell {
3611
+ --shell-measure: 64rem;
3612
+ --shell-measure-wide: 90rem;
3613
+ }
3614
+
3615
+ .shell-head-inner,
3616
+ .shell-nav,
3617
+ .shell-main {
3618
+ width: 100%;
3619
+ max-width: var(--shell-measure);
3620
+ margin-inline: auto;
3621
+ padding-inline: var(--space-4);
3622
+ }
3623
+
3624
+ .shell-wide { max-width: var(--shell-measure-wide); }
3625
+
3626
+ .shell-head-inner {
3627
+ display: flex;
3628
+ align-items: center;
3629
+ justify-content: space-between;
3630
+ gap: var(--space-4);
3631
+ padding-block: var(--space-3);
3632
+ min-width: 0;
3633
+ }
3634
+
3635
+ .shell-who {
3636
+ display: flex;
3637
+ align-items: center;
3638
+ gap: var(--space-3);
3639
+ min-width: 0;
3640
+ }
3641
+
3642
+ .shell-nav { padding-bottom: var(--space-2); }
3643
+
3644
+ .shell-main {
3645
+ flex: 1;
3646
+ padding-block: var(--space-6);
3647
+ /* `min-width: 0` because a flex child will not shrink below its content,
3648
+ and one wide table inside then pushes the whole page sideways. */
3649
+ min-width: 0;
3650
+ }
3651
+
3652
+ /* The label beside the theme control, when it is drawn. */
3653
+ .theme-switch {
3654
+ display: flex;
3655
+ align-items: center;
3656
+ gap: var(--space-2);
3657
+ min-width: 0;
3658
+ }
3659
+
3660
+ .theme-switch-label {
3661
+ color: var(--muted);
3662
+ font-size: var(--text-sm);
3663
+ white-space: nowrap;
3664
+ }
3665
+
3524
3666
  /* ---- ./scrollarea.css ---- */
3525
3667
  /* A box that scrolls, with its edges drawn.
3526
3668
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A person's theme, remembered, and applied before the page is drawn.
3
+ *
4
+ * **Three apps wrote this and two of them wrote it identically.**
5
+ * `src/theme/first-paint.ts` is byte-for-byte the same file in
6
+ * wtfalch-manage and app-template; valet's is the same script with the
7
+ * comment rewritten. Each also keeps a `themes.ts` listing ids and labels the
8
+ * package already knows -- a `Theme` carries the `name` a picker shows, and a
9
+ * `Product` carries its themes and which one it wears by default -- so the
10
+ * app-side list was a second copy of a fact, kept in step by hand.
11
+ *
12
+ * **The script is a string, and it has to be.** It runs before the bundle, in
13
+ * a blocking `<script>` in `<head>`, because a theme applied after hydration
14
+ * means a person who chose Paper sees Night for a frame. That is the whole
15
+ * reason this is not simply a `useEffect`.
16
+ *
17
+ * **It sets `data-theme` and nothing else.** The palettes ship as CSS from
18
+ * `productCss`, generated from the same objects the contrast test measures,
19
+ * so the attribute is the entire mechanism. `applyTheme` writes custom
20
+ * properties instead and is for the case with no stylesheet to lean on --
21
+ * theming a subtree, or a product whose CSS is not the one loaded.
22
+ *
23
+ * A copy of this shipped with a bug worth keeping: a stored id the app no
24
+ * longer offers has to fall back, or a browser that remembers `sepia` from a
25
+ * palette you dropped renders unthemed. The list is inlined into the script
26
+ * for that check.
27
+ */
28
+ import type { BrandName } from '../components/brandMarks.js';
29
+ import { type Product } from '../products/index.js';
30
+ /** Where the choice lives. One key across the estate, so a person who picked
31
+ * Night on one app is not asked again on the next one under the same
32
+ * origin. The README has used this name since 0.1.0. */
33
+ export declare const THEME_STORAGE_KEY = "theme";
34
+ export interface ThemeChoice {
35
+ id: string;
36
+ /** What the picker calls it -- the theme's own `name`. */
37
+ label: string;
38
+ /** The line under it, where a picker has room. */
39
+ note: string;
40
+ }
41
+ /**
42
+ * What this product offers, in the order it declared them.
43
+ *
44
+ * `only` narrows to a subset, for an app that ships a product's palette
45
+ * without all of its themes. An id the product does not have is dropped
46
+ * rather than thrown on: the list is presentation, and a picker missing a row
47
+ * is better than a page that will not render.
48
+ */
49
+ export declare function themeChoices(product: BrandName | Product, only?: readonly string[]): ThemeChoice[];
50
+ /**
51
+ * The blocking script, as a string to put in a `<script>` in `<head>`.
52
+ *
53
+ * `dangerouslySetInnerHTML={{ __html: themeChoiceScript('tf') }}` in a Next
54
+ * root layout, above everything. It reads the stored id, checks it against
55
+ * what this app offers, and writes `data-theme` on `<html>`.
56
+ *
57
+ * Wrapped in try/catch because `localStorage` throws outright in a browser
58
+ * set to block site data, and an exception here happens before anything is
59
+ * drawn -- so the page that fails to read a preference would otherwise fail
60
+ * to render at all.
61
+ */
62
+ export declare function themeChoiceScript(product: BrandName | Product, options?: {
63
+ only?: readonly string[];
64
+ storageKey?: string;
65
+ }): string;
66
+ /** What the script would have written, for code that needs the same answer
67
+ * after hydration. Reads the same key and applies the same fallback. */
68
+ export declare function storedTheme(product: BrandName | Product, options?: {
69
+ only?: readonly string[];
70
+ storageKey?: string;
71
+ }): string;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A person's theme, remembered, and applied before the page is drawn.
3
+ *
4
+ * **Three apps wrote this and two of them wrote it identically.**
5
+ * `src/theme/first-paint.ts` is byte-for-byte the same file in
6
+ * wtfalch-manage and app-template; valet's is the same script with the
7
+ * comment rewritten. Each also keeps a `themes.ts` listing ids and labels the
8
+ * package already knows -- a `Theme` carries the `name` a picker shows, and a
9
+ * `Product` carries its themes and which one it wears by default -- so the
10
+ * app-side list was a second copy of a fact, kept in step by hand.
11
+ *
12
+ * **The script is a string, and it has to be.** It runs before the bundle, in
13
+ * a blocking `<script>` in `<head>`, because a theme applied after hydration
14
+ * means a person who chose Paper sees Night for a frame. That is the whole
15
+ * reason this is not simply a `useEffect`.
16
+ *
17
+ * **It sets `data-theme` and nothing else.** The palettes ship as CSS from
18
+ * `productCss`, generated from the same objects the contrast test measures,
19
+ * so the attribute is the entire mechanism. `applyTheme` writes custom
20
+ * properties instead and is for the case with no stylesheet to lean on --
21
+ * theming a subtree, or a product whose CSS is not the one loaded.
22
+ *
23
+ * A copy of this shipped with a bug worth keeping: a stored id the app no
24
+ * longer offers has to fall back, or a browser that remembers `sepia` from a
25
+ * palette you dropped renders unthemed. The list is inlined into the script
26
+ * for that check.
27
+ */
28
+ import { PRODUCTS } from '../products/index.js';
29
+ /** Where the choice lives. One key across the estate, so a person who picked
30
+ * Night on one app is not asked again on the next one under the same
31
+ * origin. The README has used this name since 0.1.0. */
32
+ export const THEME_STORAGE_KEY = 'theme';
33
+ function resolve(product) {
34
+ return typeof product === 'string' ? PRODUCTS[product] : product;
35
+ }
36
+ /**
37
+ * What this product offers, in the order it declared them.
38
+ *
39
+ * `only` narrows to a subset, for an app that ships a product's palette
40
+ * without all of its themes. An id the product does not have is dropped
41
+ * rather than thrown on: the list is presentation, and a picker missing a row
42
+ * is better than a page that will not render.
43
+ */
44
+ export function themeChoices(product, only) {
45
+ const p = resolve(product);
46
+ const ids = only ? only.filter((id) => id in p.themes) : Object.keys(p.themes);
47
+ return ids.map((id) => ({ id, label: p.themes[id].name, note: p.themes[id].note }));
48
+ }
49
+ /**
50
+ * The blocking script, as a string to put in a `<script>` in `<head>`.
51
+ *
52
+ * `dangerouslySetInnerHTML={{ __html: themeChoiceScript('tf') }}` in a Next
53
+ * root layout, above everything. It reads the stored id, checks it against
54
+ * what this app offers, and writes `data-theme` on `<html>`.
55
+ *
56
+ * Wrapped in try/catch because `localStorage` throws outright in a browser
57
+ * set to block site data, and an exception here happens before anything is
58
+ * drawn -- so the page that fails to read a preference would otherwise fail
59
+ * to render at all.
60
+ */
61
+ export function themeChoiceScript(product, options = {}) {
62
+ const p = resolve(product);
63
+ const ids = themeChoices(product, options.only).map((c) => c.id);
64
+ const key = JSON.stringify(options.storageKey ?? THEME_STORAGE_KEY);
65
+ const fallback = JSON.stringify(ids.includes(p.defaultTheme) ? p.defaultTheme : (ids[0] ?? p.defaultTheme));
66
+ return `(function(){try{var t=localStorage.getItem(${key});document.documentElement.dataset.theme=${JSON.stringify(ids)}.indexOf(t)>=0?t:${fallback}}catch(e){}})()`;
67
+ }
68
+ /** What the script would have written, for code that needs the same answer
69
+ * after hydration. Reads the same key and applies the same fallback. */
70
+ export function storedTheme(product, options = {}) {
71
+ const p = resolve(product);
72
+ const ids = themeChoices(product, options.only).map((c) => c.id);
73
+ const fallback = ids.includes(p.defaultTheme) ? p.defaultTheme : (ids[0] ?? p.defaultTheme);
74
+ try {
75
+ const stored = localStorage.getItem(options.storageKey ?? THEME_STORAGE_KEY);
76
+ return stored && ids.includes(stored) ? stored : fallback;
77
+ }
78
+ catch {
79
+ return fallback;
80
+ }
81
+ }
package/dist/valet.css CHANGED
@@ -3392,6 +3392,70 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3392
3392
 
3393
3393
  .secret-lg > .secret-eye { padding: var(--space-2); }
3394
3394
 
3395
+ /* An input with something inside its edges: a glyph on the left, a hint or a
3396
+ clear button on the right. Same arrangement as `.secret` above, and for the
3397
+ same reason -- one grid cell with everything stacked in it, and the room
3398
+ reserved in the input's own padding, so nothing is positioned against a
3399
+ height that changes with `size`. */
3400
+ .adorned { display: grid; align-items: center; }
3401
+
3402
+ .adorned > input,
3403
+ .adorned > .adorned-mark,
3404
+ .adorned > .adorned-end { grid-area: 1 / 1; }
3405
+
3406
+ .adorned.block { width: 100%; }
3407
+
3408
+ .adorned > .adorned-mark {
3409
+ justify-self: start;
3410
+ margin-left: var(--space-2);
3411
+ color: var(--muted);
3412
+ /* The glyph is decoration over the box; clicks belong to the input under
3413
+ it, which is otherwise unreachable in its own left edge. */
3414
+ pointer-events: none;
3415
+ /* Over the input, not under it. Both share one grid cell, the input has an
3416
+ opaque `--control` background, and it comes later in the DOM -- so
3417
+ without this the glyph is painted and then covered, which looks exactly
3418
+ like an icon that failed to render. `.secret` never hit this because its
3419
+ button is written after the input. */
3420
+ z-index: 1;
3421
+ }
3422
+
3423
+ /* Room for the glyph, on the scale, so a density change moves the text with
3424
+ the mark rather than leaving one behind. `--space-8` is what `.secret`
3425
+ reserves on the right for the same reason; the scale has no 7, and an
3426
+ undefined custom property does not fall back -- the declaration computes
3427
+ to 0 and the text sits under the glyph. */
3428
+ .adorned-icon > input { padding-left: var(--space-8); }
3429
+
3430
+ /* No `--space-12` on the scale; two steps that are, added. Same arithmetic
3431
+ as `.secret-lg`. */
3432
+ .adorned-icon.adorned-lg > input { padding-left: calc(var(--space-10) + var(--space-1)); }
3433
+
3434
+ .adorned > .adorned-end {
3435
+ justify-self: end;
3436
+ display: flex;
3437
+ align-items: center;
3438
+ gap: var(--space-1);
3439
+ margin-right: var(--space-1);
3440
+ }
3441
+
3442
+ /* `:has`, not a sibling combinator: the input comes *before* the end cap in
3443
+ the DOM, so `~` reads the wrong way and matches nothing. */
3444
+ .adorned:has(> .adorned-end) > input { padding-right: var(--space-8); }
3445
+ .adorned-lg:has(> .adorned-end) > input {
3446
+ padding-right: calc(var(--space-10) + var(--space-1));
3447
+ }
3448
+
3449
+ /* Sized from its glyph, not `.icon-btn`'s 34px tile, which would be taller
3450
+ than a small input -- the note above `.secret-eye` in full. */
3451
+ .adorned-clear {
3452
+ width: auto; height: auto;
3453
+ padding: var(--space-1);
3454
+ opacity: 1;
3455
+ }
3456
+
3457
+ .adorned-lg .adorned-clear { padding: var(--space-2); }
3458
+
3395
3459
  /* ---- ./tour.css ---- */
3396
3460
  /* tour ----------------------------------------------------------------- */
3397
3461
 
@@ -3525,6 +3589,84 @@ input[type='range'].slider:disabled::-moz-range-thumb { opacity: 0; }
3525
3589
  .stat[data-tone='warn'] .stat-value { color: var(--warn); }
3526
3590
  .stat[data-tone='bad'] .stat-value { color: var(--bad); }
3527
3591
 
3592
+ /* ---- ./shell.css ---- */
3593
+ /* The frame. Layout only: every colour and every step is a token, and the
3594
+ page inside it owns its own spacing. */
3595
+ .shell {
3596
+ display: flex;
3597
+ flex-direction: column;
3598
+ min-height: 100vh;
3599
+ background: var(--bg);
3600
+ color: var(--text);
3601
+ }
3602
+
3603
+ .shell-head {
3604
+ border-bottom: var(--border-width) solid var(--border);
3605
+ background: var(--panel);
3606
+ /* `flex: none` so a long page does not squeeze the header. */
3607
+ flex: none;
3608
+ }
3609
+
3610
+ /* The measure, said once. `--shell-measure` rather than a literal so an app
3611
+ with a different one sets a variable instead of overriding three rules and
3612
+ missing the fourth -- which is how manage ended up with `head-inner-wide`
3613
+ and `main-wide` as separate classes. */
3614
+ .shell {
3615
+ --shell-measure: 64rem;
3616
+ --shell-measure-wide: 90rem;
3617
+ }
3618
+
3619
+ .shell-head-inner,
3620
+ .shell-nav,
3621
+ .shell-main {
3622
+ width: 100%;
3623
+ max-width: var(--shell-measure);
3624
+ margin-inline: auto;
3625
+ padding-inline: var(--space-4);
3626
+ }
3627
+
3628
+ .shell-wide { max-width: var(--shell-measure-wide); }
3629
+
3630
+ .shell-head-inner {
3631
+ display: flex;
3632
+ align-items: center;
3633
+ justify-content: space-between;
3634
+ gap: var(--space-4);
3635
+ padding-block: var(--space-3);
3636
+ min-width: 0;
3637
+ }
3638
+
3639
+ .shell-who {
3640
+ display: flex;
3641
+ align-items: center;
3642
+ gap: var(--space-3);
3643
+ min-width: 0;
3644
+ }
3645
+
3646
+ .shell-nav { padding-bottom: var(--space-2); }
3647
+
3648
+ .shell-main {
3649
+ flex: 1;
3650
+ padding-block: var(--space-6);
3651
+ /* `min-width: 0` because a flex child will not shrink below its content,
3652
+ and one wide table inside then pushes the whole page sideways. */
3653
+ min-width: 0;
3654
+ }
3655
+
3656
+ /* The label beside the theme control, when it is drawn. */
3657
+ .theme-switch {
3658
+ display: flex;
3659
+ align-items: center;
3660
+ gap: var(--space-2);
3661
+ min-width: 0;
3662
+ }
3663
+
3664
+ .theme-switch-label {
3665
+ color: var(--muted);
3666
+ font-size: var(--text-sm);
3667
+ white-space: nowrap;
3668
+ }
3669
+
3528
3670
  /* ---- ./scrollarea.css ---- */
3529
3671
  /* A box that scrolls, with its edges drawn.
3530
3672
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/design",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "wtfalch's design system: themeable components on a fixed token vocabulary.",
5
5
  "keywords": [
6
6
  "design-system",