staffa 0.3.1 → 0.4.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 (44) hide show
  1. package/README.md +45 -23
  2. package/dist/components/box.d.ts +2 -2
  3. package/dist/components/box.js +3 -4
  4. package/dist/components/button.d.ts +9 -11
  5. package/dist/components/button.js +16 -18
  6. package/dist/components/buttonChooser.d.ts +5 -5
  7. package/dist/components/buttonChooser.js +7 -5
  8. package/dist/components/buttonGroup.d.ts +3 -3
  9. package/dist/components/buttonGroup.js +5 -5
  10. package/dist/components/dialog.d.ts +2 -2
  11. package/dist/components/dialog.js +10 -10
  12. package/dist/components/form.d.ts +4 -4
  13. package/dist/components/form.js +6 -6
  14. package/dist/components/main.d.ts +5 -5
  15. package/dist/components/main.js +28 -40
  16. package/dist/components/menu.d.ts +4 -4
  17. package/dist/components/menu.js +8 -6
  18. package/dist/components/tabs.d.ts +3 -3
  19. package/dist/components/tabs.js +1 -1
  20. package/dist/components/toast.d.ts +1 -3
  21. package/dist/components/toast.js +2 -2
  22. package/dist/components/tooltip.js +2 -2
  23. package/dist/core.d.ts +10 -4
  24. package/dist/core.js +13 -0
  25. package/dist/index.d.ts +19 -65
  26. package/dist/index.js +18 -48
  27. package/dist/staffa.esm.js +1 -1
  28. package/dist/theme.d.ts +0 -7
  29. package/dist/theme.js +37 -14
  30. package/package.json +9 -2
  31. package/src/components/box.ts +5 -5
  32. package/src/components/button.ts +21 -22
  33. package/src/components/buttonChooser.ts +10 -8
  34. package/src/components/buttonGroup.ts +5 -4
  35. package/src/components/dialog.ts +10 -10
  36. package/src/components/form.ts +8 -8
  37. package/src/components/main.ts +31 -40
  38. package/src/components/menu.ts +9 -7
  39. package/src/components/tabs.ts +4 -4
  40. package/src/components/toast.ts +3 -5
  41. package/src/components/tooltip.ts +2 -2
  42. package/src/core.ts +16 -5
  43. package/src/index.ts +21 -76
  44. package/src/theme.ts +36 -22
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { type Content, type ContentOptions, type Attributes } from "../core.js";
2
+ import { type ContentOptions, type Attributes, type Slot, drawSlot } from "../core.js";
3
3
 
4
4
  /** Options for {@link form}. */
5
5
  export interface FormOptions extends ContentOptions {
@@ -18,7 +18,7 @@ export interface FormOptions extends ContentOptions {
18
18
  /** Aberdeen attr/style string for the action bar. */
19
19
  actionsAttrs?: Attributes;
20
20
  /** Footer actions (typically a {@link import("./buttonGroup").buttonGroup} or buttons). */
21
- actions?: Content;
21
+ actions?: Slot;
22
22
  }
23
23
 
24
24
  A.insertGlobalCss({
@@ -26,7 +26,7 @@ A.insertGlobalCss({
26
26
  "&": "display:flex flex-direction:column gap:$3",
27
27
  "&.grid": "display:grid grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap:$3",
28
28
  "&.grid > .s-wide, &.grid > footer": "grid-column: 1 / -1;",
29
- "> footer": "display:flex align-items:center gap:$2 flex-wrap:wrap margin-top:$1",
29
+ "> footer": "display:flex align-items:center justify-content:flex-end gap:$2 flex-wrap:wrap margin-top:$1",
30
30
  },
31
31
  });
32
32
 
@@ -47,12 +47,12 @@ A.insertGlobalCss({
47
47
  * S.textline({ label: "Name", required: true, bind: A.ref($u, "name") });
48
48
  * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
49
49
  * },
50
- * actions: () => S.button({ text: "Save", type: "submit" }),
50
+ * actions: () => S.button({ content: "Save", type: "submit" }),
51
51
  * });
52
52
  * ```
53
53
  */
54
- export function form(opts: FormOptions | Content = {}): void {
55
- const o: FormOptions = typeof opts === "function" ? { content: opts } : opts;
54
+ export function form(opts: FormOptions | Slot = {}): void {
55
+ const o: FormOptions = typeof opts === "string" || typeof opts === "function" ? { content: opts } : opts;
56
56
 
57
57
  A(`form.s-form`, o.attrs, () => {
58
58
  // Toggle grid class in its own scope so changing layout doesn't recreate
@@ -74,11 +74,11 @@ export function form(opts: FormOptions | Content = {}): void {
74
74
  }
75
75
  });
76
76
 
77
- if (o.content) o.content();
77
+ drawSlot(o.content);
78
78
 
79
79
  // Own scope so toggling actions doesn't recreate the fields above.
80
80
  A(() => {
81
- if (o.actions) A("footer", o.actionsAttrs, () => o.actions?.());
81
+ if (o.actions) A("footer", o.actionsAttrs, () => drawSlot(o.actions));
82
82
  });
83
83
  });
84
84
  }
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
2
+ import { type Slot, type Attributes, drawSlot } from "../core.js";
3
3
  import { type MenuOptions, menuButton, drawMenu } from "./menu.js";
4
4
 
5
5
  /** Options for {@link main}. */
@@ -13,9 +13,9 @@ export interface MainOptions {
13
13
  /** Leading icon/logo in the top bar. */
14
14
  icon?: Slot;
15
15
  /** Action area on the right of the top bar (buttons, menu, ...). */
16
- menu?: Content;
17
- /** The scrollable page content. */
18
- content?: Content;
16
+ menu?: Slot;
17
+ /** The scrollable page content. A string is rendered as rich text. */
18
+ content?: Slot;
19
19
  /** Footer content, pinned below the scroll area. */
20
20
  footer?: Slot;
21
21
  /**
@@ -62,8 +62,9 @@ A.insertGlobalCss({
62
62
  "> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
63
63
  "> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
64
64
  "> header .s-menu": "display:flex align-items:center gap:$2",
65
- // Body holds sidebar + separator + <main> side by side (only used in sidebar
66
- // nav mode). It centres `.s-body-inner`, which caps the trio to maxWidth.
65
+ // Body always wraps <main> (with or without a sidebar) so max-width centering
66
+ // and scrollbar alignment work identically in both cases.
67
+ // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
67
68
  ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
68
69
  ".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
69
70
  // Put the sidebar on the right (content fills the left) for right-hand navs.
@@ -71,20 +72,19 @@ A.insertGlobalCss({
71
72
  // A vertical hairline between sidebar and content, fading out at both ends —
72
73
  // the vertical sibling of the menu's `hr.s-menu-sep`.
73
74
  ".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
74
- // Without a sidebar, <main> is a direct child; with one it lives in .s-body.
75
- "> main, .s-body main": "flex:1 overflow-y:auto display:flex flex-direction:column",
76
- // The content area fills the scroll region with comfortable padding. Without a
77
- // sidebar it caps its own width to maxWidth and centres (applied inline in
78
- // drawMainContent); with one, `.s-body-inner` does the capping for the trio.
75
+ // min-height:0 overrides the flex default of min-height:auto so <main> can
76
+ // shrink to fit the bounded container and show its own scrollbar.
77
+ ".s-body main": "flex:1 min-height:0 overflow-y:auto display:flex flex-direction:column",
78
+ // The content area fills the scroll region with comfortable padding.
79
79
  // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
80
- "> main > .s-content, .s-body main > .s-content": "width:100% flex:1 p:$3",
80
+ ".s-body main > .s-content": "width:100% flex:1 p:$3",
81
81
  // When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
82
82
  // toggled from JS by watchVerticalOverflow), inset it from the shell edge by
83
83
  // $3 so the bar's right edge lines up with the header/footer content (which
84
84
  // sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
85
85
  // and the bar already comes from `.s-content`'s padding. Without a scrollbar
86
86
  // there's no margin, so the content keeps its single $3 edge — not 2×$3.
87
- "> main.s-scroll-y, .s-body main.s-scroll-y": "margin-right:$3",
87
+ ".s-body main.s-scroll-y": "margin-right:$3",
88
88
  },
89
89
  // Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
90
90
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -110,6 +110,9 @@ A.insertGlobalCss({
110
110
  // On phones a top-level content box becomes a full-bleed block: pull it out
111
111
  // to negate the content padding and drop the rounded corners.
112
112
  ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
113
+ // At narrow widths, content boxes are full-bleed so there's no inset to
114
+ // align the scrollbar with — cancel the right margin.
115
+ ".s-main .s-body main.s-scroll-y": "margin-right:0",
113
116
  },
114
117
  });
115
118
 
@@ -133,7 +136,7 @@ A.insertGlobalCss({
133
136
  * ],
134
137
  * },
135
138
  * navPosition: "left",
136
- * menu: () => S.button({ text: "New", attrs: ".small" }),
139
+ * menu: () => S.button({ content: "New", attrs: ".small" }),
137
140
  * content: () => drawPage(),
138
141
  * footer: "© 2026",
139
142
  * });
@@ -190,30 +193,28 @@ export function main(opts: MainOptions = {}): void {
190
193
  });
191
194
  });
192
195
  A(() => {
193
- if (opts.menu) A("div.s-menu", () => opts.menu?.());
196
+ if (opts.menu) A("div.s-menu", () => drawSlot(opts.menu));
194
197
  });
195
198
  });
196
199
  });
197
200
  });
198
201
 
199
- // Body wraps sidebar + separator + main when nav is in sidebar mode. The
200
- // trio together caps to maxWidth (via .s-body-inner); main fills the rest.
201
- if (hasNav && navPos !== "button") {
202
- A("div.s-body", () => {
203
- A("div.s-body-inner", () => {
204
- A(() => {
205
- if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
206
- });
202
+ // Body always wraps <main> so max-width centering and scrollbar alignment
203
+ // are identical with and without a sidebar nav.
204
+ A("div.s-body", () => {
205
+ A("div.s-body-inner", () => {
206
+ A(() => {
207
+ if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
208
+ });
209
+ if (hasNav && navPos !== "button") {
207
210
  A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
208
211
  drawMenu(nav.items);
209
212
  });
210
213
  A("div.s-nav-sep aria-hidden=true");
211
- drawMainContent(opts, false);
212
- });
214
+ }
215
+ drawMainContent(opts);
213
216
  });
214
- } else {
215
- drawMainContent(opts, true);
216
- }
217
+ });
217
218
 
218
219
  // Footer — full-width background, content centred to maxWidth via .s-bar.
219
220
  A(() => {
@@ -231,20 +232,10 @@ export function main(opts: MainOptions = {}): void {
231
232
  });
232
233
  }
233
234
 
234
- /**
235
- * Draw the scrollable `<main>` + content area. When `capWidth` is true (no
236
- * sidebar), the content caps its own width to maxWidth and centres; in sidebar
237
- * mode the surrounding `.s-body-inner` already caps the sidebar+content trio.
238
- */
239
- function drawMainContent(opts: MainOptions, capWidth: boolean): void {
235
+ function drawMainContent(opts: MainOptions): void {
240
236
  const mainEl = A("main", () => {
241
237
  A("div.s-content", opts.contentAttrs, () => {
242
- if (capWidth) {
243
- A(() => {
244
- if (opts.maxWidth != null) A("margin-inline:auto max-width:", opts.maxWidth);
245
- });
246
- }
247
- if (opts.content) opts.content();
238
+ drawSlot(opts.content);
248
239
  });
249
240
  }) as HTMLElement;
250
241
  watchVerticalOverflow(mainEl);
@@ -1,6 +1,6 @@
1
1
  import A from "aberdeen";
2
2
  import { matchCurrent } from "aberdeen/route";
3
- import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
3
+ import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
4
4
  import { button, type ButtonOptions } from "./button.js";
5
5
 
6
6
  /**
@@ -41,10 +41,10 @@ export interface MenuSeparator {
41
41
  * An entry in a menu or sidebar nav list. Three forms:
42
42
  * - `MenuItem` — a clickable/linkable row with label and optional icon.
43
43
  * - `MenuSeparator` — a visual divider (`{ separator: true }`).
44
- * - A draw function `() => void` — renders custom content (section header,
44
+ * - A slot (string or draw function) — renders custom content (section header,
45
45
  * avatar, search box, …). Skipped by keyboard navigation.
46
46
  */
47
- export type MenuEntry = MenuItem | MenuSeparator | Content;
47
+ export type MenuEntry = MenuItem | MenuSeparator | Slot;
48
48
 
49
49
  /** Options for {@link menuButton} and {@link MainOptions.nav}. */
50
50
  export interface MenuOptions {
@@ -148,7 +148,7 @@ export function drawMenu(items: MenuEntry[], onActivate?: () => void): void {
148
148
  });
149
149
 
150
150
  for (const entry of items) {
151
- if (typeof entry === "function") { entry(); continue; }
151
+ if (typeof entry === "string" || typeof entry === "function") { drawSlot(entry); continue; }
152
152
  if ("separator" in entry) { A("hr.s-menu-sep"); continue; }
153
153
 
154
154
  A(entry.href ? "a.s-menu-item-link" : "button.s-menu-item type=button", entry.attrs, () => {
@@ -193,7 +193,7 @@ function positionMenu(menuEl: HTMLElement, rect: DOMRect): void {
193
193
  menuEl.style.top = Math.max(8, y) + "px";
194
194
  }
195
195
 
196
- A.mount(document.body, () => {
196
+ mountPortal(() => {
197
197
  const f = $floating.opts;
198
198
  if (!f) return;
199
199
 
@@ -260,7 +260,7 @@ export function showFloatingMenu(opts: FloatingMenuOptions): () => void {
260
260
  * @example
261
261
  * ```ts
262
262
  * S.menuButton({
263
- * button: { text: "Actions", attrs: ".neutral .outlined" },
263
+ * button: { content: "Actions", attrs: ".neutral .outlined" },
264
264
  * items: [
265
265
  * { label: "Edit", icon: () => A("#✎"), click: () => edit() },
266
266
  * { separator: true },
@@ -275,7 +275,9 @@ export function menuButton(opts: MenuOptions): void {
275
275
 
276
276
  button({
277
277
  icon: () => A("span aria-hidden=true #☰"),
278
- ariaLabel: "Open menu",
278
+ // Only label the trigger "Open menu" when it has no visible text of its
279
+ // own — an aria-label would otherwise *hide* that text from AT.
280
+ ...(opts.button?.content == null ? { ariaLabel: "Open menu" } : null),
279
281
  attrs: ".neutral .outlined",
280
282
  ...opts.button,
281
283
  click: (e: Event) => {
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { type Bindable, type Content, type Slot, type Attributes, drawSlot, uniqueId } from "../core.js";
2
+ import { type Bindable, type Slot, type Attributes, drawSlot, uniqueId } from "../core.js";
3
3
 
4
4
  /** A single tab definition. */
5
5
  export interface Tab {
@@ -9,8 +9,8 @@ export interface Tab {
9
9
  label: Slot;
10
10
  /** Optional leading icon. */
11
11
  icon?: Slot;
12
- /** Content rendered in the panel when this tab is active. */
13
- content?: Content;
12
+ /** Content rendered in the panel when this tab is active. A string is rendered as rich text. */
13
+ content?: Slot;
14
14
  /** Disables selecting this tab. */
15
15
  disabled?: boolean;
16
16
  }
@@ -101,7 +101,7 @@ export function tabs(opts: TabsOptions): void {
101
101
  const tab = opts.tabs[index] ?? opts.tabs[0];
102
102
  if (!tab) return;
103
103
  A(`id=${groupId}-panel-${keyOf(tab, index)} aria-labelledby=${groupId}-tab-${keyOf(tab, index)}`);
104
- tab.content?.();
104
+ drawSlot(tab.content);
105
105
  });
106
106
  });
107
107
  });
@@ -1,7 +1,6 @@
1
1
  import A from "aberdeen";
2
2
  import { grow, shrink } from "aberdeen/transitions";
3
- import { type Slot, type Attributes, drawSlot } from "../core.js";
4
- import type { SurfaceRole } from "../theme.js";
3
+ import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
5
4
 
6
5
  /** Options for {@link toast}. */
7
6
  export interface ToastOptions {
@@ -11,9 +10,8 @@ export interface ToastOptions {
11
10
  title?: Slot;
12
11
  /**
13
12
  * Colour role. Defaults to `"neutral"`.
14
- * Use `"success"` / `"danger"` / `"warning"` for semantic feedback.
15
13
  */
16
- type?: SurfaceRole;
14
+ type?: "success" | "danger" | "warning" | "neutral"
17
15
  /**
18
16
  * Auto-dismiss delay in milliseconds. Defaults to `4000`.
19
17
  * Pass `0` to make the toast persistent until dismissed manually.
@@ -55,7 +53,7 @@ let toastCount = 0;
55
53
  // Keyed by stable ID so A.onEach scopes are per-toast — removing one never re-renders others.
56
54
  const toasts = A.proxy({} as Record<number, ToastEntry>);
57
55
 
58
- A.mount(document.body, () => {
56
+ mountPortal(() => {
59
57
  A("div.s-toasts aria-live=polite aria-atomic=false", () => {
60
58
  A.onEach(toasts, (entry) => {
61
59
  const { opts } = entry;
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { type Slot, type Attributes, drawSlot } from "../core.js";
2
+ import { type Slot, type Attributes, drawSlot, mountPortal } from "../core.js";
3
3
 
4
4
  /** Options for {@link addTooltip}. */
5
5
  export interface TooltipOptions {
@@ -79,7 +79,7 @@ function scheduleHide(): void {
79
79
 
80
80
  // ─── Portal ──────────────────────────────────────────────────────────────────
81
81
 
82
- A.mount(document.body, () => {
82
+ mountPortal(() => {
83
83
  const active = $ttActive.value;
84
84
  if (!active) return;
85
85
  const { opts, anchor } = active;
package/src/core.ts CHANGED
@@ -29,9 +29,6 @@ export type Attributes = string;
29
29
  /** A reactive "value box", such as the result of `A.proxy(x)` or `A.ref(obj, key)`. */
30
30
  export type Bindable<T> = { value: T };
31
31
 
32
- /** A content function. It runs inside the relevant element's reactive scope. */
33
- export type Content = () => void;
34
-
35
32
  /**
36
33
  * Something that renders a small piece of content: either a plain string or a
37
34
  * draw function (for icons, badges, custom markup, ...).
@@ -52,8 +49,8 @@ export type Slot<Args extends unknown[] = []> = string | ((...args: Args) => voi
52
49
  export interface ContentOptions {
53
50
  /** Aberdeen attr/style string applied to the widget's outermost element. */
54
51
  attrs?: Attributes;
55
- /** Draws the children of this component. */
56
- content?: Content;
52
+ /** Draws the children of this component. A string is rendered as rich text. */
53
+ content?: Slot;
57
54
  }
58
55
 
59
56
  let idCounter = 0;
@@ -72,3 +69,17 @@ export function drawSlot<Args extends unknown[] = []>(slot: Slot<Args> | undefin
72
69
  if (typeof slot === "function") slot(...args);
73
70
  else A("rich=", slot);
74
71
  }
72
+
73
+ /**
74
+ * Mount a portal (tooltip, toast, menu, dialog, …) in its own dedicated
75
+ * container under `<body>`. Each portal gets a private parent element because
76
+ * two `A.mount`s sharing one parent can't tell their nodes apart: a redraw of
77
+ * one (e.g. a tooltip hiding) may sweep up nodes another portal inserted
78
+ * earlier in the same parent (e.g. an open menu).
79
+ */
80
+ export function mountPortal(draw: () => void): void {
81
+ const container = document.createElement("div");
82
+ container.style.display = "contents";
83
+ document.body.appendChild(container);
84
+ A.mount(container, draw);
85
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Import the default `S` object and call its component functions:
6
6
  *
7
7
  * ```ts
8
- * import S from "staffa";
8
+ * import * as S from "staffa";
9
9
  *
10
10
  * S.main({
11
11
  * title: "Hello",
@@ -17,7 +17,7 @@
17
17
  * S.textline({ label: "Email", type: "email", bind: A.ref($u, "email") });
18
18
  * S.checkbox({ label: "Remember me", bind: A.ref($u, "remember") });
19
19
  * },
20
- * actions: () => S.button({ text: "Sign in", type: "submit" }),
20
+ * actions: () => S.button({ content: "Sign in", type: "submit" }),
21
21
  * });
22
22
  * }});
23
23
  * },
@@ -32,80 +32,25 @@
32
32
  // Importing the theme module installs spacing vars, the reactive theme and the
33
33
  // base stylesheet. Customise it from your app with A.insertGlobalCss (see
34
34
  // theme.ts); toggle modes with setDarkMode / getDarkMode.
35
- import { setDarkMode, getDarkMode } from "./theme.js";
36
-
37
- import { autocomplete } from "./components/autocomplete.js";
38
- import { box } from "./components/box.js";
39
- import { button } from "./components/button.js";
40
- import { buttonChooser } from "./components/buttonChooser.js";
41
- import { buttonGroup } from "./components/buttonGroup.js";
42
- import { checkbox } from "./components/checkbox.js";
43
- import { form } from "./components/form.js";
44
- import { main } from "./components/main.js";
45
- import { menuButton, showFloatingMenu } from "./components/menu.js";
46
- import { dialog, alert, confirm, prompt } from "./components/dialog.js";
47
- import { select } from "./components/select.js";
48
- import { tabs } from "./components/tabs.js";
49
- import { textarea } from "./components/textarea.js";
50
- import { textline } from "./components/textline.js";
51
- import { toast } from "./components/toast.js";
52
- import { addTooltip } from "./components/tooltip.js";
53
-
54
- /** The Staffa component namespace. */
55
- export const S = {
56
- main,
57
- box,
58
- dialog,
59
- alert,
60
- confirm,
61
- prompt,
62
- form,
63
- menuButton,
64
- showFloatingMenu,
65
- textline,
66
- textarea,
67
- checkbox,
68
- tabs,
69
- button,
70
- buttonChooser,
71
- buttonGroup,
72
- autocomplete,
73
- select,
74
- toast,
75
- addTooltip,
76
- setDarkMode,
77
- getDarkMode,
78
- };
79
-
80
- export default S;
35
+ export { setDarkMode, getDarkMode } from "./theme.js";
36
+ export { autocomplete, type AutocompleteOptions, type AutocompleteOptionInput } from "./components/autocomplete.js";
37
+ export { box, type BoxOptions } from "./components/box.js";
38
+ export { button, type ButtonOptions } from "./components/button.js";
39
+ export { buttonChooser, type ButtonChooserOptions } from "./components/buttonChooser.js";
40
+ export { buttonGroup, type ButtonGroupOptions } from "./components/buttonGroup.js";
41
+ export { checkbox, type CheckboxOptions } from "./components/checkbox.js";
42
+ export { form, type FormOptions } from "./components/form.js";
43
+ export { main, type MainOptions } from "./components/main.js";
44
+ export { menuButton, showFloatingMenu, type MenuOptions, type MenuEntry, type MenuItem, type MenuSeparator, type FloatingMenuOptions } from "./components/menu.js";
45
+ export { dialog, alert, confirm, prompt, type DialogOptions } from "./components/dialog.js";
46
+ export { select, type SelectOptions, type SelectOptionInput } from "./components/select.js";
47
+ export { tabs, type Tab, type TabsOptions } from "./components/tabs.js";
48
+ export { textarea, type TextareaOptions } from "./components/textarea.js";
49
+ export { textline, type TextlineOptions, type TextlineType } from "./components/textline.js";
50
+ export { toast, type ToastOptions } from "./components/toast.js";
51
+ export { addTooltip, type TooltipOptions } from "./components/tooltip.js";
52
+ export type { FieldOptions } from "./components/field.js";
81
53
 
82
54
  // Re-export theming and shared types for advanced use.
83
- export { setDarkMode, getDarkMode } from "./theme.js";
84
- export type { SurfaceRole, Variant } from "./theme.js";
85
- export type {
86
- ContentOptions,
87
- Bindable,
88
- Content,
89
- Slot,
90
- Attributes as Styling,
91
- } from "./core.js";
92
- export { drawSlot, uniqueId } from "./core.js";
55
+ export type {ContentOptions, Bindable, Slot, Attributes} from "./core.js";
93
56
 
94
- export type { FieldOptions } from "./components/field.js";
95
- export type { MainOptions } from "./components/main.js";
96
- export type { MenuOptions, MenuEntry, MenuItem, MenuSeparator, FloatingMenuOptions } from "./components/menu.js";
97
- export { drawMenu } from "./components/menu.js";
98
- export type { ToastOptions } from "./components/toast.js";
99
- export type { TooltipOptions } from "./components/tooltip.js";
100
- export type { BoxOptions } from "./components/box.js";
101
- export type { FormOptions } from "./components/form.js";
102
- export type { TextlineOptions, TextlineType } from "./components/textline.js";
103
- export type { TextareaOptions } from "./components/textarea.js";
104
- export type { CheckboxOptions } from "./components/checkbox.js";
105
- export type { Tab, TabsOptions } from "./components/tabs.js";
106
- export type { ButtonOptions } from "./components/button.js";
107
- export type { ButtonChooserOptions } from "./components/buttonChooser.js";
108
- export type { ButtonGroupOptions } from "./components/buttonGroup.js";
109
- export type { AutocompleteOptions, AutocompleteOptionInput } from "./components/autocomplete.js";
110
- export type { SelectOptions, SelectOptionInput } from "./components/select.js";
111
- export type { DialogOptions } from "./components/dialog.js";
package/src/theme.ts CHANGED
@@ -1,14 +1,5 @@
1
1
  import A from "aberdeen";
2
2
 
3
- /**
4
- * The named accent roles for interactive elements (buttons, tabs, badges, …).
5
- * Each maps to a `.s-s.<role>` class that sets `--s-a` (ink) and `--s-b` (fill).
6
- */
7
- export type SurfaceRole = "primary" | "secondary" | "gradient" | "neutral" | "danger" | "success" | "warning";
8
-
9
- /** How a surface's two colours are rendered. `filled` is the default. */
10
- export type Variant = "filled" | "tonal" | "outlined";
11
-
12
3
  /**
13
4
  * Theming and global base styles for Staffa.
14
5
  *
@@ -48,7 +39,9 @@ export type Variant = "filled" | "tonal" | "outlined";
48
39
  * | `--s-fg-faint` | placeholders, disabled |
49
40
  * | `--s-border` / `--s-border-strong` | borders |
50
41
  * | `--s-accent` | brand "pop" colour (active indicators, etc.) |
51
- * | `--s-gradient` | primary→secondary brand sweep (mark, primary buttons, active nav) |
42
+ * | `--s-gradient` | full primary→secondary brand sweep (mark, active nav) |
43
+ * | `--s-gradient-surface` | compressed sweep for filled gradient surfaces (buttons) |
44
+ * | `--s-tint` | brand mid colour; the hue the neutral greys lean toward |
52
45
  * | `--s-glow` | soft coloured shadow for lit brand elements |
53
46
  * | `--s-link` / `--s-focus` | link & focus-ring colours |
54
47
  * | `--s-radius` / `--s-radius-lg` / `--s-shadow` | shape tokens |
@@ -145,13 +138,23 @@ export function getDarkMode(allowAuto = false): boolean | undefined {
145
138
  // names (--s-primary/-danger/-success/-warning) double as semantic *ink*
146
139
  // colours, legible as text/borders on neutral surfaces.
147
140
  // ---------------------------------------------------------------------------
141
+ // The neutral fills/inks aren't hard-coded greys: each one mixes a small dose
142
+ // of `--s-tint` (the brand's mid colour, defined in the static block below)
143
+ // into a true-grey base. Re-skin the brand and every "grey" — page, panels,
144
+ // ink, the neutral fill — drifts subtly toward the new brand hue, light and
145
+ // dark alike. The percentages are deliberately small: a tint you'd only spot
146
+ // in a side-by-side, never a colour cast.
148
147
  A(() => {
149
148
  if (getDarkMode()) {
150
149
  A.insertGlobalCss({
151
150
  ":root":
152
151
  "--s-primary:#8b7bff --s-secondary:#ef7fd0 --s-danger:#ff6b6b --s-success:#46d39a --s-warning:#fbbf24 " +
153
- "--s-neutral:#3c4352 --s-page:#0e1015 --s-panel:#181b22 --s-raised:#222632 " +
154
- "--s-ink:#e8eaf0 --s-on-accent:#0c0a14 --s-focus:rgba(139,123,255,0.45) " +
152
+ "--s-neutral: color-mix(in oklab, #3d4047, $s-tint 14%); " +
153
+ "--s-page: color-mix(in oklab, #0e0f12, $s-tint 5%); " +
154
+ "--s-panel: color-mix(in oklab, #17181c, $s-tint 6%); " +
155
+ "--s-raised: color-mix(in oklab, #212327, $s-tint 8%); " +
156
+ "--s-ink: color-mix(in oklab, #e9eaec, $s-tint 8%); " +
157
+ "--s-on-accent:#0c0a14 --s-focus: color-mix(in srgb, $s-primary 45%, transparent); " +
155
158
  "--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 34px rgba(0,0,0,0.5);",
156
159
  // Contextual link, restored across the neutral group (see static block).
157
160
  ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#6db3ff",
@@ -160,8 +163,12 @@ A(() => {
160
163
  A.insertGlobalCss({
161
164
  ":root":
162
165
  "--s-primary:#6c5ce7 --s-secondary:#d6459e --s-danger:#e23b3b --s-success:#1f9d6b --s-warning:#d97706 " +
163
- "--s-neutral:#c7ccda --s-page:#f3f4f8 --s-panel:#ffffff --s-raised:#eceef4 " +
164
- "--s-ink:#1b1e27 --s-on-accent:#0c0a14 --s-focus:rgba(108,92,231,0.35) " +
166
+ "--s-neutral: color-mix(in oklab, #c9cbd0, $s-tint 14%); " +
167
+ "--s-page: color-mix(in oklab, #f3f4f6, $s-tint 5%); " +
168
+ "--s-panel: color-mix(in oklab, #ffffff, $s-tint 2%); " +
169
+ "--s-raised: color-mix(in oklab, #edeef0, $s-tint 7%); " +
170
+ "--s-ink: color-mix(in oklab, #1d1f24, $s-tint 7%); " +
171
+ "--s-on-accent:#0c0a14 --s-focus: color-mix(in srgb, $s-primary 35%, transparent); " +
165
172
  "--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 30px rgba(20,24,40,0.13);",
166
173
  ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#2563eb",
167
174
  });
@@ -189,13 +196,19 @@ A.setSpacingCssVars();
189
196
  A.insertGlobalCss({
190
197
  // Derived brand tokens. These only reference the per-mode palette colours, so
191
198
  // they're defined once here and track the active mode (and any re-skin):
192
- // `--s-gradient` is the primary→secondary brand sweep used for the headline
193
- // mark, primary buttons and the active nav pill; `--s-glow` is a soft coloured
194
- // shadow that makes those same elements feel lit; `--s-page-bg` is a faint
199
+ // `--s-tint` is the brand's mid colour, the hue the neutral greys above lean
200
+ // toward; `--s-gradient` is the full primary→secondary sweep used where the
201
+ // brand should *show* (the headline mark, the active nav pill, tab edges);
202
+ // `--s-gradient-surface` is a compressed, near-vertical cut of that same sweep
203
+ // for *filled* gradient surfaces (buttons): it reads as one rich colour with
204
+ // depth rather than a two-colour banner; `--s-glow` is a soft coloured shadow
205
+ // that makes lit brand elements feel raised; `--s-page-bg` is a faint
195
206
  // twin-corner aurora wash painted on the page surface.
196
207
  ":root":
208
+ "--s-tint: color-mix(in oklab, $s-primary, $s-secondary); " +
197
209
  "--s-gradient: linear-gradient(135deg, $s-primary, $s-secondary); " +
198
- "--s-glow: 0 8px 24px color-mix(in srgb, $s-primary 32%, transparent); " +
210
+ "--s-gradient-surface: linear-gradient(170deg, color-mix(in oklab, $s-primary 85%, $s-secondary), color-mix(in oklab, $s-primary 30%, $s-secondary)); " +
211
+ "--s-glow: 0 5px 16px color-mix(in srgb, $s-primary 26%, transparent); " +
199
212
  "--s-page-bg: radial-gradient(120% 80% at 100% 0%, color-mix(in oklab, $s-secondary, transparent 86%), transparent 56%), radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, $s-primary, transparent 87%), transparent 56%), $s-page;",
200
213
 
201
214
  // Level/role modifier → anchors. Levels (neutral elevations) use the shared
@@ -250,10 +263,11 @@ A.insertGlobalCss({
250
263
  // overrides a component's default `.tonal`/`.outlined` (resetting both the
251
264
  // anchors and the painted background).
252
265
  ".s-s.filled": "--s-fg:$s-a --s-bg:$s-b background:$s-bg;",
253
- // Paint the brand sweep for a filled `.gradient` surface. Sits after the
254
- // variant rules and is keyed on `:not(.tonal):not(.outlined)`, so those
255
- // variants keep their solid-primary tint/edge.
256
- ".s-s.gradient:not(.tonal):not(.outlined)": "background: $s-gradient;",
266
+ // Paint the brand sweep for a filled `.gradient` surface the compressed
267
+ // surface cut, not the full banner sweep. Sits after the variant rules and is
268
+ // keyed on `:not(.tonal):not(.outlined)`, so those variants keep their
269
+ // solid-primary tint/edge.
270
+ ".s-s.gradient:not(.tonal):not(.outlined)": "background: $s-gradient-surface;",
257
271
 
258
272
  // Contextual accent: the brand pop colour on neutral surfaces. Declared on the
259
273
  // whole neutral group so re-entering a neutral surface under a coloured one