staffa 0.6.1 → 0.7.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 (60) hide show
  1. package/README.md +34 -16
  2. package/dist/components/autocomplete.js +12 -9
  3. package/dist/components/box.js +15 -10
  4. package/dist/components/button.d.ts +3 -1
  5. package/dist/components/button.js +21 -28
  6. package/dist/components/buttonChooser.js +1 -1
  7. package/dist/components/buttonGroup.d.ts +3 -3
  8. package/dist/components/buttonGroup.js +3 -3
  9. package/dist/components/dialog.d.ts +3 -1
  10. package/dist/components/dialog.js +20 -10
  11. package/dist/components/field.js +7 -4
  12. package/dist/components/main.js +52 -15
  13. package/dist/components/menu.d.ts +23 -2
  14. package/dist/components/menu.js +54 -29
  15. package/dist/components/select.js +1 -1
  16. package/dist/components/tabs.js +3 -3
  17. package/dist/components/toast.js +8 -8
  18. package/dist/components/tooltip.js +4 -3
  19. package/dist/core.d.ts +14 -2
  20. package/dist/core.js +23 -0
  21. package/dist/staffa.esm.js +1 -1
  22. package/dist/theme.js +138 -224
  23. package/package.json +1 -1
  24. package/skill/ButtonOptions.md +2 -1
  25. package/skill/SKILL.md +108 -47
  26. package/skill/ToastOptions.md +1 -1
  27. package/skill/addContextMenu.md +28 -0
  28. package/skill/addTooltip.md +6 -2
  29. package/skill/autocomplete.md +9 -2
  30. package/skill/box.md +1 -0
  31. package/skill/button.md +5 -3
  32. package/skill/buttonGroup.md +5 -3
  33. package/skill/checkbox.md +1 -0
  34. package/skill/confirm.md +1 -1
  35. package/skill/dialog.md +2 -2
  36. package/skill/form.md +3 -2
  37. package/skill/main.md +5 -1
  38. package/skill/menuButton.md +1 -1
  39. package/skill/prompt.md +1 -1
  40. package/skill/select.md +1 -0
  41. package/skill/showFloatingMenu.md +5 -6
  42. package/skill/tabs.md +2 -2
  43. package/skill/textarea.md +3 -2
  44. package/skill/textline.md +1 -0
  45. package/skill/toast.md +2 -3
  46. package/src/components/autocomplete.ts +12 -9
  47. package/src/components/box.ts +15 -10
  48. package/src/components/button.ts +23 -32
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/buttonGroup.ts +3 -3
  51. package/src/components/dialog.ts +22 -11
  52. package/src/components/field.ts +7 -4
  53. package/src/components/main.ts +47 -16
  54. package/src/components/menu.ts +73 -40
  55. package/src/components/select.ts +1 -1
  56. package/src/components/tabs.ts +3 -3
  57. package/src/components/toast.ts +8 -8
  58. package/src/components/tooltip.ts +4 -3
  59. package/src/core.ts +30 -2
  60. package/src/theme.ts +152 -234
package/skill/SKILL.md CHANGED
@@ -13,26 +13,31 @@ import * as S from "staffa";
13
13
 
14
14
  const $user = A.proxy({ name: "", email: "" });
15
15
 
16
- A.mount(document.body, () => {
17
- S.main({
18
- title: "Sign up",
19
- maxWidth: "40rem",
20
- content: () => {
21
- S.form({
22
- submit: () => console.log(A.unproxy($user)),
23
- content: () => {
24
- S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
25
- S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
26
- },
27
- actions: () => S.button({ text: "Create account", type: "submit" }),
28
- });
29
- },
30
- });
16
+ S.main({
17
+ title: "Sign up",
18
+ maxWidth: "40rem",
19
+ content: () => {
20
+ S.form({
21
+ submit: () => S.dialog({
22
+ header: "Submitted",
23
+ content: () => A.dump($user)
24
+ }),
25
+ content: () => {
26
+ S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
27
+ S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
28
+ },
29
+ actions: () => S.button({ content: "Create account", type: "submit" }),
30
+ });
31
+ },
31
32
  });
32
33
  ```
33
34
 
34
35
  Staffa is made to look decent out of the box, but easily customizable at runtime.
35
36
 
37
+ ## Screenshot
38
+
39
+ ![Screenshot](screenshot.png)
40
+
36
41
  ## Install
37
42
 
38
43
  ```sh
@@ -48,7 +53,7 @@ Aberdeen is a peer dependency. Staffa is published as ESM with TypeScript types.
48
53
  Every component takes a single typed options object and draws DOM via Aberdeen. No classes, no web components. The `S` object collects all component functions:
49
54
 
50
55
  ```ts
51
- S.button({ text: "Save", disabled: false });
56
+ S.button({ content: "Save", disabled: false });
52
57
  S.box({ header: "Settings", content: () => { ... } });
53
58
  ```
54
59
 
@@ -57,10 +62,11 @@ S.box({ header: "Settings", content: () => { ... } });
57
62
  All components get their options in a typed object. The object may be an Aberdeen proxy, if you want to update the component in-place.
58
63
 
59
64
  ```ts
60
- const $btn = A.proxy({ text: "Save", disabled: false });
65
+ const $btn = A.proxy({ content: "Save", disabled: false });
61
66
  S.button($btn);
62
- // ...later:
63
- $btn.disabled = true; // button updates instantly
67
+ setTimeout(() => // Later..
68
+ $btn.disabled = true; // button updates instantly
69
+ }, 3000);
64
70
  ```
65
71
 
66
72
  ### Rich text slots
@@ -68,26 +74,35 @@ $btn.disabled = true; // button updates instantly
68
74
  Anywhere a component takes content, a `label`, `header`, button `text`, dialog body, etc, you can pass either a string or a `() => void` draw function. Strings render as **rich text**: `*italic*`, `**bold**`, `` `code` ``, `[link](/path)`. All text is safely escaped.
69
75
 
70
76
  ```ts
71
- S.button({ text: "Save **now**" });
77
+ S.button({ content: "Save **now**" });
72
78
  S.box({ header: "See the [docs](/docs)", content: () => { ... } });
73
79
  ```
74
80
 
75
81
  ### Surfaces
76
82
 
77
- Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens. Add modifier classes to colour them:
83
+ Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens. There are two families:
78
84
 
79
- - **level**: `.base`, `.panel`, `.raised`
80
- - **role**: `.primary`, `.secondary`, `.gradient`, `.neutral`, `.danger`, `.success`, `.warning`
81
- - **variant**: `.filled`, `.tonal`, `.outlined`
85
+ - **Nesting surfaces** — `.nest` (and the implicit page at `:root`). A calm neutral whose shade steps automatically with nesting depth (page → panel raised, capped). Use them for cards, bars, popovers — anything that just holds content. No variants.
86
+ - **Solid surfaces** — `.primary`, `.danger`, `.success`, `.warning`, `.link` (a bare `.s-s` defaults to primary). A bright fill with white ink, painted as a subtle single-colour gradient. They take a **variant**: `.filled` (default), `.tonal`, or `.outlined`. A surface nested *inside* a solid surface is always rendered filled, so it can't bleed into the vivid parent.
82
87
 
83
- Components are built from these (`S.button` is a `.s-s.primary.filled`, `S.box` a `.s-s.panel`, etc.). Because component options include an optional `attrs` string, which has Aberdeen `A()` string semantics, you can easily override it:
88
+ Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a `.s-s.nest`, etc.). Because component options include an optional `attrs` string, which has Aberdeen `A()` string semantics, you can easily override it:
84
89
 
85
90
  ```ts
86
- S.button({ text: "Delete", attrs: ".danger" });
87
- S.box({ attrs: ".raised.outlined", content: () => { ... } });
91
+ S.button({ content: "Delete", attrs: ".danger" });
92
+ S.button({ content: "Cancel", attrs: ".nest" }); // neutral button
93
+ S.box({ attrs: ".primary", content: () => { ... } });
88
94
  ```
89
95
 
90
- Inside any surface, CSS variables are defined for suitable foreground colors (`$s-fg`, `$s-bg`, `$s-fg-muted`, `$s-border`, `$s-accent`, ...), with `color` defaulting to `$s-fg`. By using these, components has access to various foreground colors that will look regardless of the surface it is drawing on.
96
+ Inside any surface (including `:root`), CSS variables are defined for the background and a set of safe foreground colors: `$s-bg`, `$s-text` (also applied as `color`), `$s-muted`, `$s-accent` (the surface's "pop" — the brand primary on nesting surfaces, the ink on solid surfaces), and `$s-faint`. By using these, components adapt to wherever they're nested.
97
+
98
+ The colour tokens are mode-independent and settable: `$s-primary` (the one brand colour — it tints the neutrals and defines `.s-s.primary`), `$s-danger`, `$s-success`, `$s-warning`, and `$s-link` (the link colour, which is also the fill of the `.s-s.link` surface). Links render in `$s-link` on nesting surfaces and in the ink on solid surfaces.
99
+
100
+ **Borders & shadows.** Nesting surfaces carry a subtle hairline border on their own (so a card looks like a card without any component help). Any surface can be lifted with `.shadow` or `.extra-shadow`: on a nesting surface that's a neutral drop shadow, on a solid surface it's a self-coloured glow (a lit button is just a `.primary` surface with `.shadow`), and on `.tonal`/`.outlined` it's ignored. `.no-shadow` removes a component's built-in shadow:
101
+
102
+ ```ts
103
+ S.box({ attrs: ".extra-shadow", content: () => { ... } }); // a more raised card
104
+ S.button({ content: "Quiet", attrs: ".no-shadow" }); // drop the button glow
105
+ ```
91
106
 
92
107
  ### Dark and light modes
93
108
 
@@ -107,30 +122,30 @@ Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit
107
122
 
108
123
  ### Theming
109
124
 
110
- The first step in theming is just setting some CSS variables, most commonly the primary and secondary color. This can be done through CSS directly, or using Aberdeen:
125
+ The first step in theming is just setting some CSS variables. Everything derives from a single brand colour, `s-primary` (the neutral surface shades are tinted toward it too), so often that's all you need. This can be done through CSS directly, or using Aberdeen:
111
126
 
112
127
  ```ts
113
128
  A.cssVars["s-primary"] = "#fdda58";
114
- A.cssVars["s-secondary"] = "#cc5624";
115
129
  A.cssVars["s-danger"] = "#ee4422";
116
130
  A.cssVars["s-radius"] = "4px";
117
131
  ```
118
132
 
119
133
  See `src/theme.ts` for what other CSS variables are being used.
120
134
 
121
- If you need further customization, just add some CSS to override the default styling. For instance, to add your own surface type:
135
+ If you need further customization, just add some CSS to override the default styling. For instance, to add your own solid surface, set its background (and, if needed, its ink) — the subtle gradient and the rest of the tokens follow automatically:
122
136
 
123
137
  ```ts
124
- // In filled mode, 's-a' is the foreground and 's-b' is the background. "outlined" and "tonal" use the colors in different ways.
125
- A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#ef6b00"});
138
+ A.insertGlobalCss({".s-s.my-surface": "--s-bg:#ef6b00 --s-text:#fff"});
126
139
 
127
140
  S.button({
128
- text: "You'll want to click me",
141
+ content: "You'll want to click me",
129
142
  attrs: ".my-surface",
130
143
  click: () => S.alert("Good work!", {attrs: ".my-surface"})
131
144
  });
132
145
  ```
133
146
 
147
+ Custom surface class names may be anything (other than the built-in modifiers `.tonal`, `.outlined`, `.small`, `.large`). The `.tonal` and `.outlined` variants work on your surface for free.
148
+
134
149
  Note that when changing CSS like this, things *may* break if you upgrade Staffa. The recommended update strategy is therefore: don't!
135
150
 
136
151
  If you want to make changes that are dependent upon the current light/dark mode setting, rely on Aberdeen reactivity:
@@ -139,10 +154,10 @@ If you want to make changes that are dependent upon the current light/dark mode
139
154
  A(() => {
140
155
  if (S.getDarkMode()) {
141
156
  A.cssVars["s-primary"] = "#aa9944";
142
- A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#444444"});
157
+ A.insertGlobalCss({".s-s.my-surface": "--s-bg:#444444 --s-text:#fff"});
143
158
  } else {
144
159
  A.cssVars["s-primary"] = "#fdda58";
145
- A.insertGlobalCss({".s-s.my-surface": "--s-a:black --s-b:#cccccc"});
160
+ A.insertGlobalCss({".s-s.my-surface": "--s-bg:#cccccc --s-text:#000"});
146
161
  }
147
162
  });
148
163
  ```
@@ -182,14 +197,12 @@ Components share naming conventions for options: `attrs` (outermost element), `c
182
197
 
183
198
  Staffa ships the full [Lucide icon set](https://lucide.dev/icons/) as named exports. Import only the ones you use, so a bundler tree-shakes the rest (the whole set is ~82 kB gzipped):
184
199
 
185
- ```ts
186
- import { sparkles, bell } from "staffa/icons.js";
187
- ```
188
-
189
200
  Each icon is a draw function usable anywhere a slot is accepted (e.g. a button `icon`), or called directly. Customize per call, or globally via `setDefaults()`:
190
201
 
191
202
  ```ts
192
- S.button({ text: "Save", icon: bell });
203
+ import * as S from "staffa";
204
+ import { sparkles, bell } from "staffa/icons";
205
+ S.button({ content: "Save", icon: bell });
193
206
  sparkles({ size: "1.5em", color: "var(--s-primary)", strokeWidth: 1.5 });
194
207
  ```
195
208
 
@@ -197,7 +210,7 @@ Options: `size`, `color` (defaults to `currentColor`), `strokeWidth`, `cap`, `jo
197
210
 
198
211
  ### Other
199
212
 
200
- - **`S.menuButton(opts)` / `S.showFloatingMenu(opts)`**: menu actions and floating menus, with keyboard navigation and submenus.
213
+ - **`S.menuButton(opts)` / `S.addContextMenu(opts)` / `S.showFloatingMenu(opts)`**: dropdown menus from a button, right-click/long-press context menus, and the underlying floating menu primitive — with keyboard navigation.
201
214
  - **`S.toast(opts)`**: transient notification at the bottom of the viewport.
202
215
  - **`S.addTooltip(el, opts)`**: tooltip on hover, attached to an existing element.
203
216
 
@@ -237,7 +250,7 @@ Staffa is designed for extension. A component is simply a plain function taking
237
250
 
238
251
  3. **Reach for reactivity deliberately.** Pass option strings straight to `A` as positional args (the caller's scope). Only wrap a dedicated `A(() => ...)` scope where it matters: input elements (recreation loses focus), or large subtrees you don't want to redraw. Use `A.peek(() => ...)` when you need a value but must not subscribe.
239
252
 
240
- 4. **Build on surfaces.** Mark elements `.s-s` and add level/role/variant modifiers. Inside them, use the contextual foreground color CSS variables (`$s-fg`, `$s-bg`, `$s-border`, ...) so components adapt to wherever they're nested. Hard-coding colors in components shouldn't be needed, but if you must, make sure you set *both* foreground and background.
253
+ 4. **Build on surfaces.** Mark elements `.s-s` and add `.nest` (neutral) or a solid role (`.primary`, `.danger`, …) plus an optional variant. Inside them, use the contextual CSS variables (`$s-text`, `$s-bg`, `$s-muted`, `$s-accent`, `$s-faint`, ...) so components adapt to wherever they're nested. Hard-coding colors in components shouldn't be needed, but if you must, make sure you set *both* foreground and background.
241
254
 
242
255
  5. **No outer margins.** Components don't margin themselves; spacing is the parent's job. Content components set default `padding` on the content element; `contentAttrs` overrides it.
243
256
 
@@ -276,6 +289,40 @@ npx shotest review # review/accept the visual changes against the baseline
276
289
 
277
290
  The visual tests (`tests/*.spec.ts`) need a build first (`npm run build`); they serve the repo root themselves and click through every demo page. Accepted baselines live in `test-accepted/`.
278
291
 
292
+ ## AI skill
293
+
294
+ If you use Claude Code, GitHub Copilot or another AI agents that supports Skills, Staffa includes a `skill/` directory that provides specialized knowledge to the AI about how to use the library effectively.
295
+
296
+ To use this, it is recommended to symlink the skill into your project's `.claude/skills` directory:
297
+
298
+ ```sh
299
+ mkdir -p .claude/skills
300
+ ln -s ../../node_modules/staffa/skill .claude/skills/staffa
301
+ ```
302
+
303
+ ## Breaking changes
304
+
305
+ - **0.7** — the surface model was simplified to two families: **nesting** (`.nest`) and **solid** (`.primary`/`.danger`/`.success`/`.warning`/`.link`).
306
+ - Surface levels `.base`/`.panel`/`.raised`/`.neutral` are replaced by a single `.nest` class whose shade steps with nesting depth. Replace them all with `.nest` (a neutral *button* is `.nest` too, in place of `.neutral .outlined`).
307
+ - `.secondary` and `.gradient` are gone: there is no more `s-secondary` colour, and the default button is `.primary` (which now carries a subtle auto-gradient). Drop `s-secondary` overrides.
308
+ - Solid surfaces now use **white** ink (was near-black), and the brand/semantic colours (`s-primary`/`s-danger`/`s-success`/`s-warning`) are now a single mode-independent value each.
309
+ - New: a `s-link` colour and a matching `.s-s.link` surface. Links use `s-link` on nesting surfaces, the ink on solid ones.
310
+ - Nesting surfaces now carry their own hairline border, and `.shadow`/`.extra-shadow`/`.no-shadow` utilities work on any surface. `S.box` no longer draws its own border/shadow (they come from the surface); pass `.no-shadow` to `S.box`/`S.button` to drop the default elevation.
311
+ - Contextual tokens were renamed/trimmed: `--s-fg`→`--s-text`, `--s-fg-muted`→`--s-muted`, `--s-border`→`--s-faint`; `--s-strong`, `--s-fg-faint`, `--s-border-strong`, `--s-on-accent`, `--s-ink`, `--s-page`/`--s-panel`/`--s-raised`, `--s-tint`, `--s-glow`, `--s-shadow`, `--s-gradient-surface`, and the `--s-a`/`--s-b` anchors were removed. Custom solid surfaces now set `--s-bg`/`--s-text`.
312
+
313
+ - **0.6**: None.
314
+
315
+ - **0.5**
316
+ - Surfaces (`.s-s`) now apply `border-radius` and — for `.tonal` and `.outlined` variants — `border` automatically. Custom surfaces or components that previously set these manually may see doubled or conflicting styles; remove the manual declarations.
317
+ - `border:0` is now applied to `.s-btn` by default (overriding the browser's 2px button border). Custom button-like components built on `.s-btn` that relied on the browser default border should add an explicit border.
318
+
319
+ - **0.4**
320
+ - There is no default export anymore: replace `import S from "staffa"` with `import * as S from "staffa"`.
321
+ - `S.button` no longer has a `text` option: use `content` instead (it accepts a string or a draw function).
322
+ - The `Content` type is gone: use `Slot` instead. The `Styling` type alias is now exported as `Attributes`.
323
+ - `S.buttonChooser` uses `undefined` instead of `null` for "nothing selected" (in `bind` and with `allowDeselect`).
324
+
325
+
279
326
  # API Reference
280
327
 
281
328
  ## setDarkMode · function
@@ -321,8 +368,8 @@ Options for `box`.
321
368
 
322
369
  ## [button](button.md) · function
323
370
 
324
- A button. Always carries at least a visible border so its affordance is
325
- obvious at a glance.
371
+ A button. Tonal and outlined variants show a border; filled variants rely on
372
+ their solid background for affordance.
326
373
 
327
374
  ## [ButtonOptions](ButtonOptions.md) · interface
328
375
 
@@ -391,6 +438,13 @@ Open a floating dropdown menu anchored to an element. Portals to
391
438
  no room below), and closes on Escape, Tab, item selection, or any click
392
439
  outside the panel and anchor. Returns a `close()` function.
393
440
 
441
+ ## [addContextMenu](addContextMenu.md) · function
442
+
443
+ Attaches a context menu to the current element: adds a `contextmenu` handler
444
+ via `A` so a | floating menu opens (instead of
445
+ the browser's own menu) on right-click or long-press. The menu is anchored to
446
+ the element and closes on Escape, Tab, item selection, or any click outside.
447
+
394
448
  ## [MenuOptions](MenuOptions.md) · interface
395
449
 
396
450
  Options for `menuButton` and `MainOptions.nav`.
@@ -421,6 +475,13 @@ A visual divider between groups of items.
421
475
 
422
476
  Options for `showFloatingMenu`.
423
477
 
478
+ ## ContextMenuOptions · type
479
+
480
+ Options for `addContextMenu` — like `FloatingMenuOptions`, but
481
+ the anchor is the element the handler is attached to.
482
+
483
+ **Type:** `Omit<FloatingMenuOptions, "anchor">`
484
+
424
485
  ## [dialog](dialog.md) · function
425
486
 
426
487
  A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
@@ -479,7 +540,7 @@ Options for `tabs`.
479
540
  ## [textarea](textarea.md) · function
480
541
 
481
542
  A multi-line text input. Shares the field chrome and styling of
482
- `textline`, adding `rows` and `resize` controls.
543
+ `textline`.
483
544
 
484
545
  ## [TextareaOptions](TextareaOptions.md) · interface
485
546
 
@@ -503,7 +564,7 @@ that need their own widget (`checkbox`, `radio`, `color`, `range`, `file`,
503
564
  ## [toast](toast.md) · function
504
565
 
505
566
  Show a toast notification. Returns a `dismiss()` function to remove it
506
- programmatically. Auto-dismisses after `duration` ms (default 4 000).
567
+ programmatically. Auto-dismisses after `duration` ms (default 6 000).
507
568
 
508
569
  ## [ToastOptions](ToastOptions.md) · interface
509
570
 
@@ -22,7 +22,7 @@ Colour role. Defaults to `"neutral"`.
22
22
 
23
23
  ### toastOptions.duration · member
24
24
 
25
- Auto-dismiss delay in milliseconds. Defaults to `4000`.
25
+ Auto-dismiss delay in milliseconds. Defaults to `6000`.
26
26
  Pass `0` to make the toast persistent until dismissed manually.
27
27
 
28
28
  **Type:** `number`
@@ -0,0 +1,28 @@
1
+ ## addContextMenu · function
2
+
3
+ Attaches a context menu to the current element: adds a `contextmenu` handler
4
+ via `A` so a | floating menu opens (instead of
5
+ the browser's own menu) on right-click or long-press. The menu is anchored to
6
+ the element and closes on Escape, Tab, item selection, or any click outside.
7
+
8
+ **Signature:** `(opts: ContextMenuOptions) => void`
9
+
10
+ **Parameters:**
11
+
12
+ - `opts: ContextMenuOptions`
13
+
14
+ **Examples:**
15
+
16
+ ```ts
17
+ import * as icons from "staffa/icons";
18
+
19
+ S.box(() => {
20
+ A("#Right-click / long-tap me!");
21
+ S.addContextMenu({
22
+ items: [
23
+ { label: "AI something", icon: icons.sparkles, click: () => ai() },
24
+ { label: "Launch missiles", icon: icons.rocket, click: () => launch() },
25
+ ],
26
+ });
27
+ });
28
+ ```
@@ -15,11 +15,15 @@ element's bounding rect and automatically flips when near the viewport edge.
15
15
  **Examples:**
16
16
 
17
17
  ```ts
18
- A("button #Save", () => {
18
+ S.button(() => {
19
+ A("#Save");
19
20
  S.addTooltip({ tip: "Saves your work to the cloud" });
20
21
  });
21
22
 
22
- A("button #Delete", () => {
23
+ A(" ");
24
+
25
+ S.button(() => {
26
+ A(".danger #Delete");
23
27
  S.addTooltip({ tip: "Dangerous — cannot be undone", placement: "bottom" });
24
28
  });
25
29
  ```
@@ -14,9 +14,16 @@ backspace-to-remove). Implements the ARIA combobox/listbox pattern.
14
14
 
15
15
  ```ts
16
16
  // Single select from a fixed list
17
+ const $sel = A.proxy("Netherlands");
17
18
  S.autocomplete({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
18
19
 
19
20
  // Multi-select, disallowing custom items
20
- S.autocomplete({ label: "Tags", multi: true, allowCustom: false,
21
- options: knownTags, bind: A.ref($post, "tags") });
21
+ const $tags = A.proxy({value: [] as string[]});
22
+ S.autocomplete({
23
+ label: "Tags",
24
+ multi: true,
25
+ allowCustom: false,
26
+ options: ["Rust", "JS", "C++", "Klingon", "Go"],
27
+ bind: A.ref($tags)
28
+ });
22
29
  ```
package/skill/box.md CHANGED
@@ -18,6 +18,7 @@ Shortcut: pass a function to use it directly as the body content.
18
18
  **Examples:**
19
19
 
20
20
  ```ts
21
+ const $user = A.proxy({name: "Kvothe"});
21
22
  S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", content: () => {
22
23
  S.textline({ label: "Name", bind: A.ref($user, "name") });
23
24
  }});
package/skill/button.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ## button · function
2
2
 
3
- A button. Always carries at least a visible border so its affordance is
4
- obvious at a glance.
3
+ A button. Tonal and outlined variants show a border; filled variants rely on
4
+ their solid background for affordance.
5
5
 
6
6
  Shortcut: pass a string to use it as the label, or a function for custom
7
7
  content.
@@ -9,6 +9,7 @@ content.
9
9
  **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
10
10
  startup) for SPA-style navigation without manual click handlers:
11
11
  ```ts
12
+ import {interceptLinks} from from "aberdeen/route";
12
13
  interceptLinks(); // once at root
13
14
  S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
14
15
  ```
@@ -22,7 +23,8 @@ S.button({ href: "/dashboard", content: "Dashboard" }); // navigates via router
22
23
  **Examples:**
23
24
 
24
25
  ```ts
25
- S.button({ content: "Save", click: save });
26
+ S.button({ content: "Save", click: S.alert("Saved.") });
27
+ S.button({ content: "Cancel", attrs: ".nest", click: cancel }); // neutral button
26
28
  S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
27
29
  S.button("Cancel"); // shorthand for { content: "Cancel" }
28
30
  S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
@@ -3,6 +3,8 @@
3
3
  Groups related buttons, either as a joined segmented control (`attached`) or
4
4
  spaced out. A `role=group` is applied for assistive tech.
5
5
 
6
+ If you want a single button to be *selected*, use `buttonChooser`.
7
+
6
8
  **Signature:** `(opts?: ButtonGroupOptions) => void`
7
9
 
8
10
  **Parameters:**
@@ -13,8 +15,8 @@ spaced out. A `role=group` is applied for assistive tech.
13
15
 
14
16
  ```ts
15
17
  S.buttonGroup({ buttons: [
16
- { text: "Day", attrs: ".neutral .outlined" },
17
- { text: "Week", attrs: ".neutral .outlined" },
18
- { text: "Month", attrs: ".neutral .outlined" },
18
+ { content: "Day", attrs: ".nest" },
19
+ { content: "Week", attrs: ".nest" },
20
+ { content: "Month", attrs: ".nest" },
19
21
  ]});
20
22
  ```
package/skill/checkbox.md CHANGED
@@ -13,5 +13,6 @@ reader support.
13
13
  **Examples:**
14
14
 
15
15
  ```ts
16
+ const $prefs = A.proxy({newsletter: true});
16
17
  S.checkbox({ label: "Subscribe to newsletter", bind: A.ref($prefs, "newsletter") });
17
18
  ```
package/skill/confirm.md CHANGED
@@ -13,5 +13,5 @@ Shows a confirmation dialog with Cancel and OK buttons. Returns a
13
13
  **Examples:**
14
14
 
15
15
  ```ts
16
- if (await S.confirm("Delete this item?")) deleteItem();
16
+ if (await S.confirm("Delete this item?")) S.alert("Gone!");
17
17
  ```
package/skill/dialog.md CHANGED
@@ -21,8 +21,8 @@ S.dialog({
21
21
  header: "Confirm",
22
22
  content: (close) => {
23
23
  A("p #Are you sure?");
24
- S.button({ content: "Yes", click: () => { doIt(); close(); } });
25
- S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
24
+ S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
25
+ S.button({ content: "Cancel", attrs: ".nest", click: close });
26
26
  },
27
27
  });
28
28
  ```
package/skill/form.md CHANGED
@@ -17,11 +17,12 @@ native validation runs, but the page never reloads.
17
17
  **Examples:**
18
18
 
19
19
  ```ts
20
+ const $user = A.proxy({name: "Darth", email: "d.vader@example.com"});
20
21
  S.form({
21
22
  submit: () => save(),
22
23
  content: () => {
23
- S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
24
- S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
24
+ S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
25
+ S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
25
26
  },
26
27
  actions: () => S.button({ content: "Save", type: "submit" }),
27
28
  });
package/skill/main.md CHANGED
@@ -27,7 +27,11 @@ S.main({
27
27
  },
28
28
  navPosition: "left",
29
29
  menu: () => S.button({ content: "New", attrs: ".small" }),
30
- content: () => drawPage(),
30
+ content: drawPage,
31
31
  footer: "© 2026",
32
32
  });
33
+
34
+ function drawPage() {
35
+ S.box({title: "Hello world", content: "Here's you app.."});
36
+ }
33
37
  ```
@@ -17,7 +17,7 @@ for SPA navigation — active items are highlighted automatically.
17
17
 
18
18
  ```ts
19
19
  S.menuButton({
20
- button: { text: "Actions", attrs: ".neutral .outlined" },
20
+ button: { content: "Actions", attrs: ".nest" },
21
21
  items: [
22
22
  { label: "Edit", icon: () => A("#✎"), click: () => edit() },
23
23
  { separator: true },
package/skill/prompt.md CHANGED
@@ -15,5 +15,5 @@ the entered string if the user confirmed, or `null` if cancelled.
15
15
 
16
16
  ```ts
17
17
  const name = await S.prompt("Enter your name:", "Alice");
18
- if (name !== null) greet(name);
18
+ if (name !== null) S.alert(`Hi ${name}!`);
19
19
  ```
package/skill/select.md CHANGED
@@ -13,5 +13,6 @@ mobile-native picker behaviour to the browser.
13
13
  **Examples:**
14
14
 
15
15
  ```ts
16
+ const $sel = A.proxy("Netherlands");
16
17
  S.select({ label: "Country", options: ["Belgium", "Netherlands"], bind: $sel });
17
18
  ```
@@ -5,6 +5,10 @@ Open a floating dropdown menu anchored to an element. Portals to
5
5
  no room below), and closes on Escape, Tab, item selection, or any click
6
6
  outside the panel and anchor. Returns a `close()` function.
7
7
 
8
+ Menus are usually opened through `menuButton` or
9
+ `addContextMenu`; reach for this primitive when you need to trigger a
10
+ menu from some other event, anchored to an arbitrary element.
11
+
8
12
  **Signature:** `(opts: FloatingMenuOptions) => () => void`
9
13
 
10
14
  **Parameters:**
@@ -14,9 +18,4 @@ outside the panel and anchor. Returns a `close()` function.
14
18
  **Examples:**
15
19
 
16
20
  ```ts
17
- // Custom context menu:
18
- el.addEventListener("contextmenu", (e) => {
19
- e.preventDefault();
20
- S.showFloatingMenu({ items, anchor: el });
21
- });
22
- ```
21
+ // An
package/skill/tabs.md CHANGED
@@ -13,7 +13,7 @@ for the selected tab. Supports keyboard navigation (left/right/home/end).
13
13
 
14
14
  ```ts
15
15
  S.tabs({ tabs: [
16
- { label: "Overview", content: () => A("p#...") },
17
- { label: "Settings", content: () => drawSettings() },
16
+ { label: "Overview", content: () => A("p#Let me give you an overview..") },
17
+ { label: "Settings", content: () => S.checkbox({label: "I agree to anything", checked: true}) },
18
18
  ]});
19
19
  ```
package/skill/textarea.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ## textarea · function
2
2
 
3
3
  A multi-line text input. Shares the field chrome and styling of
4
- `textline`, adding `rows` and `resize` controls.
4
+ `textline`.
5
5
 
6
6
  **Signature:** `(opts?: TextareaOptions) => void`
7
7
 
@@ -12,5 +12,6 @@ A multi-line text input. Shares the field chrome and styling of
12
12
  **Examples:**
13
13
 
14
14
  ```ts
15
- S.textarea({ label: "Bio", rows: 6, bind: A.ref($user, "bio") });
15
+ const $user = A.proxy({bio: ""});
16
+ S.textarea({ label: "Bio", bind: A.ref($user, "bio") });
16
17
  ```
package/skill/textline.md CHANGED
@@ -15,5 +15,6 @@ help/error), so it aligns cleanly inside a `form`.
15
15
  **Examples:**
16
16
 
17
17
  ```ts
18
+ const $user = A.proxy({email: "test@example.com"});
18
19
  S.textline({ label: "Email", type: "email", required: true, bind: A.ref($user, "email") });
19
20
  ```
package/skill/toast.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ## toast · function
2
2
 
3
3
  Show a toast notification. Returns a `dismiss()` function to remove it
4
- programmatically. Auto-dismisses after `duration` ms (default 4 000).
4
+ programmatically. Auto-dismisses after `duration` ms (default 6 000).
5
5
 
6
6
  **Signature:** `(opts: ToastOptions) => () => void`
7
7
 
@@ -15,6 +15,5 @@ programmatically. Auto-dismisses after `duration` ms (default 4 000).
15
15
  S.toast({ message: "Saved!", type: "success" });
16
16
  S.toast({ title: "Error", message: "Upload failed.", type: "danger", duration: 0 });
17
17
  const off = S.toast({ message: "Uploading…", duration: 0, dismissible: false });
18
- // later:
19
- off();
18
+ setTimeout(off, 3000); // Later..
20
19
  ```
@@ -35,20 +35,23 @@ export interface AutocompleteOptions extends FieldOptions {
35
35
  A.insertGlobalCss({
36
36
  ".s-ac": {
37
37
  "&": "position:relative",
38
- "> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 bg:$s-panel fg:$s-ink border: 1px solid $s-border; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
39
- "> .s-control:hover": "border-color:$s-border-strong",
38
+ // Same light inset field as `.s-input` (see field.ts), derived from the surface.
39
+ "> .s-control": "display:flex flex-wrap:wrap align-items:center gap:$1 background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
40
+ "> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
40
41
  "> .s-control:focus-within": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus;",
41
42
  "&[aria-invalid=true] > .s-control": "border-color:$s-danger",
42
- ".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg:$s-raised border: 1px solid $s-border; r:$s-radius padding: 0.1em 0.2em 0.1em 0.5em;",
43
- ".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-fg-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
44
- ".s-chip > button:hover": "fg:$s-fg background:$s-border",
43
+ ".s-chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em background: color-mix(in oklab, $s-bg, $s-text 10%); border: 1px solid $s-faint; r:$s-radius padding: 0.1em 0.2em 0.1em 0.5em;",
44
+ ".s-chip > button": "cursor:pointer border:0 background:transparent fg:$s-muted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
45
+ ".s-chip > button:hover": "fg:$s-text background:$s-faint",
45
46
  "input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
46
- "> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0 bg:$s-panel border: 1px solid $s-border; r:$s-radius box-shadow:$s-shadow",
47
+ // The popup is a `.s-s.neutral.shadow` surface (see below): background, border,
48
+ // radius and elevation all come from the surface.
49
+ "> .s-menu": "position:absolute top:100% left:0 right:0 z-index:20 margin-top:4px max-height:15rem overflow-y:auto list-style:none p:$1 margin-bottom:0",
47
50
  "> .s-menu li": "margin:0",
48
51
  ".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
49
- ".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-fg 10%, transparent);",
52
+ ".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
50
53
  ".s-add": "fg:$s-accent font-style:italic",
51
- ".s-empty": "padding: 0.45em 0.6em; fg:$s-fg-muted",
54
+ ".s-empty": "padding: 0.45em 0.6em; fg:$s-muted",
52
55
  },
53
56
  });
54
57
 
@@ -191,7 +194,7 @@ export function autocomplete(opts: AutocompleteOptions): void {
191
194
  const q = $st.query.trim();
192
195
  const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
193
196
 
194
- A("ul.s-menu role=listbox", `id=${menuId}`, () => {
197
+ A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
195
198
  list.forEach((option, i) => {
196
199
  A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
197
200
  A(() => A("aria-selected=", $st.active === i ? "true" : "false"));