staffa 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +99 -271
  2. package/dist/components/autocomplete.js +4 -5
  3. package/dist/components/box.js +11 -21
  4. package/dist/components/button.d.ts +20 -5
  5. package/dist/components/button.js +55 -47
  6. package/dist/components/buttonChooser.js +1 -3
  7. package/dist/components/checkbox.js +1 -2
  8. package/dist/components/dialog.d.ts +9 -2
  9. package/dist/components/dialog.js +29 -35
  10. package/dist/components/field.d.ts +5 -8
  11. package/dist/components/field.js +4 -6
  12. package/dist/components/form.d.ts +5 -7
  13. package/dist/components/form.js +6 -9
  14. package/dist/components/keyhelp.d.ts +22 -0
  15. package/dist/components/keyhelp.js +91 -0
  16. package/dist/components/main.js +190 -318
  17. package/dist/components/menu.d.ts +36 -9
  18. package/dist/components/menu.js +193 -144
  19. package/dist/components/panels.d.ts +152 -232
  20. package/dist/components/panels.js +341 -556
  21. package/dist/components/select.js +1 -3
  22. package/dist/components/tabs.d.ts +10 -13
  23. package/dist/components/tabs.js +40 -63
  24. package/dist/components/textline.d.ts +3 -5
  25. package/dist/components/textline.js +3 -5
  26. package/dist/components/toast.d.ts +1 -3
  27. package/dist/components/toast.js +3 -6
  28. package/dist/components/tooltip.d.ts +4 -5
  29. package/dist/components/tooltip.js +13 -22
  30. package/dist/core.d.ts +17 -39
  31. package/dist/core.js +13 -35
  32. package/dist/icons-helpers.d.ts +3 -3
  33. package/dist/icons-helpers.js +6 -11
  34. package/dist/index.d.ts +3 -1
  35. package/dist/index.js +5 -4
  36. package/dist/keys.d.ts +92 -0
  37. package/dist/keys.js +279 -0
  38. package/dist/staffa.esm.js +1 -1
  39. package/dist/theme.d.ts +4 -10
  40. package/dist/theme.js +58 -123
  41. package/package.json +2 -2
  42. package/skill/ButtonOptions.md +12 -0
  43. package/skill/DialogOptions.md +11 -2
  44. package/skill/FieldOptions.md +3 -5
  45. package/skill/IconButtonOptions.md +8 -0
  46. package/skill/MenuItem.md +22 -3
  47. package/skill/Panel.md +8 -0
  48. package/skill/SKILL.md +161 -294
  49. package/skill/addTooltip.md +4 -5
  50. package/skill/bindKey.md +51 -0
  51. package/skill/box.md +1 -1
  52. package/skill/form.md +5 -7
  53. package/skill/formatKey.md +21 -0
  54. package/skill/iconButton.md +4 -5
  55. package/skill/scrollStrip.md +7 -9
  56. package/skill/showFloatingMenu.md +2 -2
  57. package/skill/showKeyHelp.md +17 -0
  58. package/skill/tabs.md +3 -4
  59. package/skill/textline.md +3 -5
  60. package/src/components/autocomplete.ts +4 -5
  61. package/src/components/box.ts +11 -21
  62. package/src/components/button.ts +70 -47
  63. package/src/components/buttonChooser.ts +1 -3
  64. package/src/components/checkbox.ts +1 -2
  65. package/src/components/dialog.ts +39 -37
  66. package/src/components/field.ts +7 -11
  67. package/src/components/form.ts +6 -9
  68. package/src/components/keyhelp.ts +96 -0
  69. package/src/components/main.ts +194 -318
  70. package/src/components/menu.ts +209 -146
  71. package/src/components/panels.ts +389 -618
  72. package/src/components/select.ts +1 -3
  73. package/src/components/tabs.ts +40 -63
  74. package/src/components/textline.ts +3 -5
  75. package/src/components/toast.ts +4 -9
  76. package/src/components/tooltip.ts +13 -22
  77. package/src/core.ts +17 -43
  78. package/src/icons-helpers.ts +6 -11
  79. package/src/index.ts +5 -4
  80. package/src/keys.ts +300 -0
  81. package/src/theme.ts +58 -123
  82. package/skill/Attributes.md +0 -10
package/src/keys.ts ADDED
@@ -0,0 +1,300 @@
1
+ import A from "aberdeen";
2
+ import type { Slot } from "./core.js";
3
+
4
+ /**
5
+ * Keyboard shortcuts: a registry of live bindings served by one document-level
6
+ * `keydown` listener, and writing a combination back out the way this platform
7
+ * writes it. The spelling of a combination is documented on {@link bindKey}.
8
+ */
9
+
10
+ /**
11
+ * Whether this is an Apple platform, where the modifier is ⌘ rather than Ctrl.
12
+ * `platform` is deprecated but frozen rather than removed, and the user agent
13
+ * behind it says Macintosh anyway — an iPad asking for the desktop site
14
+ * included, which is the right answer here.
15
+ */
16
+ const IS_APPLE = typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);
17
+
18
+ /** Spellings people reach for that aren't `KeyboardEvent.key` values. */
19
+ const ALIASES: Record<string, string> = { esc: "escape", space: " " };
20
+
21
+ /** How a key is written in a hint, where its canonical name won't do. */
22
+ const GLYPHS: Record<string, string> = { " ": "Space", escape: "Esc", arrowup: "↑", arrowdown: "↓", arrowleft: "←", arrowright: "→" };
23
+
24
+ /** One registered shortcut, as {@link getActiveKeyBindings} hands them out. */
25
+ export interface KeyBinding {
26
+ /**
27
+ * What it does — a rich-text string or draw function, shown in the shortcut
28
+ * overview. Without one, the binding stays out of the overview.
29
+ */
30
+ description?: Slot;
31
+ /**
32
+ * Runs on the keystroke, after `preventDefault()`. Without one, the binding
33
+ * only *describes* the key (which is handled elsewhere) — it is listed and
34
+ * shadows same-key bindings further out, but the keystroke passes untouched.
35
+ */
36
+ press?: (e: KeyboardEvent) => void;
37
+ /** Keeps working while a modal owns the keyboard. */
38
+ global?: boolean;
39
+ /** The same-key binding this one shadows, restored when this one is removed. */
40
+ prev?: KeyBinding;
41
+ }
42
+
43
+ /**
44
+ * Live bindings, kept per element per canonical key string. The element a
45
+ * binding is stored at decides when it applies: the keydown handler walks up
46
+ * the tree from the focused element and takes the first match.
47
+ */
48
+ const bindings = new WeakMap<Element, Map<string, KeyBinding>>();
49
+
50
+ /** Elements that claimed the keyboard, in claiming order. The last one rules. */
51
+ const modalStack: Element[] = [];
52
+
53
+ /**
54
+ * Give the current element the keyboard, until the returned release function is
55
+ * called: normal bindings drawn inside it register at it, so they die with it
56
+ * and never fire once focus (and the walk up from it) has moved to a later
57
+ * claim — while bindings from outside any claim are silenced, the `global`
58
+ * ones excepted. What a modal dialog does while it is up.
59
+ */
60
+ export function claimKeyboard(): () => void {
61
+ const el = A() as Element | undefined;
62
+ if (!el) throw new Error("Staffa: claimKeyboard needs a current element");
63
+ modalStack.push(el);
64
+ return () => {
65
+ const i = modalStack.indexOf(el);
66
+ if (i >= 0) modalStack.splice(i, 1);
67
+ };
68
+ }
69
+
70
+ /**
71
+ * A spec reduced to its canonical form, the registry's index — simply the spec
72
+ * lower-cased, aliases resolved: an optional `mod+`, an optional `shift+`,
73
+ * then the `KeyboardEvent.key` value. Throws on anything else, loudly: a
74
+ * shortcut is invisible until it fails to fire, so a typo must not wait for
75
+ * the keystroke that needed it.
76
+ */
77
+ function canonKey(spec: string): string {
78
+ const [, mod, shift, name] = /^(mod\+)?(shift\+)?(.*)$/i.exec(spec)!;
79
+ let key = name.toLowerCase();
80
+ key = ALIASES[key] ?? key;
81
+ if (!key || (key.length > 1 && /[-+]/.test(key))) {
82
+ throw new Error(`Staffa: can't parse key "${spec}" — write "k", "f2", "mod+k" or "mod+shift+f2"`);
83
+ }
84
+ // The typed character is a combination's one name: `?` is what shift-/ types.
85
+ if (shift && key.toUpperCase() === key) {
86
+ throw new Error(`Staffa: "${spec}" — write the shifted character itself ("?", not "shift+/")`);
87
+ }
88
+ return (mod ? "mod+" : "") + (shift ? "shift+" : "") + key;
89
+ }
90
+
91
+ /** The canonical key string for a keystroke, or `null` for one that can't be a shortcut. */
92
+ function canonEvent(e: KeyboardEvent): string | null {
93
+ // Alt is never bound, nor is Ctrl on a Mac: a keystroke holding one down
94
+ // belongs to the app, the browser or the OS — not to us.
95
+ if (e.altKey || (IS_APPLE ? e.ctrlKey : e.metaKey)) return null;
96
+ const key = e.key.toLowerCase();
97
+ // Shift counts only where it doesn't already shape the typed character: it
98
+ // turns k into K and holds during F2, but *is* the difference between / and
99
+ // ? — and Caps Lock's capitals don't register.
100
+ const shift = e.shiftKey && (key.length > 1 || e.key.toUpperCase() !== key);
101
+ return ((IS_APPLE ? e.metaKey : e.ctrlKey) ? "mod+" : "") + (shift ? "shift+" : "") + key;
102
+ }
103
+
104
+ /**
105
+ * Whether the focused element keeps this keystroke for itself: anything being
106
+ * typed into keeps the unmodified keys (Escape excepted — it never types), a
107
+ * button-like control keeps its activation keys, and a link keeps Enter even
108
+ * modified — that one is the keyboard's own open-in-a-new-tab, the counterpart
109
+ * of a ctrl-click. The one answer the matcher and the `?` overview share, so
110
+ * what is listed and what fires can never disagree.
111
+ */
112
+ function keptByTarget(keyStr: string, target: Element | null): boolean {
113
+ if (!(target instanceof HTMLElement)) return false;
114
+ const mod = keyStr.startsWith("mod+");
115
+ // Modifiers stripped: a shifted Enter is still the link's new-window Enter.
116
+ const key = keyStr.replace(/^(mod\+)?(shift\+)?/, "");
117
+ if (key === "enter" && target.closest("a[href]") != null) return true;
118
+ if (!mod && (key === "enter" || key === " ") && target.closest("button, summary, [role=button]") != null) return true;
119
+ const tag = target.tagName;
120
+ return !mod && key !== "escape" &&
121
+ (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable);
122
+ }
123
+
124
+ /** Whether a binding found at `el` applies, given the current keyboard claim. */
125
+ function reachable(el: Element, b: KeyBinding): boolean {
126
+ const modal = modalStack[modalStack.length - 1];
127
+ return b.global === true || !modal || modal.contains(el);
128
+ }
129
+
130
+ /** The innermost claiming element containing `el`, if any. */
131
+ function claimFor(el: Element): Element | undefined {
132
+ for (let i = modalStack.length - 1; i >= 0; i--) {
133
+ if (modalStack[i].contains(el)) return modalStack[i];
134
+ }
135
+ }
136
+
137
+ /** The element whose claim owns the keyboard right now: the top claim, or the body. */
138
+ export function keyboardOwner(): Element {
139
+ return modalStack[modalStack.length - 1] ?? document.body;
140
+ }
141
+
142
+ /**
143
+ * Where the binding search starts: the focused element — or the claiming modal
144
+ * itself when focus has strayed outside it (a dialog holding nothing
145
+ * focusable, a click that blurred to the body), so a modal never loses its own
146
+ * keys.
147
+ */
148
+ function walkStart(target: Element | null): Element {
149
+ const modal = modalStack[modalStack.length - 1];
150
+ return modal && !(target && modal.contains(target)) ? modal : target ?? document.body;
151
+ }
152
+
153
+ let listening = false;
154
+
155
+ function onKeydown(e: KeyboardEvent): void {
156
+ // Something already answered for this keystroke — a handler of the app's own,
157
+ // a menu's or an autocomplete's; element listeners run before this one.
158
+ if (e.defaultPrevented || e.repeat || e.isComposing) return;
159
+ const keyStr = canonEvent(e);
160
+ const target = e.target instanceof Element ? e.target : null;
161
+ if (keyStr == null || keptByTarget(keyStr, target)) return;
162
+ for (let el: Element | null = walkStart(target); el; el = el.parentElement) {
163
+ const b = bindings.get(el)?.get(keyStr);
164
+ if (b && reachable(el, b)) {
165
+ // A describe-only binding still ends the search: the key is somebody
166
+ // else's, and the keystroke passes untouched.
167
+ if (b.press) {
168
+ e.preventDefault();
169
+ b.press(e);
170
+ }
171
+ return;
172
+ }
173
+ }
174
+ }
175
+
176
+ /**
177
+ * What a keypress aimed at `target` — the focused element, typically — could do
178
+ * right now: for each combination, the binding the walk up from `target` would
179
+ * find, minus the keystrokes `target` keeps for itself. Innermost first, as
180
+ * `[keyStr, binding]` pairs. A snapshot, not reactive.
181
+ */
182
+ export function getActiveKeyBindings(target: Element | null): Array<[string, KeyBinding]> {
183
+ const found = new Map<string, KeyBinding>();
184
+ for (let el: Element | null = walkStart(target); el; el = el.parentElement) {
185
+ const map = bindings.get(el);
186
+ if (map) {
187
+ for (const [keyStr, b] of map) {
188
+ if (!found.has(keyStr) && reachable(el, b) && !keptByTarget(keyStr, target)) found.set(keyStr, b);
189
+ }
190
+ }
191
+ }
192
+ return [...found];
193
+ }
194
+
195
+ /**
196
+ * Bind a keyboard shortcut, for as long as the calling scope lives.
197
+ *
198
+ * **The spec** is a `KeyboardEvent.key` value — `"k"`, `"f2"`, `"escape"`
199
+ * (`"esc"`), `"space"`, `"arrowdown"`, a bare `"?"` — optionally prefixed by
200
+ * `mod+` (⌘ on a Mac, Ctrl elsewhere) and/or `shift+`, in that order:
201
+ * `"mod+k"`, `"shift+f2"`, `"mod+shift+b"`. Case doesn't matter. A character
202
+ * Shift itself types is written as that character — `"?"`, never `"shift+/"` —
203
+ * so a combination works on every keyboard layout. No other modifiers are
204
+ * offered: Alt and the ⊞ key belong to the browser and the OS, which also
205
+ * keep some `mod` combinations for themselves — T, N, W, Q and the digits
206
+ * among them, while K, B, E, `/` and `.` are safely yours. Everything Staffa
207
+ * takes a `key` option is spelled this way, and {@link formatKey} turns it
208
+ * back into `"⇧⌘K"`/`"Ctrl+Shift+K"`.
209
+ *
210
+ * `description` is what the shortcut overview (see {@link showKeyHelp}) lists
211
+ * the binding as — a rich-text string or draw function; without one the
212
+ * binding stays out of the overview. Omit `press` to merely *describe* a key
213
+ * your app handles by other means, so the overview can still tell the user
214
+ * about it.
215
+ *
216
+ * A handler of the app's own that ran `preventDefault()` first always wins,
217
+ * and keystrokes the focused element owns (typing into a field, Enter on a
218
+ * link) are left to it. Otherwise `mode` says who else can reach the binding:
219
+ *
220
+ * - `"normal"` (the default): works app-wide, but is silenced while a modal
221
+ * dialog from outside it is up. Binding the same combination again shadows
222
+ * the earlier binding until the new scope dies — so a state can take a key
223
+ * over temporarily.
224
+ * - `"global"`: keeps working even over a modal.
225
+ * - `"local"`: only fires while the keyboard focus is inside the current
226
+ * element — for a shortcut that belongs to one row or panel of many.
227
+ * - an `Element`: like `"local"`, but for that element rather than the
228
+ * current one.
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * S.bindKey("mod+k", "Search", openSearch);
233
+ * S.bindKey("mod+z", "Undo"); // describe only: handled by our own listener
234
+ * ```
235
+ */
236
+ export function bindKey(spec: string, description?: Slot, press?: (e: KeyboardEvent) => void, mode: "normal" | "global" | "local" | Element = "normal"): void {
237
+ const cur = A() as Element | undefined;
238
+ // A normal binding is anchored by containment, not by whatever claim is top
239
+ // at call time: a scope redrawn elsewhere while a dialog is up must not
240
+ // hitch its keys to that dialog and die with it.
241
+ const el = mode === "global" ? document.body
242
+ : mode === "local" ? cur
243
+ : mode === "normal" ? (cur && claimFor(cur)) ?? document.body
244
+ : mode;
245
+ if (!el) throw new Error("Staffa: a local key binding needs a current element");
246
+ const keyStr = canonKey(spec);
247
+ let map = bindings.get(el);
248
+ if (!map) bindings.set(el, map = new Map());
249
+ // Shadow (not replace) any same-key binding already at this element; the
250
+ // scope's cleanup below restores it.
251
+ const binding: KeyBinding = { description, press, global: mode === "global", prev: map.get(keyStr) };
252
+ map.set(keyStr, binding);
253
+ if (!listening) {
254
+ listening = true;
255
+ document.addEventListener("keydown", onKeydown);
256
+ }
257
+ A.clean(() => {
258
+ // Unlink, wherever in the shadow chain the binding sits by now.
259
+ let b = map.get(keyStr);
260
+ if (b === binding) {
261
+ if (binding.prev) map.set(keyStr, binding.prev);
262
+ else map.delete(keyStr);
263
+ } else {
264
+ for (; b; b = b.prev) {
265
+ if (b.prev === binding) { b.prev = binding.prev; break; }
266
+ }
267
+ }
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Write a key combination the way the platform writes it: `"⇧⌘K"` on a Mac,
273
+ * `"Ctrl+Shift+K"` everywhere else. What the components put in their key hints —
274
+ * use it for the same hint elsewhere in your app, so both spell the shortcut the
275
+ * way this machine's user expects. Pass `aria: true` for the `aria-keyshortcuts`
276
+ * spelling instead: full modifier names and real key names,
277
+ * `"Meta+Shift+K"`/`"Control+Shift+K"`.
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * S.button({ content: `Search ${S.formatKey("mod+k")}`, click: search });
282
+ * ```
283
+ */
284
+ export function formatKey(spec: string, aria = false): string {
285
+ const keyStr = canonKey(spec);
286
+ const mod = keyStr.startsWith("mod+");
287
+ const rest = mod ? keyStr.slice(4) : keyStr;
288
+ const shift = rest.startsWith("shift+");
289
+ const key = shift ? rest.slice(6) : rest;
290
+ const cap = key.length === 1 ? key.toUpperCase() : key[0].toUpperCase() + key.slice(1);
291
+ if (aria) {
292
+ const name = key === " " ? "Space" : shift || key.length > 1 ? cap : key;
293
+ return (mod ? (IS_APPLE ? "Meta+" : "Control+") : "") + (shift ? "Shift+" : "") + name;
294
+ }
295
+ // Apple writes ⇧ before ⌘, and nothing between the glyphs.
296
+ const name = GLYPHS[key] ?? cap;
297
+ return IS_APPLE
298
+ ? (shift ? "⇧" : "") + (mod ? "⌘" : "") + name
299
+ : (mod ? "Ctrl+" : "") + (shift ? "Shift+" : "") + name;
300
+ }
package/src/theme.ts CHANGED
@@ -3,54 +3,23 @@ import A from "aberdeen";
3
3
  /**
4
4
  * Theming and global base styles for Staffa.
5
5
  *
6
- * # The surface model
6
+ * A Staffa app is a tree of **surfaces** (`.s-s`), in two families:
7
7
  *
8
- * A Staffa app is a tree of **surfaces**. A surface is anything with its own
9
- * background and matching ink — the page, a card, a coloured button. Mark an
10
- * element with `.s-s` and (usually) one modifier class. There are two families:
8
+ * - **Neutral** — `.neutral`, and the implicit page at `:root`. Its shade steps
9
+ * with nesting depth (capped). No `tonal`/`outlined` variants.
10
+ * - **Accent** — `.primary`, `.danger`, `.success`, `.warning`, `.link` (a bare
11
+ * `.s-s` is primary): a bright fill with white ink, `.tonal`/`.outlined`
12
+ * variants supported. A surface nested *inside* one is forced back to filled,
13
+ * so it can't bleed into the vivid parent.
11
14
  *
12
- * - **Neutral surfaces** — `.s-s.neutral` (and the implicit page at `:root`). A calm
13
- * neutral whose shade steps automatically with nesting depth (page → panel →
14
- * raised, capped). No `tonal`/`outlined` variants. Use them for cards, bars,
15
- * popovers — anything that just holds content.
16
- * - **Accent surfaces** — `.s-s.primary`, `.s-s.danger`, `.s-s.success`,
17
- * `.s-s.warning`, `.s-s.link` (a bare `.s-s` defaults to primary). A bright
18
- * fill with white ink, painted as a subtle single-colour gradient. They support
19
- * `.tonal` and `.outlined` variants. A surface nested *inside* an accent surface
20
- * is always rendered filled, so it can't bleed into the vivid parent.
21
- *
22
- * # Contextual tokens
23
- *
24
- * Inside any surface (including `:root`) these inherited custom properties are
25
- * defined, so widgets adapt to wherever they're nested:
26
- *
27
- * | token | meaning |
28
- * | ------------ | --------------------------------------------------------- |
29
- * | `--s-bg` | the surface background |
30
- * | `--s-text` | default ink (also applied as `color`) |
31
- * | `--s-muted` | secondary text (subtitles, help) |
32
- * | `--s-accent` | the surface's "pop" — the brand primary on neutral surfaces, the ink on accent surfaces |
33
- * | `--s-faint` | hairline, derived from text/bg |
34
- *
35
- * The brand/semantic colours are mode-independent and settable: `--s-primary`
36
- * (the one brand colour — it tints the neutrals and defines `.s-s.primary`),
37
- * `--s-danger`, `--s-success`, `--s-warning`, and `--s-link` (the link colour,
38
- * also the fill of the `.s-s.link` surface). Links render in `--s-link` on
39
- * neutral surfaces and in the ink on accent surfaces.
40
- *
41
- * # Borders & shadows
42
- *
43
- * Neutral surfaces carry a subtle hairline border by default (so a card reads as a
44
- * card with no component help); it's applied through `:where()`, so a bar that
45
- * wants only a divider overrides it trivially. Any surface can opt into elevation
46
- * with `.shadow` or `.extra-shadow`, or drop a component's built-in shadow with
47
- * `.no-shadow` (e.g. `S.button({ attrs: ".no-shadow" })`).
48
- *
49
- * # Customising
50
- *
51
- * Re-skin by overriding the colour tokens (e.g. `--s-primary`). To add your own
52
- * accent surface, just set `--s-bg` (and, if needed, `--s-text`) — the gradient
53
- * and the rest of the tokens follow automatically:
15
+ * Every surface (and `:root`) defines the inherited tokens widgets style against,
16
+ * so they adapt to wherever they're nested: `--s-bg`, `--s-text` (also applied as
17
+ * `color`), `--s-muted` (secondary text), `--s-accent` (the surface's "pop" — the
18
+ * brand primary on neutral surfaces, the ink on accent ones) and `--s-faint`
19
+ * (hairline). The brand/semantic colours — `--s-primary`, `--s-danger`,
20
+ * `--s-success`, `--s-warning`, `--s-link` — are mode-independent and settable;
21
+ * overriding them re-skins the app. A custom accent surface needs only `--s-bg`
22
+ * (and, if needed, `--s-text`); gradient and tokens follow:
54
23
  *
55
24
  * ```ts
56
25
  * A.insertGlobalCss({ ".s-s.brand": "--s-bg:#ef6b00 --s-text:#fff" });
@@ -60,10 +29,9 @@ import A from "aberdeen";
60
29
 
61
30
  /**
62
31
  * The subtle single-colour wash a surface is painted with, as a `background:`
63
- * declaration, at the given angle. Shared constants rather than a CSS custom
64
- * property, deliberately: `var()`s inside a custom property resolve where the
65
- * property is *defined*, so a `--s-sheen` at `:root` would paint every surface
66
- * with the page's wash instead of its own `$s-bg`'s.
32
+ * declaration, at the given angle. A shared constant rather than a `--s-sheen`
33
+ * custom property: `var()`s inside a custom property resolve where it is
34
+ * *defined*, so every surface would get the page's wash instead of its own.
67
35
  */
68
36
  const sheen = (angle: string) =>
69
37
  `background: linear-gradient(${angle}, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%));`;
@@ -72,16 +40,10 @@ const sheen = (angle: string) =>
72
40
  export const SURFACE_SHEEN = sheen("170deg");
73
41
 
74
42
  /**
75
- * The same wash, straight down — for panels.ts, where the routed columns and
76
- * the ground beside them have to look like one continuous surface.
77
- *
78
- * A gradient at an angle takes its extent from the box's *width* as well as its
79
- * height, so a 430px column and the 1720px region behind it would paint
80
- * different slices of the same wash and meet at a visible step. Straight down,
81
- * the extent is the height alone — which every column shares exactly with the
82
- * region (a column is `top:0 bottom:0` in it) and so with every other column.
83
- * The 10° of tilt is worth losing there; it buys the one place in the app where
84
- * boxes of *different widths* must be seamless.
43
+ * The same wash, straight down — for panels.ts, where the routed columns and the
44
+ * ground beside them have to look like one continuous surface. An angled gradient
45
+ * takes its extent from the box's *width* as well as its height, so boxes of
46
+ * different widths would paint different slices of it and meet at a visible step.
85
47
  */
86
48
  export const PANEL_SHEEN = sheen("180deg");
87
49
 
@@ -129,13 +91,9 @@ export function getDarkMode(allowAuto = false): boolean | undefined {
129
91
  return v === undefined && !allowAuto ? A.darkMode() : v;
130
92
  }
131
93
 
132
- // ---------------------------------------------------------------------------
133
- // The only mode-dependent thing: the neutral surface shades and their ink,
134
- // written straight onto the surfaces (no intermediate palette vars). `:root` is
135
- // the page (depth 0); each nested `.neutral` steps one shade up, capped at the
136
- // `.neutral .neutral` rule. The accent (coloured) surfaces and everything else are
137
- // mode-independent and live in the static block below.
138
- // ---------------------------------------------------------------------------
94
+ // The only mode-dependent thing: the neutral shades and their ink, written
95
+ // straight onto the surfaces (no intermediate palette vars). `:root` is the page
96
+ // (depth 0); each nested `.neutral` steps a shade up, capped at the second level.
139
97
  A(() => {
140
98
  if (getDarkMode()) {
141
99
  A.insertGlobalCss({
@@ -152,37 +110,29 @@ A(() => {
152
110
  }
153
111
  });
154
112
 
155
- // ---------------------------------------------------------------------------
156
- // Static structure — inserted once. The colour tokens are mode-independent
157
- // (saturated fills that carry white ink on either background); shape/effect
158
- // tokens are single values kept only because they're reused across components.
159
- // Rule order matters: role fills come after the `:not(.neutral)` default, so a
160
- // caller's `attrs` override wins at equal specificity.
161
- // ---------------------------------------------------------------------------
113
+ // Static structure — inserted once, mode-independent. Rule order matters: role
114
+ // fills come after the `:not(.neutral)` default, so a caller's `attrs` override
115
+ // wins at equal specificity.
162
116
 
163
117
  A.setSpacingCssVars(1.1);
164
118
 
165
119
  A.insertGlobalCss({
166
- // What follows is a lightweight CSS reset. Semantic HTML should keep working, but less ugly/with some reasonable defaults.
120
+ // A lightweight reset: bare semantic HTML, with less ugly defaults.
167
121
  "*, *::before, *::after": "box-sizing:border-box",
168
122
  html: "text-size-adjust:100%",
169
123
  body: "m:0 p:$3 line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased background-color:$s-bg text:$s-text",
170
- // Links resolve to the contextual link foreground: the link colour on nesting
171
- // surfaces, the ink on accent surfaces.
124
+ // The contextual link colour: `--s-link` on neutral surfaces, the ink on accent ones.
172
125
  a: "color: $s-link-fg; text-decoration:underline text-underline-offset:2px; transition: color 0.12s, filter 0.12s;",
173
126
  "a:hover": "filter: brightness(1.15)",
174
127
  "input, button, textarea, select, optgroup": "font:inherit color:inherit",
175
- // Bare text-like fields get a calm bordered box derived from the surface. The
176
- // styled controls (`.s-input`, autocomplete's `.s-control`) override this via
177
- // higher specificity, so this only governs otherwise-unstyled HTML. `:where()`
178
- // keeps it at element specificity, so a component class always wins.
128
+ // Bare text-like fields get a calm bordered box derived from the surface.
129
+ // `:where()` keeps it at element specificity, so a component class always wins.
179
130
  "input:where(:not([type=checkbox],[type=radio],[type=range],[type=file],[type=color],[type=image],[type=submit],[type=button],[type=reset],[type=hidden])), textarea, select":
180
131
  "background:$s-bg border: 1px solid $s-faint; r:$s-radius-sm padding: 0.45em 0.65em; max-width:100%",
181
- // Checkboxes/radios: a touch larger with a pointer cursor. (The brand accent —
182
- // `accent-color` — is inherited from the surface, see the `:root, .s-s` rule.)
132
+ // Checkboxes/radios: a touch larger, with a pointer cursor. (`accent-color` is
133
+ // inherited from the surface, see the `:root, .s-s` rule.)
183
134
  "input:where([type=checkbox],[type=radio])": "width:1.15em height:1.15em cursor:pointer",
184
- // Range: a thin pill track (faint, brand-filled on the lower side in Firefox)
185
- // with a round brand thumb — no native groove/outline.
135
+ // Range: a thin faint pill track with a round brand thumb — no native groove.
186
136
  "input[type=range]": "appearance:none background:transparent cursor:pointer vertical-align:middle",
187
137
  "input[type=range]::-webkit-slider-runnable-track": "height:4px r:99px background:$s-faint",
188
138
  "input[type=range]::-moz-range-track": "height:4px r:99px background:$s-faint",
@@ -223,12 +173,11 @@ A.insertGlobalCss({
223
173
  // Brand sweep for the headline mark, the active nav pill, the selected tab.
224
174
  "--s-gradient: linear-gradient(135deg, color-mix(in oklab, $s-primary, white 16%), color-mix(in oklab, $s-primary, black 14%));",
225
175
 
226
- // Neutral surfaces (and the page): the pop colour is the brand primary, and
227
- // links use the link colour. (The bg/ink come from the mode block above.)
176
+ // Neutral surfaces (and the page); their bg/ink come from the mode block above.
228
177
  ":root, .s-s.neutral": "--s-accent:$s-primary --s-link-fg:$s-link",
229
178
 
230
- // Accent surfaces: bright fill, white ink. The bare `:not(.neutral)` carries the
231
- // shared defaults (with a primary fallback fill); each role names its own fill.
179
+ // Accent surfaces: bright fill, white ink. The bare `:not(.neutral)` holds the
180
+ // shared defaults; each role below names its own fill.
232
181
  ".s-s:not(.neutral)":
233
182
  "--s-bg:$s-primary " + // Default .s-s to .primary
234
183
  "border:0 " +
@@ -241,8 +190,8 @@ A.insertGlobalCss({
241
190
  ".s-s.link": "--s-bg:$s-link",
242
191
  ".s-s.primary": "--s-bg:$s-primary",
243
192
 
244
- // Shared derive: every surface (and the page) gets a muted ink + hairline from
245
- // its resolved text/bg pair, plus `color` and themed scrollbars.
193
+ // Every surface (and the page) derives its muted ink + hairline from the
194
+ // text/bg pair it resolved to above.
246
195
  ":root, .s-s":
247
196
  "--s-muted: color-mix(in oklab, $s-text, $s-bg 42%); " +
248
197
  "--s-faint: color-mix(in oklab, $s-text, $s-bg 80%); " +
@@ -250,55 +199,44 @@ A.insertGlobalCss({
250
199
  // Subtle single-colour gradient sheen, painted on every surface (and the page).
251
200
  ".s-s, body": SURFACE_SHEEN,
252
201
  ".s-s": "r:$s-radius",
253
- // Neutral surfaces own a subtle hairline border (a card reads as a card without
254
- // any component help). `:where()` keeps it zero-specificity, so a bar/panel that
255
- // wants only a divider (a box header, the app top bar, the nav panel) overrides
256
- // it with a single plain rule. Accent (filled) surfaces don't get it — their fill
257
- // is the edge. Buttons keep their own `border:0`.
202
+ // A neutral surface owns a hairline border, so a card reads as a card without
203
+ // any component help. `:where()` keeps it zero-specificity, so a bar that wants
204
+ // only a divider overrides it with a single plain rule.
258
205
  ":where(.s-s.neutral)": "border: 1px solid $s-faint;",
259
206
  ".s-s::-webkit-scrollbar, .s-s ::-webkit-scrollbar": "width:10px height:10px",
260
207
  ".s-s::-webkit-scrollbar-track, .s-s ::-webkit-scrollbar-track": "background:transparent",
261
208
  ".s-s::-webkit-scrollbar-thumb, .s-s ::-webkit-scrollbar-thumb":
262
209
  "background:$s-faint border-radius:99px border: 2px solid transparent; background-clip:padding-box",
263
210
 
264
- // Elevation utilities — add `.shadow` or `.extra-shadow` to any surface:
265
- // • neutral surface → a neutral drop shadow
266
- // • accent (filled) surface → a self-coloured glow, keyed on its own --s-bg (a
267
- // lit button is just this on a `.primary` surface)
268
- // • tonal/outlined surface → ignored (a translucent body has nothing to lift)
269
- // A `.neutral` button stays flat (so segmented groups gain no stray
270
- // shadows). `.no-shadow` is a hard override of any of the above — place it last
271
- // and make it !important so it beats the higher-specificity glow rule.
211
+ // Elevation: `.shadow`/`.extra-shadow` give a neutral surface a drop shadow and
212
+ // an accent one a self-coloured glow; tonal/outlined have nothing to lift. A
213
+ // `.neutral` button stays flat, so segmented groups gain no stray shadows.
214
+ // `.no-shadow` comes last and needs `!important` to beat the glow rules.
272
215
  ".s-s.shadow.neutral:not(.s-btn)": "box-shadow: 0 4px 14px rgba(0,0,0,0.13);",
273
216
  ".s-s.extra-shadow.neutral:not(.s-btn)": "box-shadow: 0 18px 50px rgba(0,0,0,0.28);",
274
217
  ".s-s.shadow:not(.neutral):not(.tonal):not(.outlined)": "box-shadow: 0 4px 14px color-mix(in srgb, $s-bg 30%, transparent);",
275
218
  ".s-s.extra-shadow:not(.neutral):not(.tonal):not(.outlined)": "box-shadow: 0 14px 40px color-mix(in srgb, $s-bg 40%, transparent);",
276
219
  ".s-s.no-shadow": "box-shadow: none !important;",
277
220
 
278
- // Accent variants. `tonal`: the fill colour becomes the ink over a soft
279
- // self-tint. `outlined`: the fill colour is the ink over a transparent body
280
- // with a colour edge. (Neutral surfaces ignore these.)
221
+ // Accent variants: the fill colour becomes the ink, over a soft self-tint
222
+ // (`tonal`) or a transparent body with a colour edge (`outlined`).
281
223
  ".s-s:not(.neutral).tonal, .s-s:not(.neutral).outlined":
282
224
  "--s-text:$s-bg --s-accent:$s-bg --s-link-fg:$s-bg --s-faint: color-mix(in srgb, $s-bg 30%, transparent); --s-muted: color-mix(in srgb, $s-bg 70%, transparent);",
283
225
  ".s-s:not(.neutral).tonal":
284
226
  "background: color-mix(in srgb, $s-bg 15%, transparent); border: 1px solid $s-faint;",
285
227
  ".s-s:not(.neutral).outlined":
286
228
  "background: transparent; border: 1px solid color-mix(in srgb, $s-bg 45%, transparent);",
287
- // A surface nested inside an accent surface is forced back to a filled look —
288
- // a translucent tonal/outlined body would bleed into the vivid parent fill.
289
- // Specificity (4 classes) beats the 2-class variant rules — no !important.
229
+ // A surface inside an accent surface is forced back to filled: a translucent
230
+ // body would bleed into the vivid parent. 4 classes beats the variant rules.
290
231
  ".s-s:not(.neutral) .s-s.tonal, .s-s:not(.neutral) .s-s.outlined":
291
232
  "--s-text:#fff --s-accent:#fff --s-link-fg:#fff " +
292
233
  SURFACE_SHEEN + " border-color: transparent;",
293
234
  });
294
235
 
295
236
  // ── Suppress transitions during the initial load ─────────────────────────────
296
- // Buttons (and links) transition their colours, so a light↔dark switch animates
297
- // smoothly. On a *cold* load that's a liability: elements mount and paint in the
298
- // active theme in one pass, but a transitioning element animates from its
299
- // default (unstyled) colours into the theme. Tag <html> until the first frame
300
- // has painted and hard-disable transitions under that tag, so the initial render
301
- // snaps straight to the right colours.
237
+ // Colour transitions make a light↔dark switch smooth, but on a cold load they'd
238
+ // animate from the unstyled colours into the theme. Tag <html> until the first
239
+ // frame has painted, so the initial render snaps to the right colours.
302
240
  A.insertGlobalCss({
303
241
  ".s-preload, .s-preload *, .s-preload *::before, .s-preload *::after":
304
242
  "transition: none !important; animation: none !important;",
@@ -310,19 +248,16 @@ if (typeof document !== "undefined" && typeof requestAnimationFrame === "functio
310
248
  }
311
249
 
312
250
  // ── Disabled region ───────────────────────────────────────────────────────────
313
- // aria-disabled="true" on any container dims it and blocks pointer events on
314
- // it and all descendants, matching the per-element disabled look. Keyboard
315
- // access to focusable descendants is unaffected — add the `inert` attribute too
316
- // if you need that.
251
+ // aria-disabled="true" on any container dims it and blocks pointer events on it
252
+ // and all descendants. Keyboard access is unaffected — add `inert` for that.
317
253
  A.insertGlobalCss({
318
254
  ":disabled, [aria-disabled=true]": "opacity:0.45 filter:saturate(0.6) user-select:none",
319
255
  ":disabled, [aria-disabled=true], :disabled *, [aria-disabled=true] *": "pointer-events:none cursor:not-allowed",
320
256
  });
321
257
 
322
258
  // ── Flow content: vertical rhythm & light typography ─────────────────────────
323
- // Sensible block defaults for *any* content — your own UI just as much as
324
- // markdown-rendered HTML. The rhythm: strip the browser's block margins, then
325
- // give every block a *top* margin only when it isn't its parent's first child.
259
+ // Block defaults for *any* content, markdown-rendered or your own: no browser
260
+ // block margins, but a *top* margin unless the block is its parent's first child.
326
261
  const BLOCK = "p, ul, ol, dl, blockquote, pre, table, figure, hr, h1, h2, h3, h4, h5, h6";
327
262
 
328
263
  A.insertGlobalCss({
@@ -1,10 +0,0 @@
1
- ## Attributes · type
2
-
3
- Shared building blocks for the Staffa component library.
4
-
5
- Every component in Staffa is "just an Aberdeen draw function": a plain function
6
- that takes a single, strongly typed options object and emits DOM through
7
- Aberdeen's `A` function. This module defines the option-type hierarchy
8
- that all components build on, plus a couple of tiny helpers.
9
-
10
- **Type:** `string`