@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.
- package/dist/components/Field.d.ts +22 -19
- package/dist/components/Field.js +11 -9
- package/dist/components/Input.d.ts +10 -0
- package/dist/components/Input.js +24 -3
- package/dist/components/Select.d.ts +3 -1
- package/dist/components/Select.js +27 -5
- package/dist/components/Shell.d.ts +35 -0
- package/dist/components/Shell.js +26 -0
- package/dist/components/Textarea.js +14 -4
- package/dist/components/ThemeSwitch.d.ts +19 -0
- package/dist/components/ThemeSwitch.js +65 -0
- package/dist/components/fieldWiring.d.ts +44 -0
- package/dist/components/fieldWiring.js +30 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +8 -0
- package/dist/styles/index.css +142 -0
- package/dist/tf.css +142 -0
- package/dist/themes/choice.d.ts +71 -0
- package/dist/themes/choice.js +81 -0
- package/dist/valet.css +142 -0
- package/package.json +1 -1
|
@@ -1,21 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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;
|
package/dist/components/Field.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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>>;
|
package/dist/components/Input.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/* `
|
|
36
|
-
`
|
|
37
|
-
|
|
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":
|
|
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
|
|
12
|
-
* come from `Field`
|
|
13
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
package/dist/styles/index.css
CHANGED
|
@@ -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
|
|