staffa 0.6.1 → 0.7.1

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 (62) 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/checkbox.js +3 -2
  10. package/dist/components/dialog.d.ts +3 -1
  11. package/dist/components/dialog.js +20 -10
  12. package/dist/components/field.js +7 -4
  13. package/dist/components/main.js +52 -15
  14. package/dist/components/menu.d.ts +23 -2
  15. package/dist/components/menu.js +54 -29
  16. package/dist/components/select.js +1 -1
  17. package/dist/components/tabs.js +3 -3
  18. package/dist/components/toast.js +8 -8
  19. package/dist/components/tooltip.js +4 -3
  20. package/dist/core.d.ts +14 -2
  21. package/dist/core.js +23 -0
  22. package/dist/staffa.esm.js +1 -1
  23. package/dist/theme.js +165 -225
  24. package/package.json +2 -2
  25. package/skill/ButtonOptions.md +2 -1
  26. package/skill/SKILL.md +108 -47
  27. package/skill/ToastOptions.md +1 -1
  28. package/skill/addContextMenu.md +28 -0
  29. package/skill/addTooltip.md +6 -2
  30. package/skill/autocomplete.md +9 -2
  31. package/skill/box.md +1 -0
  32. package/skill/button.md +5 -3
  33. package/skill/buttonGroup.md +5 -3
  34. package/skill/checkbox.md +1 -0
  35. package/skill/confirm.md +1 -1
  36. package/skill/dialog.md +2 -2
  37. package/skill/form.md +3 -2
  38. package/skill/main.md +5 -1
  39. package/skill/menuButton.md +1 -1
  40. package/skill/prompt.md +1 -1
  41. package/skill/select.md +1 -0
  42. package/skill/showFloatingMenu.md +5 -6
  43. package/skill/tabs.md +2 -2
  44. package/skill/textarea.md +3 -2
  45. package/skill/textline.md +1 -0
  46. package/skill/toast.md +2 -3
  47. package/src/components/autocomplete.ts +12 -9
  48. package/src/components/box.ts +15 -10
  49. package/src/components/button.ts +23 -32
  50. package/src/components/buttonChooser.ts +1 -1
  51. package/src/components/buttonGroup.ts +3 -3
  52. package/src/components/checkbox.ts +3 -2
  53. package/src/components/dialog.ts +22 -11
  54. package/src/components/field.ts +7 -4
  55. package/src/components/main.ts +47 -16
  56. package/src/components/menu.ts +73 -40
  57. package/src/components/select.ts +1 -1
  58. package/src/components/tabs.ts +3 -3
  59. package/src/components/toast.ts +8 -8
  60. package/src/components/tooltip.ts +4 -3
  61. package/src/core.ts +30 -2
  62. package/src/theme.ts +180 -235
package/README.md CHANGED
@@ -13,7 +13,10 @@ S.main({
13
13
  maxWidth: "40rem",
14
14
  content: () => {
15
15
  S.form({
16
- submit: () => console.log(A.unproxy($user)),
16
+ submit: () => S.dialog({
17
+ header: "Submitted",
18
+ content: () => A.dump($user)
19
+ }),
17
20
  content: () => {
18
21
  S.textline({ label: "Name", required: true, bind: A.ref($user, "name") });
19
22
  S.textline({ label: "Email", type: "email", bind: A.ref($user, "email") });
@@ -72,20 +75,29 @@ S.box({ header: "See the [docs](/docs)", content: () => { ... } });
72
75
 
73
76
  ### Surfaces
74
77
 
75
- 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:
78
+ Staffa builds on **surfaces**: elements marked with `.s-s` that have their own background and derived text/border tokens. There are two families:
76
79
 
77
- - **level**: `.base`, `.panel`, `.raised`
78
- - **role**: `.primary`, `.secondary`, `.gradient`, `.neutral`, `.danger`, `.success`, `.warning`
79
- - **variant**: `.filled`, `.tonal`, `.outlined`
80
+ - **Neutral surfaces** — `.neutral` (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.
81
+ - **Accent 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* an accent surface is always rendered filled, so it can't bleed into the vivid parent.
80
82
 
81
- 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:
83
+ Components are built from these (`S.button` is a `.s-s.primary`, `S.box` a `.s-s.neutral`, etc.). Because component options include an optional `attrs` string, which has Aberdeen `A()` string semantics, you can easily override it:
82
84
 
83
85
  ```ts
84
86
  S.button({ content: "Delete", attrs: ".danger" });
85
- S.box({ attrs: ".raised.outlined", content: () => { ... } });
87
+ S.button({ content: "Cancel", attrs: ".neutral" }); // neutral button
88
+ S.box({ attrs: ".primary", content: () => { ... } });
86
89
  ```
87
90
 
88
- 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.
91
+ 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 neutral surfaces, the ink on accent surfaces), and `$s-faint`. By using these, components adapt to wherever they're nested.
92
+
93
+ 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 neutral surfaces and in the ink on accent surfaces.
94
+
95
+ **Borders & shadows.** Neutral 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 neutral surface that's a neutral drop shadow, on an accent 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:
96
+
97
+ ```ts
98
+ S.box({ attrs: ".extra-shadow", content: () => { ... } }); // a more raised card
99
+ S.button({ content: "Quiet", attrs: ".no-shadow" }); // drop the button glow
100
+ ```
89
101
 
90
102
  ### Dark and light modes
91
103
 
@@ -105,22 +117,20 @@ Staffa includes a lightweight CSS reset that makes bare semantic HTML look a bit
105
117
 
106
118
  ### Theming
107
119
 
108
- 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:
120
+ 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:
109
121
 
110
122
  ```ts
111
123
  A.cssVars["s-primary"] = "#fdda58";
112
- A.cssVars["s-secondary"] = "#cc5624";
113
124
  A.cssVars["s-danger"] = "#ee4422";
114
125
  A.cssVars["s-radius"] = "4px";
115
126
  ```
116
127
 
117
128
  See `src/theme.ts` for what other CSS variables are being used.
118
129
 
119
- If you need further customization, just add some CSS to override the default styling. For instance, to add your own surface type:
130
+ If you need further customization, just add some CSS to override the default styling. For instance, to add your own accent surface, set its background (and, if needed, its ink) — the subtle gradient and the rest of the tokens follow automatically:
120
131
 
121
132
  ```ts
122
- // In filled mode, 's-a' is the foreground and 's-b' is the background. "outlined" and "tonal" use the colors in different ways.
123
- A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#ef6b00"});
133
+ A.insertGlobalCss({".s-s.my-surface": "--s-bg:#ef6b00 --s-text:#fff"});
124
134
 
125
135
  S.button({
126
136
  content: "You'll want to click me",
@@ -129,6 +139,8 @@ S.button({
129
139
  });
130
140
  ```
131
141
 
142
+ 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.
143
+
132
144
  Note that when changing CSS like this, things *may* break if you upgrade Staffa. The recommended update strategy is therefore: don't!
133
145
 
134
146
  If you want to make changes that are dependent upon the current light/dark mode setting, rely on Aberdeen reactivity:
@@ -137,10 +149,10 @@ If you want to make changes that are dependent upon the current light/dark mode
137
149
  A(() => {
138
150
  if (S.getDarkMode()) {
139
151
  A.cssVars["s-primary"] = "#aa9944";
140
- A.insertGlobalCss({".s-s.my-surface": "--s-a:white --s-b:#444444"});
152
+ A.insertGlobalCss({".s-s.my-surface": "--s-bg:#444444 --s-text:#fff"});
141
153
  } else {
142
154
  A.cssVars["s-primary"] = "#fdda58";
143
- A.insertGlobalCss({".s-s.my-surface": "--s-a:black --s-b:#cccccc"});
155
+ A.insertGlobalCss({".s-s.my-surface": "--s-bg:#cccccc --s-text:#000"});
144
156
  }
145
157
  });
146
158
  ```
@@ -233,7 +245,7 @@ Staffa is designed for extension. A component is simply a plain function taking
233
245
 
234
246
  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.
235
247
 
236
- 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.
248
+ 4. **Build on surfaces.** Mark elements `.s-s` and add `.neutral` or an accent 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.
237
249
 
238
250
  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.
239
251
 
@@ -285,6 +297,12 @@ ln -s ../../node_modules/staffa/skill .claude/skills/staffa
285
297
 
286
298
  ## Breaking changes
287
299
 
300
+ - **0.7** — the surface model was reduced to two families: **neutral** (`.neutral`) and **accent** (`.primary`/`.danger`/`.success`/`.warning`/`.link`). Apps that only use the high-level `S.*` components need no changes. Code that uses surface classes or tokens directly must update:
301
+ - **Surface levels gone.** Replace `.base`/`.panel`/`.raised`/`.neutral`/`.nest` with the single `.neutral` class.
302
+ - **`.secondary` and `.gradient` gone.** Drop any `s-secondary` colour override; there's no `s-secondary` anymore. The default button is now `.primary`.
303
+ - **Tokens renamed.** `--s-fg`→`--s-text`, `--s-fg-muted`→`--s-muted`, `--s-border`→`--s-faint`. Removed: `--s-fg-faint`, `--s-border-strong`, `--s-ink`, `--s-on-accent`, `--s-page`/`--s-panel`/`--s-raised`, `--s-neutral`, `--s-tint`, `--s-glow`, `--s-shadow`, `--s-gradient-surface`. A custom surface now sets `--s-bg`/`--s-text` (was the `--s-a`/`--s-b` anchors).
304
+ - **Borders/shadows moved onto surfaces.** Components no longer draw their own border/shadow. If you relied on `S.box`/`S.dialog`/etc. elevation, it now comes from the surface; pass `.no-shadow` to drop it, or `.shadow`/`.extra-shadow` to add it on any surface.
305
+
288
306
  - **0.6**: None.
289
307
 
290
308
  - **0.5**
@@ -4,20 +4,23 @@ import { drawField } from "./field.js";
4
4
  A.insertGlobalCss({
5
5
  ".s-ac": {
6
6
  "&": "position:relative",
7
- "> .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;",
8
- "> .s-control:hover": "border-color:$s-border-strong",
7
+ // Same light inset field as `.s-input` (see field.ts), derived from the surface.
8
+ "> .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;",
9
+ "> .s-control:hover": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
9
10
  "> .s-control:focus-within": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus;",
10
11
  "&[aria-invalid=true] > .s-control": "border-color:$s-danger",
11
- ".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;",
12
- ".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",
13
- ".s-chip > button:hover": "fg:$s-fg background:$s-border",
12
+ ".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;",
13
+ ".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",
14
+ ".s-chip > button:hover": "fg:$s-text background:$s-faint",
14
15
  "input": "flex:1 min-width:6ch border:0 background:transparent color:inherit outline:none padding:0.25em",
15
- "> .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",
16
+ // The popup is a `.s-s.neutral.shadow` surface (see below): background, border,
17
+ // radius and elevation all come from the surface.
18
+ "> .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",
16
19
  "> .s-menu li": "margin:0",
17
20
  ".s-option": "padding: 0.45em 0.6em; r:6px cursor:pointer transition: background 0.1s;",
18
- ".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-fg 10%, transparent);",
21
+ ".s-option[aria-selected=true]": "background: color-mix(in srgb, $s-text 10%, transparent);",
19
22
  ".s-add": "fg:$s-accent font-style:italic",
20
- ".s-empty": "padding: 0.45em 0.6em; fg:$s-fg-muted",
23
+ ".s-empty": "padding: 0.45em 0.6em; fg:$s-muted",
21
24
  },
22
25
  });
23
26
  function normOption(o) {
@@ -160,7 +163,7 @@ export function autocomplete(opts) {
160
163
  const list = filtered();
161
164
  const q = $st.query.trim();
162
165
  const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
163
- A("ul.s-menu role=listbox", `id=${menuId}`, () => {
166
+ A("ul.s-menu.s-s.neutral.shadow role=listbox", `id=${menuId}`, () => {
164
167
  list.forEach((option, i) => {
165
168
  A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
166
169
  A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
@@ -1,15 +1,20 @@
1
1
  import A from "aberdeen";
2
2
  import { drawSlot } from "../core.js";
3
- // The box itself is a `.panel` surface; its header/footer are `.raised`
4
- // surfaces (classes set on the elements in `box()` below). Colours and borders
5
- // come from the contextual tokens, so a box stays legible on whatever surface
6
- // it's nested in.
3
+ // The box itself is a `.neutral` surface; its header/footer are `.neutral` surfaces
4
+ // too nested one level deeper, so they pick up the next elevation shade
5
+ // automatically. Colours and borders come from the contextual tokens, so a box
6
+ // stays legible on whatever surface it's nested in.
7
+ // The box is just a `.s-s.neutral.shadow` surface: its border and shadow come from
8
+ // the surface itself (see theme.ts), not from here. `.s-box` only does layout and
9
+ // the header/footer dividers. Header/footer are `.neutral` surfaces too (one level
10
+ // deeper, for the raised shade), so we cancel their full surface border down to a
11
+ // single divider.
7
12
  A.insertGlobalCss({
8
13
  ".s-box": {
9
- "&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius-lg; overflow:hidden box-shadow: $s-shadow;",
14
+ "&": "display:flex flex-direction:column overflow:hidden r: $s-radius-lg;",
10
15
  "&:not(:first-child)": "margin-top: $3",
11
- "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
12
- "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
16
+ "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600",
17
+ "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; border:0 border-top: 1px solid $s-faint; r:0",
13
18
  "> div": "p:$3 gap:$3",
14
19
  },
15
20
  });
@@ -34,19 +39,19 @@ A.insertGlobalCss({
34
39
  */
35
40
  export function box(opts = {}) {
36
41
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
37
- A("section.s-box.s-s.panel", o.attrs, () => {
42
+ A("section.s-box.s-s.neutral.shadow", o.attrs, () => {
38
43
  // Header and footer get their own scopes so toggling them doesn't recreate
39
44
  // the body (which may hold focused inputs / lots of content).
40
45
  A(() => {
41
46
  if (o.header != null)
42
- A("header.s-s.raised", o.headerAttrs, () => drawSlot(o.header));
47
+ A("header.s-s.neutral", o.headerAttrs, () => drawSlot(o.header));
43
48
  });
44
49
  A("div", o.contentAttrs, () => {
45
50
  drawSlot(o.content);
46
51
  });
47
52
  A(() => {
48
53
  if (o.footer != null)
49
- A("footer.s-s.raised", o.footerAttrs, () => drawSlot(o.footer));
54
+ A("footer.s-s.neutral", o.footerAttrs, () => drawSlot(o.footer));
50
55
  });
51
56
  });
52
57
  }
@@ -18,7 +18,8 @@ export interface ButtonOptions {
18
18
  /**
19
19
  * Aberdeen attr/style string applied to the button. A button is a surface, so
20
20
  * pass surface modifier classes here to restyle it, e.g. `".danger"`,
21
- * `".neutral .outlined"`. Defaults to a filled `.primary` surface.
21
+ * `".danger .outlined"`, or `".neutral"` for a neutral button. Defaults to a
22
+ * filled `.primary` surface.
22
23
  *
23
24
  * Size is set here too, with `.small` or `.large` (medium is the default and
24
25
  * needs no class), e.g. `".danger .small"`. A `.small`/`.large` parent (such
@@ -44,6 +45,7 @@ export interface ButtonOptions {
44
45
  * @example
45
46
  * ```ts
46
47
  * S.button({ content: "Save", click: S.alert("Saved.") });
48
+ * S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
47
49
  * S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
48
50
  * S.button("Cancel"); // shorthand for { content: "Cancel" }
49
51
  * S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
@@ -6,35 +6,28 @@ import { drawSlot } from "../core.js";
6
6
  A.insertGlobalCss({
7
7
  ".s-btn": {
8
8
  "&": "display:inline-flex align-items:center justify-content:center gap:$2 " +
9
- "font-weight:600 line-height:1.2 white-space:nowrap cursor:pointer text-decoration:none " +
10
- "border:0 padding: 0.5em 1em; " +
9
+ "font-weight:450 line-height:1.1 white-space:nowrap cursor:pointer text-decoration:none " +
10
+ "padding: $m2 $m3; " +
11
11
  "transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
12
- "&:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus;",
13
- // Hover feedback is colour-only (no movement). The filled `.gradient` CTA
14
- // below layers a deeper shadow on top, so it still reads as the signature action.
15
- "&:hover": "filter: brightness(1.08)",
16
- "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
17
- // A filled `.gradient` button (the default) is the app's signature call to
18
- // action: a borderless gradient with a hairline top highlight (a hint of
19
- // top-lighting that sells the fill as a lit, rounded shape) over a soft glow.
20
- // The gradient fill itself comes from the `.s-s.gradient` surface rule in
21
- // theme.ts. No border: a filled gradient reads as one solid shape. Dropping
22
- // the border (rather than making it transparent) also sidesteps a Chromium
23
- // artifact where a gradient clipped to a transparent rounded border fringes
24
- // the edge with the gradient's far colour.
25
- "&.gradient:not(.tonal):not(.outlined)": "box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), $s-glow;",
26
- "&.gradient:not(.tonal):not(.outlined):hover": "filter: brightness(1.05); box-shadow: inset 0 1px 0 color-mix(in srgb, white 25%, transparent), 0 7px 18px color-mix(in srgb, $s-primary 34%, transparent);",
12
+ // Focus ring via `outline` (not box-shadow) so it survives a `.no-shadow`
13
+ // (which hard-clears box-shadow). Modern browsers round it to the border-radius.
14
+ "&:focus-visible": "outline: 3px solid $s-focus; outline-offset: 1px;",
15
+ // The button carries `.shadow` (added in button() below); on a filled accent
16
+ // surface that resolves to the signature self-coloured glow, on a neutral
17
+ // `.neutral` button to nothing, on tonal/outlined to nothing all via theme.ts.
18
+ "&:hover": "filter: brightness(1.06)",
19
+ // Tonal/outlined hover deepen their translucent fill; a neutral `.neutral`
20
+ // button (which is already near-white) darkens toward its ink instead.
21
+ "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-bg 24%, transparent);",
22
+ "&.neutral:hover": "filter:none background: color-mix(in srgb, $s-text 8%, $s-bg);",
27
23
  // Subtle press feedback.
28
24
  "&:active:not(:disabled)": "transform: translateY(1px)",
29
25
  // Size: set on the button itself, or inherited from a `.small`/`.large`
30
26
  // parent (e.g. a buttonGroup), so a container can size all its buttons at once.
31
- "&.small, .small > &": "padding: 0.32em 0.7em; font-size:0.85em",
32
- "&.large, .large > &": "padding: 0.66em 1.3em; font-size:1.1em",
27
+ "&.small, .small > &": "padding: $m1 $m2; font-size:0.85em border-radius:$s-radius-sm",
28
+ "&.large, .large > &": "font-size:1.4em border-radius:$s-radius-lg",
33
29
  },
34
30
  });
35
- // Surface-role classes a caller may pass in `attrs`. When one is present we skip
36
- // the default `.gradient` base so the two roles don't stack on one element.
37
- const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning|base|panel|raised)(\.|\s|$)/;
38
31
  /**
39
32
  * A button. Tonal and outlined variants show a border; filled variants rely on
40
33
  * their solid background for affordance.
@@ -53,6 +46,7 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
53
46
  * @example
54
47
  * ```ts
55
48
  * S.button({ content: "Save", click: S.alert("Saved.") });
49
+ * S.button({ content: "Cancel", attrs: ".neutral", click: cancel }); // neutral button
56
50
  * S.button({ content: "Delete", attrs: ".danger .outlined", click: del });
57
51
  * S.button("Cancel"); // shorthand for { content: "Cancel" }
58
52
  * S.button({ href: "/docs", content: "Docs" }); // renders an <a role=button>
@@ -61,12 +55,11 @@ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning
61
55
  export function button(opts = {}) {
62
56
  const o = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
63
57
  const tag = o.href != null ? "a" : "button";
64
- // A filled `.gradient` surface by default the signature CTA. If the caller's
65
- // `attrs` already names a surface role we omit the default, so `.danger`,
66
- // `.neutral .outlined`, etc. fully take over (rather than stacking two roles).
67
- // A bare variant/size (`.outlined`, `.small`) keeps the gradient base.
68
- const role = o.attrs && ROLE_CLASS.test(o.attrs) ? "" : ".gradient";
69
- A(`${tag}.s-btn.s-s${role}`, o.attrs, () => {
58
+ // A bare `.s-s` is a filled accent surface defaulting to `.primary` (see
59
+ // theme.ts) the signature CTA. The caller's `attrs` simply names another
60
+ // role (`.danger`, `.neutral`, a custom `.brand`) or variant (`.outlined`); no
61
+ // role detection needed, since the default lives in CSS, not here.
62
+ A(`${tag}.s-btn.s-s.shadow`, o.attrs, () => {
70
63
  if (o.href != null) {
71
64
  A(`href=${o.href} role=button`);
72
65
  if (o.disabled)
@@ -26,7 +26,7 @@ export function buttonChooser(opts) {
26
26
  // Icon-only options (draw-function labels) get the id as their
27
27
  // accessible name; plain-text labels speak for themselves.
28
28
  ariaLabel: typeof label === "function" ? id : undefined,
29
- attrs: selected === id ? ".primary" : ".neutral .outlined",
29
+ attrs: selected === id ? ".primary" : ".neutral",
30
30
  click: () => {
31
31
  opts.bind.value = (opts.allowDeselect && selected === id) ? undefined : id;
32
32
  },
@@ -24,9 +24,9 @@ export interface ButtonGroupOptions extends ContentOptions {
24
24
  * @example
25
25
  * ```ts
26
26
  * S.buttonGroup({ buttons: [
27
- * { content: "Day", attrs: ".neutral .outlined" },
28
- * { content: "Week", attrs: ".neutral .outlined" },
29
- * { content: "Month", attrs: ".neutral .outlined" },
27
+ * { content: "Day", attrs: ".neutral" },
28
+ * { content: "Week", attrs: ".neutral" },
29
+ * { content: "Month", attrs: ".neutral" },
30
30
  * ]});
31
31
  * ```
32
32
  */
@@ -27,9 +27,9 @@ A.insertGlobalCss({
27
27
  * @example
28
28
  * ```ts
29
29
  * S.buttonGroup({ buttons: [
30
- * { content: "Day", attrs: ".neutral .outlined" },
31
- * { content: "Week", attrs: ".neutral .outlined" },
32
- * { content: "Month", attrs: ".neutral .outlined" },
30
+ * { content: "Day", attrs: ".neutral" },
31
+ * { content: "Week", attrs: ".neutral" },
32
+ * { content: "Month", attrs: ".neutral" },
33
33
  * ]});
34
34
  * ```
35
35
  */
@@ -5,8 +5,9 @@ A.insertGlobalCss({
5
5
  "&": "display:flex flex-direction:column gap:$1",
6
6
  "> label": "display:flex align-items:center gap:$2 cursor:pointer user-select:none",
7
7
  "> label:has(input:disabled)": "cursor:not-allowed opacity:0.45 filter:saturate(0.6)",
8
- // Native control styled with accent-color: accessible and zero-fuss.
9
- "input": "width:1.15em height:1.15em accent-color:$s-accent cursor:inherit m:0",
8
+ // Native control: size and brand accent-color come from the CSS reset; here we
9
+ // just strip the margin and let it inherit the label's cursor (pointer / not-allowed).
10
+ "input": "cursor:inherit m:0",
10
11
  },
11
12
  });
12
13
  /**
@@ -37,6 +37,8 @@ export interface DialogOptions {
37
37
  */
38
38
  onClose?: () => void;
39
39
  }
40
+ /** Whether any dialog is currently open (live state, not the lingering DOM). */
41
+ export declare function isDialogOpen(): boolean;
40
42
  /**
41
43
  * A dialog rendered into `document.body` via `A.mount`, with a dimming backdrop
42
44
  * that fades in and out. Returns a `Promise<void>` that resolves when the dialog
@@ -53,7 +55,7 @@ export interface DialogOptions {
53
55
  * content: (close) => {
54
56
  * A("p #Are you sure?");
55
57
  * S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
56
- * S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
58
+ * S.button({ content: "Cancel", attrs: ".neutral", click: close });
57
59
  * },
58
60
  * });
59
61
  * ```
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { drawSlot, mountPortal } from "../core.js";
2
+ import { drawSlot, mountPortal, focusFirst } from "../core.js";
3
3
  import { button } from "./button.js";
4
4
  import { buttonGroup } from "./buttonGroup.js";
5
5
  import { textline } from "./textline.js";
@@ -8,17 +8,19 @@ A.insertGlobalCss({
8
8
  "&": "position:fixed inset:0 z-index:200 display:block background: rgba(0,0,0,0.55); transition: opacity 0.4s ease-in-out;",
9
9
  "&.hidden": "opacity:0 pointer-events:none",
10
10
  },
11
+ // The dialog panel is a `.s-s.neutral.extra-shadow` surface: border, radius (lg)
12
+ // and the deep floating shadow come from the surface itself (see theme.ts).
11
13
  ".s-dialog": {
12
14
  "&": "position:fixed z-index:200 top:50% left:50% " +
13
15
  "display:flex flex-direction:column " +
14
16
  "transform:translate(-50%,-50%) " +
15
17
  "min-width:20rem max-width:min(90vw,44rem) max-height:min(88vh,800px) " +
16
- "border: 1px solid $s-border; r: $s-radius-lg; box-shadow: $s-shadow; overflow:hidden " +
18
+ "r: $s-radius-lg; overflow:hidden " +
17
19
  "transition: opacity 0.2s ease-out, transform 0.2s ease-out;",
18
20
  "> header": "display:flex align-items:center gap:$2 padding: $2 $3; " +
19
- "border-bottom: 1px solid $s-border; font-weight:600 flex-shrink:0",
21
+ "border:0 border-bottom: 1px solid $s-faint; r:0 font-weight:600 flex-shrink:0",
20
22
  "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 padding: $2 $3; " +
21
- "border-top: 1px solid $s-border; flex-shrink:0",
23
+ "border:0 border-top: 1px solid $s-faint; r:0 flex-shrink:0",
22
24
  "> div": "p:$3 gap:$3 display:flex flex-direction:column overflow-y:auto flex:1 min-height:0",
23
25
  "&.hidden": "opacity:0 pointer-events:none transform: translate(-50%, calc(-50% + 20px)); pointer-events:none",
24
26
  },
@@ -30,6 +32,10 @@ const topDialogId = A.derive(() => {
30
32
  if (keys.length)
31
33
  return keys[keys.length - 1];
32
34
  });
35
+ /** Whether any dialog is currently open (live state, not the lingering DOM). */
36
+ export function isDialogOpen() {
37
+ return topDialogId.value != null;
38
+ }
33
39
  mountPortal(() => {
34
40
  A.onEach(dialogs, ({ resolve, opts }, dialogId) => {
35
41
  const close = () => { delete dialogs[dialogId]; };
@@ -46,10 +52,10 @@ mountPortal(() => {
46
52
  close();
47
53
  });
48
54
  // Dialog itself
49
- A("div.s-dialog.s-s.panel create=hidden destroy=hidden", opts.attrs, () => {
55
+ const dialogEl = A("div.s-dialog.neutral.s-s.extra-shadow create=hidden destroy=hidden", opts.attrs, () => {
50
56
  A(() => {
51
57
  if (opts.header != null) {
52
- A("header.s-s.raised", opts.headerAttrs, () => drawSlot(opts.header));
58
+ A("header.s-s.neutral", opts.headerAttrs, () => drawSlot(opts.header));
53
59
  }
54
60
  });
55
61
  A("div", opts.contentAttrs, () => {
@@ -57,10 +63,14 @@ mountPortal(() => {
57
63
  });
58
64
  A(() => {
59
65
  if (opts.footer != null) {
60
- A("footer.s-s.raised", opts.footerAttrs, () => drawSlot(opts.footer));
66
+ A("footer.s-s.neutral", opts.footerAttrs, () => drawSlot(opts.footer));
61
67
  }
62
68
  });
63
69
  });
70
+ // Once laid out, move focus into the dialog (first focusable element) so it's
71
+ // keyboard-ready and focus doesn't linger on whatever opened it.
72
+ requestAnimationFrame(() => { if (document.body.contains(dialogEl))
73
+ focusFirst(dialogEl); });
64
74
  });
65
75
  });
66
76
  /**
@@ -79,7 +89,7 @@ mountPortal(() => {
79
89
  * content: (close) => {
80
90
  * A("p #Are you sure?");
81
91
  * S.button({ content: "Yes", click: () => { S.alert("Nice!"); close(); } });
82
- * S.button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
92
+ * S.button({ content: "Cancel", attrs: ".neutral", click: close });
83
93
  * },
84
94
  * });
85
95
  * ```
@@ -150,7 +160,7 @@ export function confirm(message, opts = {}) {
150
160
  content: (close) => {
151
161
  A("p", () => { A("#", message); });
152
162
  buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
153
- button({ content: "Cancel", attrs: ".neutral .outlined", click: close });
163
+ button({ content: "Cancel", attrs: ".neutral", click: close });
154
164
  button({ content: "OK", click: () => { confirmed = true; close(); } });
155
165
  } });
156
166
  },
@@ -189,7 +199,7 @@ export function prompt(message, defaultValue = "", opts = {}) {
189
199
  });
190
200
  textline({ bind: A.ref($v, "value") });
191
201
  buttonGroup({ layout: "spaced", attrs: "align-self:flex-end", content: () => {
192
- button({ content: "Cancel", attrs: ".neutral .outlined", type: "button", click: close });
202
+ button({ content: "Cancel", attrs: ".neutral", type: "button", click: close });
193
203
  button({ content: "OK", type: "submit" });
194
204
  } });
195
205
  });
@@ -3,14 +3,17 @@ import { drawSlot, uniqueId } from "../core.js";
3
3
  A.insertGlobalCss({
4
4
  ".s-field": {
5
5
  "&": "display:flex flex-direction:column gap:$1",
6
- "> label": "font-weight:600 font-size:0.9em fg:$s-fg user-select:none",
6
+ "> label": "font-weight:600 font-size:0.9em fg:$s-text user-select:none",
7
7
  },
8
8
  ".s-req": "fg:$s-danger margin-left:2px",
9
- ".s-help": "font-size:0.82em fg:$s-fg-muted",
9
+ ".s-help": "font-size:0.82em fg:$s-muted",
10
10
  ".s-error": "font-size:0.82em fg:$s-danger",
11
11
  ".s-input": {
12
- "&": "w:100% bg:$s-panel fg:$s-ink border: 1px solid $s-border; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
13
- "&:hover:not(:disabled)": "border-color:$s-border-strong",
12
+ // A light inset field, derived from the surrounding surface: the surface
13
+ // background nudged slightly toward its ink, with the ink as text. Adapts to
14
+ // whatever surface (and mode) holds the field, no fixed input colour needed.
15
+ "&": "w:100% background: color-mix(in oklab, $s-bg, $s-text 4%); color:$s-text border: 1px solid $s-faint; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
16
+ "&:hover:not(:disabled)": "border-color: color-mix(in oklab, $s-text, $s-bg 55%);",
14
17
  "&:focus-visible": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus; outline:none",
15
18
  "&[aria-invalid=true]": "border-color:$s-danger",
16
19
  },