staffa 0.1.0 → 0.3.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 (68) hide show
  1. package/README.md +161 -97
  2. package/dist/components/autocomplete.js +21 -20
  3. package/dist/components/box.d.ts +8 -6
  4. package/dist/components/box.js +16 -12
  5. package/dist/components/button.d.ts +21 -35
  6. package/dist/components/button.js +46 -39
  7. package/dist/components/buttonChooser.d.ts +41 -0
  8. package/dist/components/buttonChooser.js +38 -0
  9. package/dist/components/buttonGroup.d.ts +3 -3
  10. package/dist/components/buttonGroup.js +18 -21
  11. package/dist/components/checkbox.js +7 -7
  12. package/dist/components/dialog.d.ts +20 -25
  13. package/dist/components/dialog.js +81 -91
  14. package/dist/components/field.d.ts +8 -6
  15. package/dist/components/field.js +16 -18
  16. package/dist/components/form.d.ts +3 -3
  17. package/dist/components/form.js +4 -4
  18. package/dist/components/main.d.ts +41 -14
  19. package/dist/components/main.js +193 -53
  20. package/dist/components/menu.d.ts +118 -0
  21. package/dist/components/menu.js +218 -0
  22. package/dist/components/select.d.ts +1 -1
  23. package/dist/components/select.js +5 -5
  24. package/dist/components/tabs.d.ts +5 -5
  25. package/dist/components/tabs.js +15 -22
  26. package/dist/components/textarea.js +4 -4
  27. package/dist/components/textline.js +1 -1
  28. package/dist/components/toast.d.ts +37 -0
  29. package/dist/components/toast.js +79 -0
  30. package/dist/components/tooltip.d.ts +32 -0
  31. package/dist/components/tooltip.js +130 -0
  32. package/dist/core.d.ts +26 -39
  33. package/dist/core.js +6 -5
  34. package/dist/icons-helpers.d.ts +46 -0
  35. package/dist/icons-helpers.js +44 -0
  36. package/dist/icons.d.ts +1960 -0
  37. package/dist/icons.js +1972 -0
  38. package/dist/index.d.ts +21 -8
  39. package/dist/index.js +17 -8
  40. package/dist/staffa.esm.js +1 -0
  41. package/dist/theme.d.ts +9 -75
  42. package/dist/theme.js +279 -82
  43. package/package.json +12 -5
  44. package/src/components/autocomplete.ts +21 -20
  45. package/src/components/box.ts +21 -15
  46. package/src/components/button.ts +59 -75
  47. package/src/components/buttonChooser.ts +65 -0
  48. package/src/components/buttonGroup.ts +18 -21
  49. package/src/components/checkbox.ts +7 -7
  50. package/src/components/dialog.ts +101 -102
  51. package/src/components/field.ts +22 -22
  52. package/src/components/form.ts +7 -7
  53. package/src/components/main.ts +212 -52
  54. package/src/components/menu.ts +288 -0
  55. package/src/components/select.ts +4 -4
  56. package/src/components/tabs.ts +20 -27
  57. package/src/components/textarea.ts +4 -4
  58. package/src/components/textline.ts +1 -1
  59. package/src/components/toast.ts +115 -0
  60. package/src/components/tooltip.ts +139 -0
  61. package/src/core.ts +26 -40
  62. package/src/icons-helpers.ts +90 -0
  63. package/src/icons.ts +1977 -0
  64. package/src/index.ts +21 -8
  65. package/src/theme.ts +300 -135
  66. package/dist/components/modal.d.ts +0 -2
  67. package/dist/components/modal.js +0 -2
  68. package/dist/skye.esm.js +0 -1
package/dist/theme.js CHANGED
@@ -1,65 +1,89 @@
1
1
  import A from "aberdeen";
2
2
  /**
3
- * The default dark Skye theme: modern and intentionally a little vivid so it
4
- * stands out of the box.
3
+ * Theming and global base styles for Staffa.
5
4
  *
6
- * This is a live Aberdeen proxy — mutate it (e.g. `darkTheme.sPrimary = "..."`)
7
- * and, while dark mode is active, the change flows straight into the CSS
8
- * variables. Use this to retheme dark and {@link lightTheme} independently.
9
- */
10
- export const darkTheme = A.proxy({
11
- sBg: "#0e1015",
12
- sSurface: "#181b22",
13
- sSurfaceHi: "#222632",
14
- sFg: "#e8eaf0",
15
- sFgMuted: "#a6acba",
16
- sFgFaint: "#6b7280",
17
- sBorder: "#2c313c",
18
- sBorderStrong: "#3c4352",
19
- sPrimary: "#8b7bff",
20
- sPrimaryHover: "#a99dff",
21
- sPrimaryFg: "#0c0a1a",
22
- sDanger: "#ff6b6b",
23
- sSuccess: "#46d39a",
24
- sWarning: "#fbbf24",
25
- sFocus: "rgba(139, 123, 255, 0.45)",
26
- sRadius: "10px",
27
- sRadiusLg: "16px",
28
- sShadow: "0 8px 30px rgba(0, 0, 0, 0.45)",
29
- });
30
- /**
31
- * The light Skye theme — the same lavender brand, retuned for a bright,
32
- * modern surface: white cards on a soft grey page, a deeper primary so it
33
- * reads well on light backgrounds, and a softer elevation shadow.
5
+ * # The surface model
34
6
  *
35
- * Like {@link darkTheme}, a live proxy: mutate it to retheme light mode.
36
- */
37
- export const lightTheme = A.proxy({
38
- sBg: "#f3f4f8",
39
- sSurface: "#ffffff",
40
- sSurfaceHi: "#eceef4",
41
- sFg: "#1b1e27",
42
- sFgMuted: "#5b6273",
43
- sFgFaint: "#9aa1b2",
44
- sBorder: "#e2e5ee",
45
- sBorderStrong: "#c7ccda",
46
- sPrimary: "#6c5ce7",
47
- sPrimaryHover: "#5847d4",
48
- sPrimaryFg: "#ffffff",
49
- sDanger: "#e23b3b",
50
- sSuccess: "#1f9d6b",
51
- sWarning: "#d97706",
52
- sFocus: "rgba(108, 92, 231, 0.35)",
53
- sRadius: "10px",
54
- sRadiusLg: "16px",
55
- sShadow: "0 6px 24px rgba(20, 24, 40, 0.12)",
56
- });
57
- const STORAGE_KEY = "skye:darkMode";
58
- /**
59
- * The explicit dark-mode choice — `true` (force dark), `false` (force light) or
60
- * `undefined` (follow the OS via {@link A.darkMode}). A reactive proxy, seeded
61
- * from localStorage so the persisted preference applies on the first paint.
7
+ * A Staffa app is a tree of **surfaces**. A surface is anything with its own
8
+ * background and the text colour that goes on it — the page, a card, a raised
9
+ * header, a coloured button. Mark an element as a surface with the `.s-s` class,
10
+ * then add modifier classes to colour it. A surface carries two colours:
11
+ *
12
+ * - `--s-a` — its default **foreground** (ink)
13
+ * - `--s-b` — its default **background** (fill)
14
+ *
15
+ * set by a **level** modifier — `.base` (the page), `.panel` (the default card),
16
+ * `.raised` (elevated chrome) — or an **accent role** modifier — `.primary`,
17
+ * `.secondary`, `.gradient` (the brand sweep), `.neutral`, `.danger`,
18
+ * `.success`, `.warning`.
19
+ *
20
+ * A **variant** modifier — `.filled` (the default), `.tonal` or `.outlined` —
21
+ * decides how `--s-a`/`--s-b` map onto the tokens widgets read
22
+ * (`--s-fg`/`--s-bg`). A shared rule then derives muted/faint/border from that
23
+ * pair and paints the element. Because the derivation reads `$s-fg`/`$s-bg`,
24
+ * every surface gets its *own* legible secondary colours: drop a widget on any
25
+ * surface and it adapts. (`:root` is an implicit filled surface, so the page
26
+ * renders without extra classes.)
27
+ *
28
+ * Components build surfaces by combining classes, e.g.
29
+ * `A("div.s-s.panel.outlined", opts.attrs)` — and because `opts.attrs` is the
30
+ * caller's escape hatch, passing e.g. `.filled` or `.danger` overrides the
31
+ * default look.
32
+ *
33
+ * | token | meaning |
34
+ * | ---------------- | ---------------------------------------- |
35
+ * | `--s-fg` | default text |
36
+ * | `--s-bg` | background |
37
+ * | `--s-fg-muted` | secondary text (subtitles, help) |
38
+ * | `--s-fg-faint` | placeholders, disabled |
39
+ * | `--s-border` / `--s-border-strong` | borders |
40
+ * | `--s-accent` | brand "pop" colour (active indicators, etc.) |
41
+ * | `--s-gradient` | primary→secondary brand sweep (mark, primary buttons, active nav) |
42
+ * | `--s-glow` | soft coloured shadow for lit brand elements |
43
+ * | `--s-link` / `--s-focus` | link & focus-ring colours |
44
+ * | `--s-radius` / `--s-radius-lg` / `--s-shadow` | shape tokens |
45
+ *
46
+ * `--s-accent` and `--s-link` default to the brand / link colour, but on a
47
+ * bright coloured surface (`.primary`, `.danger`, …) they fall back to that
48
+ * surface's own ink so they stay legible.
49
+ *
50
+ * # The palette
51
+ *
52
+ * All colours come from a small set of named **palette** tokens on `:root`, set
53
+ * per mode — the only place colours live, and the single place to re-skin:
54
+ * `--s-primary`, `--s-secondary` (the two ends of `--s-gradient`), `--s-danger`,
55
+ * `--s-success`, `--s-warning` (accent fills, which
56
+ * double as semantic *ink* on neutral surfaces), `--s-neutral`, `--s-page`,
57
+ * `--s-panel`, `--s-raised` (neutral fills), `--s-ink` (text on neutral) and
58
+ * `--s-on-accent` (text on accent fills), plus `--s-link`/`--s-focus` and the
59
+ * shape tokens. Every surface rule is wired to these, so they adapt with the
60
+ * mode and with any override.
61
+ *
62
+ * # Variants
63
+ *
64
+ * Because the variant decides how `--s-a`/`--s-b` become `--s-fg`/`--s-bg`, the
65
+ * three looks are generic and work on *any* role: `.tonal` reads the fill colour
66
+ * as ink over a soft self-tint; `.outlined` reads it as ink over a transparent
67
+ * fill with a coloured edge (inheriting the parent's background, so its derived
68
+ * tokens read the real surroundings). Inside any of them `--s-fg`/`--s-bg` still
69
+ * describe the real, rendered colours.
70
+ *
71
+ * # Customising
72
+ *
73
+ * There's no JS theme object — the colours live in the palette `insertGlobalCss`
74
+ * call below, branched on {@link getDarkMode}. Re-skin from your app by
75
+ * overriding palette tokens (per mode if you like), or give a surface an
76
+ * image/gradient background (set `--s-b` to the dominant fallback colour so
77
+ * derived tokens stay sensible). Staffa uses global, `s-`-prefixed classes, so
78
+ * nothing is scoped away from you.
79
+ *
80
+ * ```ts
81
+ * A(() => A.insertGlobalCss({ ":root": getDarkMode() ? "--s-primary:#28c4a0" : "--s-primary:#1f9d6b" }));
82
+ * A.insertGlobalCss({ ".s-s.panel": "background: url(paper.png); --s-b: #efe9dd" });
83
+ * ```
62
84
  */
85
+ const STORAGE_KEY = "staffa:darkMode";
86
+ /** The explicit dark-mode choice; `undefined` follows the OS via {@link A.darkMode}. */
63
87
  const $override = A.proxy({ value: readStoredOverride() });
64
88
  /** Read the persisted dark-mode override from localStorage (defensively). */
65
89
  function readStoredOverride() {
@@ -77,8 +101,7 @@ function readStoredOverride() {
77
101
  }
78
102
  /**
79
103
  * Force dark mode (`true`), light mode (`false`), or follow the OS preference
80
- * (`undefined`). Takes effect immediately and is persisted to localStorage, so
81
- * the choice survives reloads.
104
+ * (`undefined`). Takes effect immediately and is persisted to localStorage.
82
105
  */
83
106
  export function setDarkMode(value) {
84
107
  $override.value = value;
@@ -96,40 +119,214 @@ export function setDarkMode(value) {
96
119
  * Whether dark mode is currently active. Reactive — read it inside a scope to
97
120
  * re-run on changes.
98
121
  *
99
- * @param allowAuto - When `true`, returns `undefined` (rather than resolving to
100
- * a boolean) if the user is following the OS preference, so a dark/light/auto
101
- * control can tell the three states apart.
122
+ * @param allowAuto - When `true`, returns `undefined` (rather than a boolean) if
123
+ * the user is following the OS preference, so a dark/light/auto control can
124
+ * tell the three states apart.
102
125
  */
103
126
  export function getDarkMode(allowAuto = false) {
104
127
  const v = $override.value;
105
128
  return v === undefined && !allowAuto ? A.darkMode() : v;
106
129
  }
107
- // Set up everything as this module loads. Spacing scale first ($1 = 0.25rem,
108
- // $2 = 0.5rem, $3 = 1rem, ...), then reactively merge the active theme into the
109
- // CSS variables. This scope runs synchronously now before the first paint —
110
- // so the correct colors are in place immediately (no flash), and A.merge
111
- // subscribes to the theme it reads, so toggling the mode or mutating
112
- // darkTheme / lightTheme re-applies automatically.
113
- A.setSpacingCssVars();
130
+ // ---------------------------------------------------------------------------
131
+ // Reactive palette the ONE place colours live, and the only thing that
132
+ // differs between light and dark. Everything else is wired to these named
133
+ // tokens, so an app can re-skin by overriding just a few of them. The accent
134
+ // names (--s-primary/-danger/-success/-warning) double as semantic *ink*
135
+ // colours, legible as text/borders on neutral surfaces.
136
+ // ---------------------------------------------------------------------------
114
137
  A(() => {
115
- A.merge(A.cssVars, getDarkMode() ? darkTheme : lightTheme);
138
+ if (getDarkMode()) {
139
+ A.insertGlobalCss({
140
+ ":root": "--s-primary:#8b7bff --s-secondary:#ef7fd0 --s-danger:#ff6b6b --s-success:#46d39a --s-warning:#fbbf24 " +
141
+ "--s-neutral:#3c4352 --s-page:#0e1015 --s-panel:#181b22 --s-raised:#222632 " +
142
+ "--s-ink:#e8eaf0 --s-on-accent:#0c0a14 --s-focus:rgba(139,123,255,0.45) " +
143
+ "--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 34px rgba(0,0,0,0.5);",
144
+ // Contextual link, restored across the neutral group (see static block).
145
+ ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#6db3ff",
146
+ });
147
+ }
148
+ else {
149
+ A.insertGlobalCss({
150
+ ":root": "--s-primary:#6c5ce7 --s-secondary:#d6459e --s-danger:#e23b3b --s-success:#1f9d6b --s-warning:#d97706 " +
151
+ "--s-neutral:#c7ccda --s-page:#f3f4f8 --s-panel:#ffffff --s-raised:#eceef4 " +
152
+ "--s-ink:#1b1e27 --s-on-accent:#0c0a14 --s-focus:rgba(108,92,231,0.35) " +
153
+ "--s-radius:12px --s-radius-lg:18px --s-shadow: 0 10px 30px rgba(20,24,40,0.13);",
154
+ ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#2563eb",
155
+ });
156
+ }
116
157
  });
117
- // A deliberately light reset. It sets box-sizing and sensible colors/fonts, but
118
- // does NOT strip margins from headings/paragraphs/lists, so rendered rich
119
- // content (e.g. markdown-to-HTML) keeps reasonable default rhythm.
158
+ // ---------------------------------------------------------------------------
159
+ // Static structure inserted once, all wired to the palette above.
160
+ //
161
+ // A surface is marked with `.s-s`. Its level/role modifier sets two anchors
162
+ // (--s-a ink, --s-b fill); its variant modifier (`.filled` default, `.tonal`,
163
+ // `.outlined`) decides how those anchors map onto the rendered fg/bg. `:root`
164
+ // (the page) is an implicit filled surface. Every `.s-s` then runs the same
165
+ // derive+paint step, reading the resolved fg/bg, so each one gets its own
166
+ // legible secondary tokens regardless of variant.
167
+ //
168
+ // Rule order matters: the variant/role rules below all share specificity, so a
169
+ // later one wins. `.filled` therefore comes *last* among the variants — that's
170
+ // what lets a caller's `attrs: ".filled"` override a component's default
171
+ // `.tonal`/`.outlined`.
172
+ // ---------------------------------------------------------------------------
173
+ A.setSpacingCssVars();
174
+ A.insertGlobalCss({
175
+ // Derived brand tokens. These only reference the per-mode palette colours, so
176
+ // they're defined once here and track the active mode (and any re-skin):
177
+ // `--s-gradient` is the primary→secondary brand sweep used for the headline
178
+ // mark, primary buttons and the active nav pill; `--s-glow` is a soft coloured
179
+ // shadow that makes those same elements feel lit; `--s-page-bg` is a faint
180
+ // twin-corner aurora wash painted on the page surface.
181
+ ":root": "--s-gradient: linear-gradient(135deg, $s-primary, $s-secondary); " +
182
+ "--s-glow: 0 8px 24px color-mix(in srgb, $s-primary 32%, transparent); " +
183
+ "--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;",
184
+ // Level/role modifier → anchors. Levels (neutral elevations) use the shared
185
+ // ink; accent roles use the on-accent ink over their named fill. The page
186
+ // (`.base`) additionally paints the aurora wash; `--s-b` keeps the solid
187
+ // fallback so every derived token stays sensible.
188
+ ":root, .s-s.base": "--s-a:$s-ink --s-b:$s-page",
189
+ ".s-s.base": "background: $s-page-bg;",
190
+ ".s-s.panel": "--s-a:$s-ink --s-b:$s-panel",
191
+ ".s-s.raised": "--s-a:$s-ink --s-b:$s-raised",
192
+ ".s-s.neutral": "--s-a:$s-ink --s-b:$s-neutral",
193
+ ".s-s.primary": "--s-a:$s-on-accent --s-b:$s-primary",
194
+ ".s-s.secondary": "--s-a:$s-on-accent --s-b:$s-secondary",
195
+ ".s-s.danger": "--s-a:$s-on-accent --s-b:$s-danger",
196
+ ".s-s.success": "--s-a:$s-on-accent --s-b:$s-success",
197
+ ".s-s.warning": "--s-a:$s-on-accent --s-b:$s-warning",
198
+ // `.gradient` is an accent role whose fill is the brand sweep. Its `--s-b`
199
+ // anchor stays the solid `--s-primary` so the derived tokens (muted ink,
200
+ // border, …) remain sensible; the gradient itself is painted further down,
201
+ // only in the filled context (tonal/outlined read the solid fallback instead).
202
+ ".s-s.gradient": "--s-a:$s-on-accent --s-b:$s-primary",
203
+ // Filled default (bare `.s-s` and `:root`): map the anchors to fg/bg, derive
204
+ // the secondary tokens from that pair, then paint. var() resolves at use time,
205
+ // so the tonal/outlined remaps below feed back into the derived tokens.
206
+ ":root, .s-s": "--s-fg:$s-a --s-bg:$s-b " +
207
+ "--s-fg-muted: color-mix(in oklab, $s-fg, $s-bg 42%); " +
208
+ "--s-fg-faint: color-mix(in oklab, $s-fg, $s-bg 64%); " +
209
+ "--s-border: color-mix(in oklab, $s-fg, $s-bg 82%); " +
210
+ "--s-border-strong: color-mix(in oklab, $s-fg, $s-bg 68%); " +
211
+ // Themed scrollbars (standard properties, honoured by Firefox and modern
212
+ // Chromium): the thumb uses the surface's derived border token, so the bar
213
+ // tracks the active mode/re-skin and a nested surface's own colours.
214
+ "scrollbar-width:thin scrollbar-color: $s-border-strong transparent; " +
215
+ "background:$s-bg color:$s-fg",
216
+ // WebKit/older-Chromium counterpart to the standard `scrollbar-*` props above:
217
+ // a transparent track and a rounded thumb (border-clipped to leave breathing
218
+ // room) that brightens on hover.
219
+ ".s-s::-webkit-scrollbar, .s-s ::-webkit-scrollbar": "width:10px height:10px",
220
+ ".s-s::-webkit-scrollbar-track, .s-s ::-webkit-scrollbar-track": "background:transparent",
221
+ ".s-s::-webkit-scrollbar-thumb, .s-s ::-webkit-scrollbar-thumb": "background:$s-border-strong border-radius:99px border: 2px solid transparent; background-clip:padding-box",
222
+ ".s-s::-webkit-scrollbar-thumb:hover, .s-s ::-webkit-scrollbar-thumb:hover": "background:$s-fg-faint background-clip:padding-box",
223
+ // Tonal: the fill colour becomes the ink, over a soft tint of itself.
224
+ ".s-s.tonal": "--s-fg:$s-b --s-bg: color-mix(in srgb, $s-b 16%, transparent);",
225
+ // Outlined: the fill colour is the ink; --s-bg *inherits* the parent's bg (the
226
+ // token the derivations read, so the edge mixes ink with the real surroundings)
227
+ // while the painted background is transparent, letting that parent fill — even
228
+ // a gradient or image — show through.
229
+ ".s-s.outlined": "--s-fg:$s-b --s-bg:inherit background:transparent --s-border: color-mix(in srgb, $s-fg 55%, $s-bg);",
230
+ // Filled, explicit — last among the variants so a caller's `attrs: ".filled"`
231
+ // overrides a component's default `.tonal`/`.outlined` (resetting both the
232
+ // anchors and the painted background).
233
+ ".s-s.filled": "--s-fg:$s-a --s-bg:$s-b background:$s-bg;",
234
+ // Paint the brand sweep for a filled `.gradient` surface. Sits after the
235
+ // variant rules and is keyed on `:not(.tonal):not(.outlined)`, so those
236
+ // variants keep their solid-primary tint/edge.
237
+ ".s-s.gradient:not(.tonal):not(.outlined)": "background: $s-gradient;",
238
+ // Contextual accent: the brand pop colour on neutral surfaces. Declared on the
239
+ // whole neutral group so re-entering a neutral surface under a coloured one
240
+ // restores it. (--s-link gets the same treatment in the reactive block, where
241
+ // its per-mode literal lives — it can't reference the palette, as the palette
242
+ // source and the contextual token share the name --s-link.)
243
+ ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-accent:$s-primary",
244
+ // On a bright coloured surface those wouldn't be legible, so they fall back to
245
+ // the surface's own ink. (Keyed on the role modifier, so it holds across
246
+ // variants — and tracks the tonal/outlined fg remap.)
247
+ ".s-s.primary, .s-s.secondary, .s-s.danger, .s-s.success, .s-s.warning, .s-s.gradient": "--s-accent:$s-fg --s-link:$s-fg",
248
+ });
249
+ // A deliberately light reset. Colours/shape come from the contextual tokens, so
250
+ // rich content (e.g. markdown-to-HTML) adapts to whatever surface holds it. The
251
+ // vertical rhythm / typography of block elements is handled separately, below.
120
252
  A.insertGlobalCss({
121
253
  "*, *::before, *::after": "box-sizing:border-box",
122
254
  html: "text-size-adjust:100%",
123
- body: "m:0 bg:$sBg fg:$sFg line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased",
124
- a: "fg:$sPrimary text-decoration:underline text-underline-offset:2px",
125
- "a:hover": "fg:$sPrimaryHover",
255
+ body: "m:0 line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased",
256
+ a: "color: $s-link; text-decoration:underline text-underline-offset:2px; transition: color 0.12s, filter 0.12s;",
257
+ "a:hover": "filter: brightness(1.15)",
126
258
  "input, button, textarea, select": "font:inherit color:inherit",
127
259
  "code, kbd, samp, pre": "font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;",
128
- code: "bg:$sSurfaceHi padding: 0.12em 0.34em; r:4px font-size:0.9em",
129
- pre: "bg:$sSurface p:$3 r:$sRadius overflow:auto",
130
- "pre code": "bg:transparent p:0",
260
+ code: "background: color-mix(in oklab, $s-fg, $s-bg 86%); padding: 0.12em 0.34em; r:4px font-size:0.9em",
261
+ pre: "background: color-mix(in oklab, $s-fg, $s-bg 92%); p:$3 r: $s-radius; overflow:auto",
262
+ "pre code": "background:transparent p:0",
131
263
  "img, svg, video, canvas": "max-width:100% h:auto",
132
- hr: "border:0 border-top: 1px solid $sBorder; margin: $3 0;",
133
- "::placeholder": "fg:$sFgFaint opacity:1",
134
- ":focus-visible": "outline: 2px solid $sFocus; outline-offset:2px",
264
+ hr: "border:0 border-top: 1px solid $s-border;",
265
+ "::placeholder": "color: $s-fg-faint; opacity:1",
266
+ ":focus-visible": "outline: 2px solid $s-focus; outline-offset:2px",
267
+ small: "color:$s-fg-muted font-size:0.9em",
268
+ // Respect users who prefer less motion: keep transitions essentially instant.
269
+ "@media (prefers-reduced-motion: reduce)": {
270
+ "*, *::before, *::after": "transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; scroll-behavior: auto !important;",
271
+ },
272
+ });
273
+ // ── Suppress transitions during the initial load ─────────────────────────────
274
+ // Buttons (and links) transition their colours, so a light↔dark switch animates
275
+ // smoothly. On a *cold* load that's a liability: elements mount and paint in the
276
+ // active theme in one pass, but a transitioning element animates from its
277
+ // default (unstyled) colours into the theme — so a fresh button visibly slides
278
+ // from light to dark while the rest of the page is already correct. Tag <html>
279
+ // until the first frame has painted and hard-disable transitions under that tag,
280
+ // so the initial render snaps straight to the right colours. Two rAFs: the first
281
+ // runs before the paint that shows the themed UI, the second clears the tag just
282
+ // after it — later theme switches then animate as normal.
283
+ A.insertGlobalCss({
284
+ ".s-preload, .s-preload *, .s-preload *::before, .s-preload *::after": "transition: none !important; animation: none !important;",
285
+ });
286
+ if (typeof document !== "undefined" && typeof requestAnimationFrame === "function") {
287
+ const root = document.documentElement;
288
+ root.classList.add("s-preload");
289
+ requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove("s-preload")));
290
+ }
291
+ // ── Flow content: vertical rhythm & light typography ─────────────────────────
292
+ // Sensible block defaults for *any* content — your own UI just as much as
293
+ // markdown-rendered HTML. The rhythm: strip the browser's block margins, then
294
+ // give every block a *top* margin only when it isn't its parent's first child.
295
+ // Content sits flush against its container's edges, and space appears solely
296
+ // *between* siblings. These are intentionally low-specificity defaults — a
297
+ // component or utility that sets its own margin (or an inline `mt:` shortcut)
298
+ // always wins, so a class only ever changes what it actually names.
299
+ const BLOCK = "p, ul, ol, dl, blockquote, pre, table, figure, hr, h1, h2, h3, h4, h5, h6";
300
+ A.insertGlobalCss({
301
+ [`${BLOCK}`]: {
302
+ "&": "margin:0",
303
+ "&:not(:first-child)": "margin-top:$3",
304
+ },
305
+ // Headings: bold, tight, balanced. Big levels get slightly negative tracking,
306
+ // small levels become spaced "labels" — so adjacent levels stay distinct. The
307
+ // em-based top margin gives larger headings a little more room above.
308
+ "h1, h2, h3, h4, h5, h6": {
309
+ "&": "line-height:1.15 font-weight:700 text-wrap:balance",
310
+ "&:not(:first-child)": "margin-top:1.4em",
311
+ },
312
+ h1: "font-size:2em font-weight:800 letter-spacing:-0.022em",
313
+ h2: "font-size:1.55em letter-spacing:-0.018em",
314
+ h3: "font-size:1.3em letter-spacing:-0.011em",
315
+ h4: "font-size:1.1em",
316
+ h5: "font-size:0.95em letter-spacing:0.005em",
317
+ h6: "font-size:0.8em fg:$s-fg-muted text-transform:uppercase letter-spacing:0.07em",
318
+ // Lists: markers, a sensible indent, gently spaced items, tight nesting.
319
+ "ul, ol": {
320
+ "&": "padding-left:1.5em",
321
+ "> li:not(:first-child), li > &:not(:first-child)": "margin-top:$1",
322
+ },
323
+ // Blockquote, tables, definition lists, figure captions.
324
+ blockquote: "border-left: 3px solid $s-border; padding-left: $3; fg: $s-fg-muted",
325
+ table: "border-collapse:collapse",
326
+ "th, td": "text-align:left padding: $1 $2; border-bottom: 1px solid $s-border; vertical-align:top",
327
+ th: "font-weight:600",
328
+ "thead th": "border-bottom: 2px solid $s-border-strong;",
329
+ dt: "font-weight:600",
330
+ dd: "margin-left: 1.5em",
331
+ figcaption: "fg:$s-fg-muted font-size:0.9em margin-top:$1 text-align:center",
135
332
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staffa",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "An opinionated component library for the Aberdeen reactive UI library.",
5
5
  "license": "ISC",
6
6
  "author": "",
@@ -12,6 +12,10 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "default": "./dist/index.js"
14
14
  },
15
+ "./icons.js": {
16
+ "types": "./dist/icons.d.ts",
17
+ "default": "./dist/icons.js"
18
+ },
15
19
  "./all.js": "./dist/staffa.esm.js"
16
20
  },
17
21
  "files": [
@@ -19,17 +23,20 @@
19
23
  "src"
20
24
  ],
21
25
  "scripts": {
22
- "build": "tsc && tsc -p demo && esbuild src/index.ts --bundle --external:aberdeen --minify --format=esm --outfile=dist/staffa.esm.js",
26
+ "build:icons": "node scripts/generate-icons.mjs",
27
+ "build": "npm run build:icons && tsc && tsc -p demo && esbuild src/index.ts --bundle --external:aberdeen --minify --format=esm --outfile=dist/staffa.esm.js",
23
28
  "typecheck": "tsc --noEmit && tsc -p demo --noEmit",
24
- "smoke": "tsc && node smoke.mjs"
29
+ "smoke": "tsc && node smoke.mjs",
30
+ "prepublish": "npm run build"
25
31
  },
26
32
  "peerDependencies": {
27
- "aberdeen": "^1.15.0"
33
+ "aberdeen": "^1.17.1"
28
34
  },
29
35
  "devDependencies": {
30
- "aberdeen": "^1.15.0",
36
+ "aberdeen": "^1.17.1",
31
37
  "esbuild": "^0.28.0",
32
38
  "jsdom": "^29.1.1",
39
+ "lucide-static": "^1.17.0",
33
40
  "typescript": "^5.9.3"
34
41
  }
35
42
  }
@@ -33,21 +33,22 @@ export interface AutocompleteOptions extends FieldOptions {
33
33
  }
34
34
 
35
35
  A.insertGlobalCss({
36
- ".S_ac": {
36
+ ".s-ac": {
37
37
  "&": "position:relative",
38
- "> .S_control": "display:flex flex-wrap:wrap align-items:center gap:$1 bg:$sSurface fg:$sFg border: 1px solid $sBorder; r:$sRadius padding: 0.3em 0.4em; cursor:text; transition: border-color 0.15s, box-shadow 0.15s;",
39
- "> .S_control:hover": "border-color:$sBorderStrong",
40
- "> .S_control:focus-within": "border-color:$sPrimary box-shadow: 0 0 0 3px $sFocus;",
41
- "&[aria-invalid=true] > .S_control": "border-color:$sDanger",
42
- ".S_chip": "display:inline-flex align-items:center gap:$1 font-size:0.85em bg:$sSurfaceHi border: 1px solid $sBorder; r:$sRadius padding: 0.1em 0.2em 0.1em 0.5em;",
43
- ".S_chip > button": "cursor:pointer border:0 background:transparent fg:$sFgMuted font-size:1.1em line-height:1 padding: 0 0.2em; r:4px",
44
- ".S_chip > button:hover": "fg:$sFg background:$sBorder",
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",
40
+ "> .s-control:focus-within": "border-color:$s-accent box-shadow: 0 0 0 3px $s-focus;",
41
+ "&[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",
45
45
  "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:$sSurface border: 1px solid $sBorder; r:$sRadius box-shadow:$sShadow",
47
- ".S_option": "padding: 0.45em 0.6em; r:6px cursor:pointer",
48
- ".S_option[aria-selected=true]": "background:$sSurfaceHi",
49
- ".S_add": "fg:$sPrimary font-style:italic",
50
- ".S_empty": "padding: 0.45em 0.6em; fg:$sFgMuted",
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
+ "> .s-menu li": "margin:0",
48
+ ".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);",
50
+ ".s-add": "fg:$s-accent font-style:italic",
51
+ ".s-empty": "padding: 0.45em 0.6em; fg:$s-fg-muted",
51
52
  },
52
53
  });
53
54
 
@@ -123,19 +124,19 @@ export function autocomplete(opts: AutocompleteOptions): void {
123
124
  };
124
125
 
125
126
  drawField(opts, (id, isInvalid) => {
126
- A("div.S_ac", opts.control, () => {
127
+ A("div.s-ac", opts.inputAttrs, () => {
127
128
  A(() => A("aria-invalid=", isInvalid() ? "true" : "false"));
128
129
 
129
130
  let inputEl: HTMLInputElement | undefined;
130
131
 
131
- A("div.S_control", () => {
132
+ A("div.s-control", () => {
132
133
  A("click=", () => inputEl?.focus());
133
134
 
134
135
  // Chips for multi-select.
135
136
  A(() => {
136
137
  if (!opts.multi) return;
137
138
  for (const value of selectedValues()) {
138
- A("span.S_chip", () => {
139
+ A("span.s-chip", () => {
139
140
  A("span #", A.peek(() => labelFor(value)));
140
141
  A("button type=button aria-label=", `Remove ${value}`, () => {
141
142
  A("#×");
@@ -183,9 +184,9 @@ export function autocomplete(opts: AutocompleteOptions): void {
183
184
  const q = $st.query.trim();
184
185
  const showAdd = opts.allowCustom !== false && q !== "" && !list.some((o) => o.label.toLowerCase() === q.toLowerCase());
185
186
 
186
- A("ul.S_menu role=listbox", `id=${menuId}`, () => {
187
+ A("ul.s-menu role=listbox", `id=${menuId}`, () => {
187
188
  list.forEach((option, i) => {
188
- A("li.S_option role=option", `id=${menuId}-opt-${i}`, () => {
189
+ A("li.s-option role=option", `id=${menuId}-opt-${i}`, () => {
189
190
  A(() => A("aria-selected=", $st.active === i ? "true" : "false"));
190
191
  A("#", option.label);
191
192
  A("mousedown=", (e: Event) => e.preventDefault());
@@ -196,14 +197,14 @@ export function autocomplete(opts: AutocompleteOptions): void {
196
197
  });
197
198
  });
198
199
  if (showAdd) {
199
- A("li.S_option.S_add role=option", () => {
200
+ A("li.s-option.s-add role=option", () => {
200
201
  A("#", `Add "${q}"`);
201
202
  A("mousedown=", (e: Event) => e.preventDefault());
202
203
  A("click=", () => commit(q, inputEl));
203
204
  });
204
205
  }
205
206
  if (list.length === 0 && !showAdd) {
206
- A("li.S_empty #No matches");
207
+ A("li.s-empty #No matches");
207
208
  }
208
209
  });
209
210
  });
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { type Content, type ContentOptions, type Slot, type Styling, drawSlot } from "../core.js";
2
+ import { type Content, type ContentOptions, type Slot, type Attributes, drawSlot } from "../core.js";
3
3
 
4
4
  /** Options for {@link box}. */
5
5
  export interface BoxOptions extends ContentOptions {
@@ -7,18 +7,24 @@ export interface BoxOptions extends ContentOptions {
7
7
  header?: Slot;
8
8
  /** Footer content, drawn in a styled bar below the body. */
9
9
  footer?: Slot;
10
+ /** Aberdeen attr/style string applied to the body (content-holding) element. */
11
+ contentAttrs?: Attributes;
10
12
  /** Aberdeen attr/style string applied to the header bar. */
11
- headerInner?: Styling;
13
+ headerAttrs?: Attributes;
12
14
  /** Aberdeen attr/style string applied to the footer bar. */
13
- footerInner?: Styling;
15
+ footerAttrs?: Attributes;
14
16
  }
15
17
 
18
+ // The box itself is a `.panel` surface; its header/footer are `.raised`
19
+ // surfaces (classes set on the elements in `box()` below). Colours and borders
20
+ // come from the contextual tokens, so a box stays legible on whatever surface
21
+ // it's nested in.
16
22
  A.insertGlobalCss({
17
- ".S_box": {
18
- "&": "display:flex flex-direction:column bg:$sSurface border: 1px solid $sBorder; r:$sRadius overflow:hidden",
19
- "> header": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-bottom: 1px solid $sBorder; font-weight:600",
20
- "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; bg:$sSurfaceHi border-top: 1px solid $sBorder;",
21
- // The body is the only plain <div> child; give it the default padding+gap.
23
+ ".s-box": {
24
+ "&": "display:flex flex-direction:column border: 1px solid $s-border; r: $s-radius-lg; overflow:hidden box-shadow: $s-shadow;",
25
+ "&:not(:first-child)": "margin-top: $3",
26
+ "> header": "display:flex align-items:center gap:$2 padding: $2 $3; border-bottom: 1px solid $s-border; font-weight:600",
27
+ "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border;",
22
28
  "> div": "p:$3 gap:$3",
23
29
  },
24
30
  });
@@ -28,14 +34,14 @@ A.insertGlobalCss({
28
34
  * header and footer, and a padded body that holds {@link ContentOptions.content}.
29
35
  *
30
36
  * The body gets default `padding` and matching `gap`; add `display:flex` via
31
- * {@link ContentOptions.inner | inner} if you want its children laid out as a
32
- * flex container.
37
+ * {@link BoxOptions.contentAttrs | contentAttrs} if you want its children laid
38
+ * out as a flex container.
33
39
  *
34
40
  * Shortcut: pass a function to use it directly as the body content.
35
41
  *
36
42
  * @example
37
43
  * ```ts
38
- * S.box({ header: "Profile", inner: "display:flex flex-direction:column", content: () => {
44
+ * S.box({ header: "Profile", contentAttrs: "display:flex flex-direction:column", content: () => {
39
45
  * S.textline({ label: "Name", bind: A.ref($user, "name") });
40
46
  * }});
41
47
  * S.box(() => A("p#Just some content")); // shorthand
@@ -44,19 +50,19 @@ A.insertGlobalCss({
44
50
  export function box(opts: BoxOptions | Content = {}): void {
45
51
  const o: BoxOptions = typeof opts === "function" ? { content: opts } : opts;
46
52
 
47
- A("section.S_box", o.root, () => {
53
+ A("section.s-box.s-s.panel", o.attrs, () => {
48
54
  // Header and footer get their own scopes so toggling them doesn't recreate
49
55
  // the body (which may hold focused inputs / lots of content).
50
56
  A(() => {
51
- if (o.header != null) A("header", o.headerInner, () => drawSlot(o.header));
57
+ if (o.header != null) A("header.s-s.raised", o.headerAttrs, () => drawSlot(o.header));
52
58
  });
53
59
 
54
- A("div", o.inner, () => {
60
+ A("div", o.contentAttrs, () => {
55
61
  if (o.content) o.content();
56
62
  });
57
63
 
58
64
  A(() => {
59
- if (o.footer != null) A("footer", o.footerInner, () => drawSlot(o.footer));
65
+ if (o.footer != null) A("footer.s-s.raised", o.footerAttrs, () => drawSlot(o.footer));
60
66
  });
61
67
  });
62
68
  }