staffa 0.1.0 → 0.2.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 (50) hide show
  1. package/README.md +65 -27
  2. package/dist/components/autocomplete.js +20 -20
  3. package/dist/components/box.d.ts +8 -6
  4. package/dist/components/box.js +15 -11
  5. package/dist/components/button.d.ts +10 -33
  6. package/dist/components/button.js +17 -38
  7. package/dist/components/buttonChooser.d.ts +42 -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 -18
  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 +18 -17
  16. package/dist/components/form.d.ts +3 -3
  17. package/dist/components/form.js +4 -4
  18. package/dist/components/main.d.ts +7 -5
  19. package/dist/components/main.js +25 -23
  20. package/dist/components/select.d.ts +1 -1
  21. package/dist/components/select.js +5 -5
  22. package/dist/components/tabs.d.ts +5 -3
  23. package/dist/components/tabs.js +25 -19
  24. package/dist/components/textarea.js +4 -4
  25. package/dist/components/textline.js +1 -1
  26. package/dist/core.d.ts +26 -39
  27. package/dist/core.js +6 -5
  28. package/dist/index.d.ts +10 -8
  29. package/dist/index.js +9 -8
  30. package/dist/staffa.esm.js +1 -0
  31. package/dist/theme.d.ts +9 -75
  32. package/dist/theme.js +178 -82
  33. package/package.json +3 -2
  34. package/src/components/autocomplete.ts +20 -20
  35. package/src/components/box.ts +20 -14
  36. package/src/components/button.ts +24 -72
  37. package/src/components/buttonChooser.ts +66 -0
  38. package/src/components/buttonGroup.ts +18 -18
  39. package/src/components/checkbox.ts +7 -7
  40. package/src/components/dialog.ts +101 -102
  41. package/src/components/field.ts +24 -21
  42. package/src/components/form.ts +7 -7
  43. package/src/components/main.ts +30 -26
  44. package/src/components/select.ts +4 -4
  45. package/src/components/tabs.ts +29 -22
  46. package/src/components/textarea.ts +4 -4
  47. package/src/components/textline.ts +1 -1
  48. package/src/core.ts +26 -40
  49. package/src/index.ts +10 -8
  50. package/src/theme.ts +190 -135
package/src/theme.ts CHANGED
@@ -1,126 +1,98 @@
1
1
  import A from "aberdeen";
2
2
 
3
3
  /**
4
- * Theming and global base styles for Staffa.
5
- *
6
- * Staffa is themed entirely through CSS custom properties (via Aberdeen's
7
- * {@link A.cssVars}). Components reference these with `var(--sPrimary)` etc., so
8
- * changing a single variable restyles the whole app — at runtime, reactively.
9
- *
10
- * Unlike typical Aberdeen apps (which use component-local `insertCss`), Staffa uses
11
- * **global** CSS (`insertGlobalCss`) with class names prefixed `S_`. This is a
12
- * deliberate trade-off: it lets application authors override any Staffa style from
13
- * their own stylesheet without fighting scoped class names.
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).
14
6
  */
7
+ export type SurfaceRole = "primary" | "neutral" | "danger" | "success" | "warning";
15
8
 
16
- /**
17
- * The set of CSS custom properties Staffa understands. All are plain CSS color /
18
- * length strings. Override any subset by mutating {@link darkTheme} /
19
- * {@link lightTheme}.
20
- */
21
- export interface Theme {
22
- /** Page background — the darkest surface. */
23
- sBg: string;
24
- /** Default surface for cards, inputs, menus. */
25
- sSurface: string;
26
- /** Raised surface for headers, footers, chips, hover states. */
27
- sSurfaceHi: string;
28
- /** Primary foreground / text color. */
29
- sFg: string;
30
- /** Muted text (help text, subtitles). */
31
- sFgMuted: string;
32
- /** Faint text (placeholders, disabled). */
33
- sFgFaint: string;
34
- /** Default border color. */
35
- sBorder: string;
36
- /** Stronger border / neutral control color. */
37
- sBorderStrong: string;
38
- /** Brand / accent color. */
39
- sPrimary: string;
40
- /** Brand color, hover/brighter. */
41
- sPrimaryHover: string;
42
- /** Text drawn on top of {@link Theme.sPrimary}. */
43
- sPrimaryFg: string;
44
- /** Destructive / error color. */
45
- sDanger: string;
46
- /** Positive / success color. */
47
- sSuccess: string;
48
- /** Caution color. */
49
- sWarning: string;
50
- /** Focus-ring color (usually a translucent primary). */
51
- sFocus: string;
52
- /** Default corner radius. */
53
- sRadius: string;
54
- /** Larger corner radius (e.g. the {@link import("./components/main").main} sheet). */
55
- sRadiusLg: string;
56
- /** Elevation shadow for menus, dialogs, the framed content sheet. */
57
- sShadow: string;
58
- }
9
+ /** How a surface's two colours are rendered. `filled` is the default. */
10
+ export type Variant = "filled" | "tonal" | "outlined";
59
11
 
60
12
  /**
61
- * The default dark Staffa theme: modern and intentionally a little vivid so it
62
- * stands out of the box.
13
+ * Theming and global base styles for Staffa.
63
14
  *
64
- * This is a live Aberdeen proxy — mutate it (e.g. `darkTheme.sPrimary = "..."`)
65
- * and, while dark mode is active, the change flows straight into the CSS
66
- * variables. Use this to retheme dark and {@link lightTheme} independently.
67
- */
68
- export const darkTheme: Theme = A.proxy<Theme>({
69
- sBg: "#0e1015",
70
- sSurface: "#181b22",
71
- sSurfaceHi: "#222632",
72
- sFg: "#e8eaf0",
73
- sFgMuted: "#a6acba",
74
- sFgFaint: "#6b7280",
75
- sBorder: "#2c313c",
76
- sBorderStrong: "#3c4352",
77
- sPrimary: "#8b7bff",
78
- sPrimaryHover: "#a99dff",
79
- sPrimaryFg: "#0c0a1a",
80
- sDanger: "#ff6b6b",
81
- sSuccess: "#46d39a",
82
- sWarning: "#fbbf24",
83
- sFocus: "rgba(139, 123, 255, 0.45)",
84
- sRadius: "10px",
85
- sRadiusLg: "16px",
86
- sShadow: "0 8px 30px rgba(0, 0, 0, 0.45)",
87
- });
88
-
89
- /**
90
- * The light Staffa theme — the same lavender brand, retuned for a bright,
91
- * modern surface: white cards on a soft grey page, a deeper primary so it
92
- * reads well on light backgrounds, and a softer elevation shadow.
15
+ * # The surface model
16
+ *
17
+ * A Staffa app is a tree of **surfaces**. A surface is anything with its own
18
+ * background and the text colour that goes on it — the page, a card, a raised
19
+ * header, a coloured button. Mark an element as a surface with the `.s-s` class,
20
+ * then add modifier classes to colour it. A surface carries two colours:
21
+ *
22
+ * - `--s-a` — its default **foreground** (ink)
23
+ * - `--s-b` — its default **background** (fill)
24
+ *
25
+ * set by a **level** modifier — `.base` (the page), `.panel` (the default card),
26
+ * `.raised` (elevated chrome) — or an **accent role** modifier — `.primary`,
27
+ * `.neutral`, `.danger`, `.success`, `.warning`.
28
+ *
29
+ * A **variant** modifier — `.filled` (the default), `.tonal` or `.outlined` —
30
+ * decides how `--s-a`/`--s-b` map onto the tokens widgets read
31
+ * (`--s-fg`/`--s-bg`). A shared rule then derives muted/faint/border from that
32
+ * pair and paints the element. Because the derivation reads `$s-fg`/`$s-bg`,
33
+ * every surface gets its *own* legible secondary colours: drop a widget on any
34
+ * surface and it adapts. (`:root` is an implicit filled surface, so the page
35
+ * renders without extra classes.)
36
+ *
37
+ * Components build surfaces by combining classes, e.g.
38
+ * `A("div.s-s.panel.outlined", opts.attrs)` — and because `opts.attrs` is the
39
+ * caller's escape hatch, passing e.g. `.filled` or `.danger` overrides the
40
+ * default look.
41
+ *
42
+ * | token | meaning |
43
+ * | ---------------- | ---------------------------------------- |
44
+ * | `--s-fg` | default text |
45
+ * | `--s-bg` | background |
46
+ * | `--s-fg-muted` | secondary text (subtitles, help) |
47
+ * | `--s-fg-faint` | placeholders, disabled |
48
+ * | `--s-border` / `--s-border-strong` | borders |
49
+ * | `--s-accent` | brand "pop" colour (active indicators, etc.) |
50
+ * | `--s-link` / `--s-focus` | link & focus-ring colours |
51
+ * | `--s-radius` / `--s-radius-lg` / `--s-shadow` | shape tokens |
52
+ *
53
+ * `--s-accent` and `--s-link` default to the brand / link colour, but on a
54
+ * bright coloured surface (`.primary`, `.danger`, …) they fall back to that
55
+ * surface's own ink so they stay legible.
56
+ *
57
+ * # The palette
93
58
  *
94
- * Like {@link darkTheme}, a live proxy: mutate it to retheme light mode.
59
+ * All colours come from a small set of named **palette** tokens on `:root`, set
60
+ * per mode — the only place colours live, and the single place to re-skin:
61
+ * `--s-primary`, `--s-danger`, `--s-success`, `--s-warning` (accent fills, which
62
+ * double as semantic *ink* on neutral surfaces), `--s-neutral`, `--s-page`,
63
+ * `--s-panel`, `--s-raised` (neutral fills), `--s-ink` (text on neutral) and
64
+ * `--s-on-accent` (text on accent fills), plus `--s-link`/`--s-focus` and the
65
+ * shape tokens. Every surface rule is wired to these, so they adapt with the
66
+ * mode and with any override.
67
+ *
68
+ * # Variants
69
+ *
70
+ * Because the variant decides how `--s-a`/`--s-b` become `--s-fg`/`--s-bg`, the
71
+ * three looks are generic and work on *any* role: `.tonal` reads the fill colour
72
+ * as ink over a soft self-tint; `.outlined` reads it as ink over a transparent
73
+ * fill with a coloured edge (inheriting the parent's background, so its derived
74
+ * tokens read the real surroundings). Inside any of them `--s-fg`/`--s-bg` still
75
+ * describe the real, rendered colours.
76
+ *
77
+ * # Customising
78
+ *
79
+ * There's no JS theme object — the colours live in the palette `insertGlobalCss`
80
+ * call below, branched on {@link getDarkMode}. Re-skin from your app by
81
+ * overriding palette tokens (per mode if you like), or give a surface an
82
+ * image/gradient background (set `--s-b` to the dominant fallback colour so
83
+ * derived tokens stay sensible). Staffa uses global, `s-`-prefixed classes, so
84
+ * nothing is scoped away from you.
85
+ *
86
+ * ```ts
87
+ * A(() => A.insertGlobalCss({ ":root": getDarkMode() ? "--s-primary:#28c4a0" : "--s-primary:#1f9d6b" }));
88
+ * A.insertGlobalCss({ ".s-s.panel": "background: url(paper.png); --s-b: #efe9dd" });
89
+ * ```
95
90
  */
96
- export const lightTheme: Theme = A.proxy<Theme>({
97
- sBg: "#f3f4f8",
98
- sSurface: "#ffffff",
99
- sSurfaceHi: "#eceef4",
100
- sFg: "#1b1e27",
101
- sFgMuted: "#5b6273",
102
- sFgFaint: "#9aa1b2",
103
- sBorder: "#e2e5ee",
104
- sBorderStrong: "#c7ccda",
105
- sPrimary: "#6c5ce7",
106
- sPrimaryHover: "#5847d4",
107
- sPrimaryFg: "#ffffff",
108
- sDanger: "#e23b3b",
109
- sSuccess: "#1f9d6b",
110
- sWarning: "#d97706",
111
- sFocus: "rgba(108, 92, 231, 0.35)",
112
- sRadius: "10px",
113
- sRadiusLg: "16px",
114
- sShadow: "0 6px 24px rgba(20, 24, 40, 0.12)",
115
- });
91
+
116
92
 
117
93
  const STORAGE_KEY = "staffa:darkMode";
118
94
 
119
- /**
120
- * The explicit dark-mode choice — `true` (force dark), `false` (force light) or
121
- * `undefined` (follow the OS via {@link A.darkMode}). A reactive proxy, seeded
122
- * from localStorage so the persisted preference applies on the first paint.
123
- */
95
+ /** The explicit dark-mode choice; `undefined` follows the OS via {@link A.darkMode}. */
124
96
  const $override = A.proxy<{ value: boolean | undefined }>({ value: readStoredOverride() });
125
97
 
126
98
  /** Read the persisted dark-mode override from localStorage (defensively). */
@@ -137,8 +109,7 @@ function readStoredOverride(): boolean | undefined {
137
109
 
138
110
  /**
139
111
  * Force dark mode (`true`), light mode (`false`), or follow the OS preference
140
- * (`undefined`). Takes effect immediately and is persisted to localStorage, so
141
- * the choice survives reloads.
112
+ * (`undefined`). Takes effect immediately and is persisted to localStorage.
142
113
  */
143
114
  export function setDarkMode(value: boolean | undefined): void {
144
115
  $override.value = value;
@@ -154,42 +125,126 @@ export function setDarkMode(value: boolean | undefined): void {
154
125
  * Whether dark mode is currently active. Reactive — read it inside a scope to
155
126
  * re-run on changes.
156
127
  *
157
- * @param allowAuto - When `true`, returns `undefined` (rather than resolving to
158
- * a boolean) if the user is following the OS preference, so a dark/light/auto
159
- * control can tell the three states apart.
128
+ * @param allowAuto - When `true`, returns `undefined` (rather than a boolean) if
129
+ * the user is following the OS preference, so a dark/light/auto control can
130
+ * tell the three states apart.
160
131
  */
161
132
  export function getDarkMode(allowAuto = false): boolean | undefined {
162
133
  const v = $override.value;
163
134
  return v === undefined && !allowAuto ? A.darkMode() : v;
164
135
  }
165
136
 
166
- // Set up everything as this module loads. Spacing scale first ($1 = 0.25rem,
167
- // $2 = 0.5rem, $3 = 1rem, ...), then reactively merge the active theme into the
168
- // CSS variables. This scope runs synchronously now before the first paint —
169
- // so the correct colors are in place immediately (no flash), and A.merge
170
- // subscribes to the theme it reads, so toggling the mode or mutating
171
- // darkTheme / lightTheme re-applies automatically.
172
- A.setSpacingCssVars();
137
+ // ---------------------------------------------------------------------------
138
+ // Reactive palette the ONE place colours live, and the only thing that
139
+ // differs between light and dark. Everything else is wired to these named
140
+ // tokens, so an app can re-skin by overriding just a few of them. The accent
141
+ // names (--s-primary/-danger/-success/-warning) double as semantic *ink*
142
+ // colours, legible as text/borders on neutral surfaces.
143
+ // ---------------------------------------------------------------------------
173
144
  A(() => {
174
- A.merge(A.cssVars as Partial<Theme>, getDarkMode() ? darkTheme : lightTheme);
145
+ if (getDarkMode()) {
146
+ A.insertGlobalCss({
147
+ ":root":
148
+ "--s-primary:#8b7bff --s-danger:#ff6b6b --s-success:#46d39a --s-warning:#fbbf24 " +
149
+ "--s-neutral:#3c4352 --s-page:#0e1015 --s-panel:#181b22 --s-raised:#222632 " +
150
+ "--s-ink:#e8eaf0 --s-on-accent:#0c0a14 --s-focus:rgba(139,123,255,0.45) " +
151
+ "--s-radius:10px --s-radius-lg:16px --s-shadow: 0 8px 30px rgba(0,0,0,0.45);",
152
+ // Contextual link, restored across the neutral group (see static block).
153
+ ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#6db3ff",
154
+ });
155
+ } else {
156
+ A.insertGlobalCss({
157
+ ":root":
158
+ "--s-primary:#6c5ce7 --s-danger:#e23b3b --s-success:#1f9d6b --s-warning:#d97706 " +
159
+ "--s-neutral:#c7ccda --s-page:#f3f4f8 --s-panel:#ffffff --s-raised:#eceef4 " +
160
+ "--s-ink:#1b1e27 --s-on-accent:#ffffff --s-focus:rgba(108,92,231,0.35) " +
161
+ "--s-radius:10px --s-radius-lg:16px --s-shadow: 0 6px 24px rgba(20,24,40,0.12);",
162
+ ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-link:#2563eb",
163
+ });
164
+ }
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Static structure — inserted once, all wired to the palette above.
169
+ //
170
+ // A surface is marked with `.s-s`. Its level/role modifier sets two anchors
171
+ // (--s-a ink, --s-b fill); its variant modifier (`.filled` default, `.tonal`,
172
+ // `.outlined`) decides how those anchors map onto the rendered fg/bg. `:root`
173
+ // (the page) is an implicit filled surface. Every `.s-s` then runs the same
174
+ // derive+paint step, reading the resolved fg/bg, so each one gets its own
175
+ // legible secondary tokens regardless of variant.
176
+ //
177
+ // Rule order matters: the variant/role rules below all share specificity, so a
178
+ // later one wins. `.filled` therefore comes *last* among the variants — that's
179
+ // what lets a caller's `attrs: ".filled"` override a component's default
180
+ // `.tonal`/`.outlined`.
181
+ // ---------------------------------------------------------------------------
182
+
183
+ A.setSpacingCssVars();
184
+
185
+ A.insertGlobalCss({
186
+ // Level/role modifier → anchors. Levels (neutral elevations) use the shared
187
+ // ink; accent roles use the on-accent ink over their named fill.
188
+ ":root, .s-s.base": "--s-a:$s-ink --s-b:$s-page",
189
+ ".s-s.panel": "--s-a:$s-ink --s-b:$s-panel",
190
+ ".s-s.raised": "--s-a:$s-ink --s-b:$s-raised",
191
+ ".s-s.neutral": "--s-a:$s-ink --s-b:$s-neutral",
192
+ ".s-s.primary": "--s-a:$s-on-accent --s-b:$s-primary",
193
+ ".s-s.danger": "--s-a:$s-on-accent --s-b:$s-danger",
194
+ ".s-s.success": "--s-a:$s-on-accent --s-b:$s-success",
195
+ ".s-s.warning": "--s-a:$s-on-accent --s-b:$s-warning",
196
+
197
+ // Filled default (bare `.s-s` and `:root`): map the anchors to fg/bg, derive
198
+ // the secondary tokens from that pair, then paint. var() resolves at use time,
199
+ // so the tonal/outlined remaps below feed back into the derived tokens.
200
+ ":root, .s-s":
201
+ "--s-fg:$s-a --s-bg:$s-b " +
202
+ "--s-fg-muted: color-mix(in oklab, $s-fg, $s-bg 42%); " +
203
+ "--s-fg-faint: color-mix(in oklab, $s-fg, $s-bg 64%); " +
204
+ "--s-border: color-mix(in oklab, $s-fg, $s-bg 82%); " +
205
+ "--s-border-strong: color-mix(in oklab, $s-fg, $s-bg 68%); " +
206
+ "background:$s-bg color:$s-fg",
207
+ // Tonal: the fill colour becomes the ink, over a soft tint of itself.
208
+ ".s-s.tonal": "--s-fg:$s-b --s-bg: color-mix(in srgb, $s-b 16%, transparent);",
209
+ // Outlined: the fill colour is the ink; --s-bg *inherits* the parent's bg (the
210
+ // token the derivations read, so the edge mixes ink with the real surroundings)
211
+ // while the painted background is transparent, letting that parent fill — even
212
+ // a gradient or image — show through.
213
+ ".s-s.outlined": "--s-fg:$s-b --s-bg:inherit background:transparent --s-border: color-mix(in srgb, $s-fg 55%, $s-bg);",
214
+ // Filled, explicit — last among the variants so a caller's `attrs: ".filled"`
215
+ // overrides a component's default `.tonal`/`.outlined` (resetting both the
216
+ // anchors and the painted background).
217
+ ".s-s.filled": "--s-fg:$s-a --s-bg:$s-b background:$s-bg;",
218
+
219
+ // Contextual accent: the brand pop colour on neutral surfaces. Declared on the
220
+ // whole neutral group so re-entering a neutral surface under a coloured one
221
+ // restores it. (--s-link gets the same treatment in the reactive block, where
222
+ // its per-mode literal lives — it can't reference the palette, as the palette
223
+ // source and the contextual token share the name --s-link.)
224
+ ":root, .s-s.base, .s-s.panel, .s-s.raised, .s-s.neutral": "--s-accent:$s-primary",
225
+ // On a bright coloured surface those wouldn't be legible, so they fall back to
226
+ // the surface's own ink. (Keyed on the role modifier, so it holds across
227
+ // variants — and tracks the tonal/outlined fg remap.)
228
+ ".s-s.primary, .s-s.danger, .s-s.success, .s-s.warning": "--s-accent:$s-fg --s-link:$s-fg",
175
229
  });
176
230
 
177
- // A deliberately light reset. It sets box-sizing and sensible colors/fonts, but
178
- // does NOT strip margins from headings/paragraphs/lists, so rendered rich
179
- // content (e.g. markdown-to-HTML) keeps reasonable default rhythm.
231
+ // A deliberately light reset. Colours/shape come from the contextual tokens, so
232
+ // rich content (e.g. markdown-to-HTML) adapts to whatever surface holds it. It
233
+ // does NOT strip margins from headings/paragraphs/lists.
180
234
  A.insertGlobalCss({
181
235
  "*, *::before, *::after": "box-sizing:border-box",
182
236
  html: "text-size-adjust:100%",
183
- 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",
184
- a: "fg:$sPrimary text-decoration:underline text-underline-offset:2px",
185
- "a:hover": "fg:$sPrimaryHover",
237
+ body: "m:0 line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased",
238
+ a: "color: $s-link; text-decoration:underline text-underline-offset:2px",
239
+ "a:hover": "filter: brightness(1.15)",
186
240
  "input, button, textarea, select": "font:inherit color:inherit",
187
241
  "code, kbd, samp, pre": "font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;",
188
- code: "bg:$sSurfaceHi padding: 0.12em 0.34em; r:4px font-size:0.9em",
189
- pre: "bg:$sSurface p:$3 r:$sRadius overflow:auto",
190
- "pre code": "bg:transparent p:0",
242
+ code: "background: color-mix(in oklab, $s-fg, $s-bg 86%); padding: 0.12em 0.34em; r:4px font-size:0.9em",
243
+ pre: "background: color-mix(in oklab, $s-fg, $s-bg 92%); p:$3 r: $s-radius; overflow:auto",
244
+ "pre code": "background:transparent p:0",
191
245
  "img, svg, video, canvas": "max-width:100% h:auto",
192
- hr: "border:0 border-top: 1px solid $sBorder; margin: $3 0;",
193
- "::placeholder": "fg:$sFgFaint opacity:1",
194
- ":focus-visible": "outline: 2px solid $sFocus; outline-offset:2px",
246
+ hr: "border:0 border-top: 1px solid $s-border; margin: $3 0;",
247
+ "::placeholder": "color: $s-fg-faint; opacity:1",
248
+ ":focus-visible": "outline: 2px solid $s-focus; outline-offset:2px",
249
+ small: "color:$s-fg-muted font-size:0.9em",
195
250
  });