@dorsk/tsumikit 0.81.0 → 0.82.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 (102) hide show
  1. package/README.md +180 -2
  2. package/dist/breakpoints.d.ts +15 -0
  3. package/dist/breakpoints.js +14 -0
  4. package/dist/components/atoms/Badge.svelte +1 -0
  5. package/dist/components/atoms/Button.svelte +16 -5
  6. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  7. package/dist/components/atoms/Input.svelte +1 -0
  8. package/dist/components/atoms/Slider.svelte +1 -1
  9. package/dist/components/atoms/Text.svelte +4 -4
  10. package/dist/components/atoms/Text.svelte.d.ts +1 -1
  11. package/dist/components/layouts/AppShell.svelte +14 -0
  12. package/dist/components/layouts/Backdrop.svelte +152 -0
  13. package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
  14. package/dist/components/layouts/NavSection.svelte +1 -1
  15. package/dist/components/molecules/AttachmentList.svelte +1 -1
  16. package/dist/components/molecules/CapBar.svelte +1 -1
  17. package/dist/components/molecules/Composer.svelte +1 -1
  18. package/dist/components/molecules/Dropzone.svelte +1 -1
  19. package/dist/components/molecules/EmptyState.svelte +13 -6
  20. package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
  21. package/dist/components/molecules/Field.svelte +1 -1
  22. package/dist/components/molecules/FilterInput.svelte +5 -3
  23. package/dist/components/molecules/Meter.svelte +1 -1
  24. package/dist/components/molecules/Metric.svelte +3 -1
  25. package/dist/components/molecules/Modal.svelte +4 -4
  26. package/dist/components/molecules/Pagination.svelte +26 -2
  27. package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
  28. package/dist/components/molecules/Prose.svelte +1 -0
  29. package/dist/components/molecules/RadioGroup.svelte +2 -2
  30. package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
  31. package/dist/components/molecules/SectionHeader.svelte +1 -1
  32. package/dist/components/molecules/SegmentedControl.svelte +55 -22
  33. package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
  34. package/dist/components/molecules/SortableList.svelte +1 -1
  35. package/dist/components/molecules/StatusBar.svelte +2 -2
  36. package/dist/components/molecules/Tabs.svelte +32 -0
  37. package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
  38. package/dist/components/molecules/ThemePicker.svelte +3 -3
  39. package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
  40. package/dist/components/organisms/DataTable.svelte +2 -2
  41. package/dist/dev-audit.d.ts +19 -0
  42. package/dist/dev-audit.js +259 -0
  43. package/dist/index.d.ts +6 -1
  44. package/dist/index.js +6 -1
  45. package/dist/media.svelte.d.ts +8 -0
  46. package/dist/media.svelte.js +26 -0
  47. package/dist/pending.svelte.d.ts +16 -0
  48. package/dist/pending.svelte.js +75 -0
  49. package/dist/stores/toast.svelte.d.ts +22 -0
  50. package/dist/stores/toast.svelte.js +54 -2
  51. package/dist/styles/app.css +2 -1
  52. package/dist/styles/font.css +19 -0
  53. package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
  54. package/dist/styles/fonts/inter.woff2 +0 -0
  55. package/dist/styles/themes.css +58 -58
  56. package/dist/styles/tokens.css +15 -9
  57. package/dist/url-state.svelte.d.ts +22 -0
  58. package/dist/url-state.svelte.js +64 -0
  59. package/lint/breakpoints.mjs +33 -0
  60. package/lint/cli.mjs +99 -0
  61. package/lint/context.mjs +242 -0
  62. package/lint/index.mjs +97 -0
  63. package/lint/rules/breakpoints.mjs +25 -0
  64. package/lint/rules/img-alt.mjs +13 -0
  65. package/lint/rules/motion.mjs +85 -0
  66. package/lint/rules/no-raw-color.mjs +59 -0
  67. package/lint/rules/no-raw-control.mjs +23 -0
  68. package/lint/rules/no-raw-font.mjs +64 -0
  69. package/lint/rules/one-primary.mjs +74 -0
  70. package/lint/rules/spacing-scale.mjs +66 -0
  71. package/package.json +12 -2
  72. package/principles/01-visibility-of-status.md +32 -0
  73. package/principles/02-one-primary-action.md +31 -0
  74. package/principles/03-emphasise-by-de-emphasising.md +31 -0
  75. package/principles/04-labels-last-resort.md +30 -0
  76. package/principles/05-tint-the-greys.md +30 -0
  77. package/principles/06-depth-from-shade.md +30 -0
  78. package/principles/07-one-accent.md +31 -0
  79. package/principles/08-spacing-scale.md +30 -0
  80. package/principles/09-group-with-space.md +31 -0
  81. package/principles/10-real-typeface-fixed-scale.md +33 -0
  82. package/principles/11-mono-only-for-code.md +30 -0
  83. package/principles/12-measure.md +29 -0
  84. package/principles/13-imagery-sets-the-mood.md +32 -0
  85. package/principles/14-instant-calm-feedback.md +32 -0
  86. package/principles/15-undo-over-confirm.md +30 -0
  87. package/principles/16-empty-states.md +31 -0
  88. package/principles/17-errors-in-plain-words.md +31 -0
  89. package/principles/18-match-the-real-world.md +31 -0
  90. package/principles/19-consistency-and-standards.md +32 -0
  91. package/principles/20-error-prevention.md +31 -0
  92. package/principles/21-recognition-over-recall.md +31 -0
  93. package/principles/22-flexibility-and-shortcuts.md +31 -0
  94. package/principles/23-url-is-state.md +31 -0
  95. package/principles/24-links-are-links.md +31 -0
  96. package/principles/25-visible-focus.md +32 -0
  97. package/principles/26-generous-targets.md +31 -0
  98. package/principles/27-cheap-motion-respect-preferences.md +32 -0
  99. package/principles/28-less-but-better.md +31 -0
  100. package/principles/29-colour-is-not-the-only-cue.md +31 -0
  101. package/principles/30-help-where-needed.md +31 -0
  102. package/principles/README.md +62 -0
package/README.md CHANGED
@@ -26,6 +26,16 @@ hard-coded values · override by specificity, never by forking · props (incl.
26
26
 
27
27
  **Live demo:** https://dorskfr.github.io/tsumikit
28
28
 
29
+ ### Principles
30
+
31
+ The design principles the kit serves, one markdown file each, live in
32
+ [`principles/`](principles/README.md) and ship in the package
33
+ (`node_modules/@dorsk/tsumikit/principles/`). Each gives the principle, its
34
+ source, do / don't, what tsumikit provides for it, and a check to run on a
35
+ screen. The index ends with a short checklist for designing a screen; point
36
+ design reviews and coding agents at it. The showcase renders them at
37
+ [`/principles`](https://dorskfr.github.io/tsumikit/principles).
38
+
29
39
  ## Develop
30
40
 
31
41
  ```bash
@@ -55,6 +65,18 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
55
65
  <Button variant="primary" onclick={save}>Save</Button>
56
66
  ```
57
67
 
68
+ ### Start a new project
69
+
70
+ Copy [`examples/starter`](examples/starter) and change `"@dorsk/tsumikit": "file:../.."` to a
71
+ version. It is the baseline every app should start from: the kit stylesheet (tokens, themes,
72
+ Inter), `AppShell`, a `ThemePicker`, a `Toaster`, an `+error.svelte` that says what happened in
73
+ plain words (404 included), a list page with URL-driven filters (`Tabs href` + `urlState`),
74
+ `LoadMore`, empty and filtered-empty states, delete with `toasts.undo()` instead of a confirm
75
+ dialog, and `tsumikit-lint` on every commit via lefthook.
76
+
77
+ Upgrading from 0.81 or older: [docs/MIGRATION-0.82.md](docs/MIGRATION-0.82.md) lists what looks
78
+ different and how to revert each change.
79
+
58
80
  ## Theming
59
81
 
60
82
  - 31 themes ship (light, highcontrast, gruvboxlight, solarizedlight,
@@ -64,8 +86,15 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
64
86
  **dim** tier between the bright and dark extremes: stone, fog, sage (light)
65
87
  and slate, moss, espresso, zenburn (dark).
66
88
  - Every built-in clears WCAG AA on `bg`, `bg-elev` and `surface`: text 7:1,
67
- muted 5.5:1, faint and every accent/status hue 4.5:1, `accent-ink` on
68
- `accent` 4.5:1, and `--c-border-strong` (the outline of every control) 3:1.
89
+ muted 4.5:1, every accent/status hue 4.5:1, `accent-ink` on `accent` 4.5:1,
90
+ and `--c-border-strong` (the outline of every control) 3:1.
91
+ - Three text levels: `--text` for values and titles, `--text-muted` for labels
92
+ and secondary text (4.5:1), `--text-faint` only for placeholders, disabled
93
+ and decorative text (3:1, and muted is at least 1.35x faint so the step is
94
+ visible).
95
+ - The accent is never a status colour: every theme keeps `--c-accent` at least
96
+ OKLab deltaE 0.08 away from `--c-green`, `--c-amber` and `--c-red`, so the
97
+ primary action never reads as "success".
69
98
  `tests/theme-contrast.mjs` holds the floors; the test suite fails on any
70
99
  breach.
71
100
  - `<ThemePicker />` and `<FontScalePicker />` wire the stores to the UI. Theme
@@ -84,6 +113,17 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
84
113
  older version still loads — it pins its own slot — but a downgrade will not
85
114
  read the new blob and falls back to the default theme.
86
115
 
116
+ ### Typeface
117
+
118
+ `styles/app.css` bundles [Inter](https://rsms.me/inter/) (variable, latin subset, OFL-1.1, licence in
119
+ `styles/fonts/`) through `styles/font.css` and lists it first in `--font-sans`. Counts, stat values,
120
+ eyebrow labels and shortcut hints (`--font-numeric`, `--font-label`) are sans by default; only code
121
+ (`CodeBlock`, `Kbd`, `GitRef`, `Text variant="code"`) stays monospace.
122
+
123
+ To opt out, import `tokens.css` / `themes.css` without `font.css`, or override `--font-sans`.
124
+ Reading text is capped at `--measure` (68ch): `Prose` always, `Text` with `measure`; set
125
+ `--measure: none` on a container to lift it.
126
+
87
127
  ### Stylesheets
88
128
 
89
129
  `@dorsk/tsumikit/styles/app.css` is a shell over five files you can import
@@ -837,6 +877,144 @@ step; override any string through `messages` (defaults in
837
877
  </SortableList>
838
878
  ```
839
879
 
880
+ ## Patterns
881
+
882
+ ### Calm loading
883
+
884
+ Every busy indicator in the kit waits 200 ms before appearing and then stays at least 500 ms, so a 60 ms save never flashes a spinner and a slow one never flickers. `Button loading` and `EmptyState loading` do this internally; `aria-busy` and the click block still follow `loading` at once, only the visual is calmed. Tune or disable per instance with `loadingDelay` / `loadingMin` (`0` = immediate). For your own indicators:
885
+
886
+ ```svelte
887
+ <script>
888
+ import { pending } from '@dorsk/tsumikit';
889
+ let saving = $state(false);
890
+ const p = pending(() => saving, { delay: 200, min: 500 });
891
+ </script>
892
+ {#if p.visible}<Spinner />{/if}
893
+ ```
894
+
895
+ ### Undo over confirm
896
+
897
+ Prefer an optimistic change with an Undo toast to a confirmation dialog when success is likely. `toasts.undo` applies at once, offers Undo while the toast is visible, commits when it goes away (timeout, click, or `pagehide`), and reverts with a plain-words error toast if the commit fails:
898
+
899
+ ```ts
900
+ toasts.undo({
901
+ message: 'Message deleted',
902
+ apply: () => (items = items.filter((m) => m.id !== id)),
903
+ revert: () => (items = [...items, removed]),
904
+ commit: () => api.delete(id),
905
+ });
906
+ ```
907
+
908
+ Keep `ConfirmModal` for irreversible or bulk actions (purging data, deleting many rows, anything that cannot be reverted locally).
909
+
910
+ ### URL state
911
+
912
+ Put tab, filter and page state in the URL so it survives a refresh and can be shared. `urlState` is a framework-neutral rune for one search param; Tabs, SegmentedControl and Pagination accept `href` and then render real links (middle-click opens in a new tab) with `aria-current`.
913
+
914
+ ```svelte
915
+ <script lang="ts">
916
+ import { Tabs, Pagination, urlState } from '@dorsk/tsumikit';
917
+ const tab = urlState('tab', { default: 'overview' });
918
+ const page = urlState('page', { default: 1, parse: Number, serialize: String, push: true });
919
+ </script>
920
+
921
+ <Tabs {tabs} bind:value={tab.value} href={(id) => tab.href(id)} />
922
+ <Pagination bind:page={page.value} pageCount={12} href={(p) => page.href(p)} />
923
+ ```
924
+
925
+ `urlState(key, { default, parse?, serialize?, push? })` reads the param (or `default`), writes with `history.replaceState` (`pushState` when `push`), follows back/forward, and holds `default` without a `window` (SSR-safe). A value that serializes like the default removes the param. `href(value)` builds the link for the current page with that value. Per-item `href` on `TabItem`/`SegmentOption` overrides the function. `isModifiedClick(e)` is exported for custom links that must leave ctrl/meta/shift/middle clicks to the browser.
926
+
927
+ ### Backdrop
928
+
929
+ `Backdrop` is the page-level hero: a full-bleed `Artwork` (so a missing or failed
930
+ `src` shows the seeded placeholder and the picture fades in lazily) that melts into
931
+ `--bg` along its bottom edge, with `children` sitting on a scrim mixed from `--bg`
932
+ so titles and actions read over any picture. `height` takes any CSS length
933
+ (default `min(45svh, 22rem)`), `fade="none"` keeps a hard edge, `blur` softens the
934
+ picture, `dim` tints it toward `--bg`. `seed`, `fallback`, `icon`, `fit`, `srcset`,
935
+ `sizes`, `onerror` are passed straight to Artwork; everything else (incl. `aria-*`)
936
+ spreads onto the root. Under `prefers-reduced-transparency` the scrim becomes
937
+ opaque and the blur is dropped; `prefers-contrast: more` also uses the opaque scrim.
938
+
939
+ ```svelte
940
+ <Backdrop as="header" src={album.cover} alt="" seed={album.title} height="18rem">
941
+ <Heading level={1}>{album.title}</Heading>
942
+ <Text tone="muted">{album.artist}</Text>
943
+ <Button variant="primary">Play</Button>
944
+ </Backdrop>
945
+ ```
946
+
947
+ ### Breakpoints
948
+
949
+ One list for every media query: `sm` 40rem (640px), `md` 48rem (768px), `lg`
950
+ 64rem (1024px); max-width queries use the complement (`39.999rem`,
951
+ `47.999rem`, `63.999rem`). CSS custom properties cannot drive `@media`, so the
952
+ list is exported for JS (`breakpoints`, `minWidth(bp)`, `maxWidth(bp)`) with a
953
+ reactive, SSR-safe `mq.sm` / `mq.md` / `mq.lg` and `media(query)`.
954
+ `tsumikit-lint` enforces the list in CSS. Inside a pane, prefer container
955
+ queries (below).
956
+
957
+ ## Guardrails
958
+
959
+ ### Design lint (`tsumikit-lint`)
960
+
961
+ The package ships a dependency-free CLI that flags drift from the kit's rules in your `.svelte` and `.css` files (it only needs node and the `svelte` peer).
962
+
963
+ ```sh
964
+ npx tsumikit-lint # lints ./src
965
+ npx tsumikit-lint src lib/styles/app.css
966
+ npx tsumikit-lint --json src # CI-friendly
967
+ npx tsumikit-lint --rule spacing-scale,breakpoints src
968
+ npx tsumikit-lint --disable no-raw-control src
969
+ ```
970
+
971
+ Output is `file:line:col rule message` plus a per-rule summary; exit code is 1 when anything is reported, 2 on usage errors.
972
+
973
+ | Rule | Flags |
974
+ | --- | --- |
975
+ | `no-raw-color` | hex / rgb / hsl literals outside `:root` / `[data-theme]` blocks (custom-property definitions and `var()`-composed colors are fine) |
976
+ | `spacing-scale` | px / rem in padding, margin, gap, inset and top/right/bottom/left that is not a `--sp-*` token; `0` and `1px` hairlines allowed; suggests the token |
977
+ | `no-raw-control` | raw `<button>`, `<input>`, `<select>`, `<textarea>` instead of the atoms |
978
+ | `one-primary` | more than one `variant="primary"` that can render at the same time (if/else branches are exclusive, snippets are separate) |
979
+ | `no-raw-font` | `font-family` other than `var(--font-*)`; `--font-mono` outside code-ish elements (use `--font-numeric` / `--font-label`) |
980
+ | `breakpoints` | media-query widths other than 40rem/640px, 48rem/768px, 64rem/1024px (and their max-width complements like 39.999rem / 639.98px) |
981
+ | `motion` | `transition` / `@keyframes` on properties other than transform, opacity, color, background-color, box-shadow, filter (and other paint-only properties); `transition: all` |
982
+ | `img-alt` | `<img>` without `alt` |
983
+
984
+ Suppress a single line with a comment on the line above (or at the end of the line):
985
+
986
+ ```css
987
+ /* tsumikit-lint-ignore spacing-scale */
988
+ padding: 3px;
989
+ ```
990
+ ```svelte
991
+ <!-- tsumikit-lint-ignore no-raw-control -->
992
+ <button type="button" class="chip">…</button>
993
+ ```
994
+
995
+ Omit the rule id to ignore every rule on that line. In app code, read the breakpoint list from `breakpoints` in the main entry.
996
+
997
+ lefthook:
998
+
999
+ ```yaml
1000
+ pre-commit:
1001
+ commands:
1002
+ tsumikit-lint:
1003
+ glob: '*.{svelte,css}'
1004
+ run: npx tsumikit-lint {staged_files}
1005
+ ```
1006
+
1007
+ ### Dev-mode design warnings
1008
+
1009
+ In development, `AppShell` starts a DOM audit (`src/lib/dev-audit.ts`) that console.warns once per issue, prefixed `[tsumikit]`, with the offending element:
1010
+
1011
+ - more than one visible primary `Button` in the same `main` or dialog (modal and page are counted separately);
1012
+ - `Button`/`IconButton` without an accessible name;
1013
+ - on coarse pointers, interactive elements whose hit box is under `--touch-target` (kit controls are accepted at `--touch-target-compact`; controls carrying the kit's touch slab are not flagged; opt out per element with `.hit-compact`);
1014
+ - text over an `Artwork`/`img` without a `Scrim` (or any opaque background).
1015
+
1016
+ Without `AppShell`: `import { startDesignAudit } from '@dorsk/tsumikit/dev-audit'; startDesignAudit();`. The audit is guarded by `import.meta.env.DEV`, so production bundles contain none of it.
1017
+
840
1018
  ## Container queries
841
1019
 
842
1020
  AppShell's `main` and `sidebar` are query containers (`container-name: main` /
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The kit's breakpoint contract, in rem so zoom and large-text settings move the
3
+ * switch points with the type. Must match lint/breakpoints.mjs and every
4
+ * width `@media` in src/lib.
5
+ */
6
+ export declare const breakpoints: {
7
+ readonly sm: 40;
8
+ readonly md: 48;
9
+ readonly lg: 64;
10
+ };
11
+ export type Breakpoint = keyof typeof breakpoints;
12
+ export declare const breakpointsPx: Record<Breakpoint, number>;
13
+ export declare function minWidth(bp: Breakpoint): string;
14
+ /** 0.001rem below `minWidth` so the two never both match at the edge. */
15
+ export declare function maxWidth(bp: Breakpoint): string;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The kit's breakpoint contract, in rem so zoom and large-text settings move the
3
+ * switch points with the type. Must match lint/breakpoints.mjs and every
4
+ * width `@media` in src/lib.
5
+ */
6
+ export const breakpoints = { sm: 40, md: 48, lg: 64 };
7
+ export const breakpointsPx = { sm: 640, md: 768, lg: 1024 };
8
+ export function minWidth(bp) {
9
+ return `(min-width: ${breakpoints[bp]}rem)`;
10
+ }
11
+ /** 0.001rem below `minWidth` so the two never both match at the edge. */
12
+ export function maxWidth(bp) {
13
+ return `(max-width: ${(breakpoints[bp] - 0.001).toFixed(3)}rem)`;
14
+ }
@@ -231,6 +231,7 @@
231
231
  }
232
232
  .mono {
233
233
  font-family: var(--font-numeric);
234
+ font-variant-numeric: tabular-nums;
234
235
  font-weight: var(--badge-fw, var(--fw-normal));
235
236
  }
236
237
  .uppercase {
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import Icon, { isIconName } from './Icon.svelte';
3
3
  import { formatCount, hasCount } from '../../count';
4
+ import { pending } from '../../pending.svelte';
4
5
  import { playSound } from '../../sound';
5
6
  import type { ControlSize } from '../../size';
6
7
  import { canonicalTone, type Tone } from '../../tone';
@@ -65,6 +66,11 @@
65
66
  // morphing into a centred spinner at constant width, or a progress fill
66
67
  // pouring in from the left (driven by `progress`).
67
68
  loadingStyle?: 'spinner' | 'morph' | 'fill';
69
+ // Calm loading: the visual waits `loadingDelay` ms and then stays at least
70
+ // `loadingMin` ms; aria-busy and the click block follow `loading` at once.
71
+ // 0 for both shows and hides with `loading`.
72
+ loadingDelay?: number;
73
+ loadingMin?: number;
68
74
  // 0–1 for `loadingStyle="fill"`; omit for an indeterminate idle fill.
69
75
  progress?: number;
70
76
  // Text beside the check in the `'done'` state; defaults to the children.
@@ -113,6 +119,8 @@
113
119
  hitArea = 'auto',
114
120
  loading = false,
115
121
  loadingStyle = 'spinner',
122
+ loadingDelay,
123
+ loadingMin,
116
124
  progress,
117
125
  doneLabel,
118
126
  count,
@@ -136,6 +144,8 @@
136
144
  // so an inactive link drops `href` (no focus, no keyboard activation).
137
145
  const tag = $derived(href !== undefined || as === 'a' ? 'a' : 'button');
138
146
  const busy = $derived(loading === true);
147
+ const spin = pending(() => busy, () => ({ delay: loadingDelay, min: loadingMin }));
148
+ const spinning = $derived(spin.visible);
139
149
  const done = $derived(loading === 'done');
140
150
  const layered = $derived(loadingStyle !== 'spinner');
141
151
  const inactive = $derived(disabled || busy);
@@ -224,7 +234,8 @@
224
234
  class:btn-tone-danger={t === 'danger'}
225
235
  class:btn-icon-inline={iconInline}
226
236
  class:hover-danger={hoverDanger}
227
- class:loading={busy}
237
+ class:loading={spinning}
238
+ class:btn-busy={busy}
228
239
  class:btn-done={done}
229
240
  class:btn-morph={loadingStyle === 'morph'}
230
241
  class:btn-fill={loadingStyle === 'fill'}
@@ -234,7 +245,7 @@
234
245
  onclick={handleClick}
235
246
  >
236
247
  {#if loadingStyle === 'fill'}<span class="btn-fill-bar" aria-hidden="true"></span>{/if}
237
- {#if busy && !layered}<span class="btn-spinner" aria-hidden="true"></span>{:else if done && !layered}<span class="btn-check" aria-hidden="true"><Icon name="check" /></span>{/if}
248
+ {#if spinning && !layered}<span class="btn-spinner" aria-hidden="true"></span>{:else if done && !layered}<span class="btn-check" aria-hidden="true"><Icon name="check" /></span>{/if}
238
249
  {#if glyph !== undefined}
239
250
  <span class="btn-glyph" aria-hidden="true">
240
251
  {#if typeof glyph !== 'string'}{@render glyph()}{:else if isIconName(glyph)}<Icon name={glyph} />{:else}{glyph}{/if}
@@ -542,7 +553,7 @@
542
553
 
543
554
  /* Label collapse: `[data-label]` children disappear below the threshold and
544
555
  the button falls back to icon-only padding. */
545
- @media (max-width: 40rem) {
556
+ @media (max-width: 39.999rem) {
546
557
  .btn-collapse-mobile :global([data-label]) {
547
558
  display: none;
548
559
  }
@@ -697,10 +708,10 @@
697
708
 
698
709
  /* Loading: keep full opacity (it's busy, not disabled-looking) + a wait cursor,
699
710
  and show a spinner in the current text color. */
700
- .btn.loading {
711
+ .btn.btn-busy {
701
712
  cursor: wait;
702
713
  }
703
- .btn.loading:disabled {
714
+ .btn.btn-busy:disabled {
704
715
  opacity: 1;
705
716
  }
706
717
  .btn-spinner {
@@ -25,6 +25,8 @@ type ButtonProps = HTMLButtonAttributes & {
25
25
  hitArea?: 'auto' | 'compact';
26
26
  loading?: boolean | 'done';
27
27
  loadingStyle?: 'spinner' | 'morph' | 'fill';
28
+ loadingDelay?: number;
29
+ loadingMin?: number;
28
30
  progress?: number;
29
31
  doneLabel?: string;
30
32
  count?: number;
@@ -260,6 +260,7 @@
260
260
  }
261
261
  .mono {
262
262
  font-family: var(--font-numeric);
263
+ font-variant-numeric: tabular-nums;
263
264
  }
264
265
 
265
266
  .input-wrap {
@@ -158,7 +158,7 @@
158
158
  flex: 1 0 100%;
159
159
  height: 1.2em;
160
160
  font-size: var(--fs-xs);
161
- color: var(--text-faint);
161
+ color: var(--text-muted);
162
162
  }
163
163
  .mark {
164
164
  position: absolute;
@@ -32,8 +32,8 @@
32
32
  wrap?: 'normal' | 'anywhere' | 'balance';
33
33
  uppercase?: boolean;
34
34
  leading?: 'tight' | 'normal' | 'none';
35
- // CSS max-width for the line measure, e.g. "60ch".
36
- measure?: string;
35
+ // Line measure: `true` caps at --measure (68ch), a string sets a CSS max-width ("60ch").
36
+ measure?: string | boolean;
37
37
  grow?: boolean;
38
38
  // false pins the size to its unscaled px value, ignoring --fs-scale.
39
39
  scale?: boolean;
@@ -67,7 +67,7 @@
67
67
  const toneClass = $derived(
68
68
  tone === 'neutral' ? 'default' : canonicalTone(tone) === 'ok' ? 'success' : canonicalTone(tone)
69
69
  );
70
- const styleAttr = $derived(measure ? `max-width: ${measure}` : undefined);
70
+ const styleAttr = $derived(measure ? `max-width: ${measure === true ? 'var(--measure)' : measure}` : undefined);
71
71
  </script>
72
72
 
73
73
  <svelte:element
@@ -111,7 +111,7 @@
111
111
  }
112
112
  .v-caption {
113
113
  font-size: var(--txt-size, var(--fs-xs));
114
- color: var(--txt-fg, var(--text-faint));
114
+ color: var(--txt-fg, var(--text-muted));
115
115
  }
116
116
  .v-code {
117
117
  font-family: var(--font-mono);
@@ -17,7 +17,7 @@ type Own = {
17
17
  wrap?: 'normal' | 'anywhere' | 'balance';
18
18
  uppercase?: boolean;
19
19
  leading?: 'tight' | 'normal' | 'none';
20
- measure?: string;
20
+ measure?: string | boolean;
21
21
  grow?: boolean;
22
22
  scale?: boolean;
23
23
  block?: boolean;
@@ -140,6 +140,20 @@
140
140
  return () => el.removeEventListener('click', onSidebarClick);
141
141
  });
142
142
 
143
+ if (import.meta.env.DEV && browser) {
144
+ $effect(() => {
145
+ let stop: (() => void) | undefined;
146
+ let gone = false;
147
+ import('../../dev-audit').then((m) => {
148
+ if (!gone) stop = m.startDesignAudit();
149
+ });
150
+ return () => {
151
+ gone = true;
152
+ stop?.();
153
+ };
154
+ });
155
+ }
156
+
143
157
  // Lock body scroll while the mobile drawer is open.
144
158
  $effect(() => {
145
159
  if (!browser) return;
@@ -0,0 +1,152 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ // Full-bleed hero image (an Artwork, so the seeded placeholder and lazy fade-in
4
+ // come for free) that fades into --bg at the bottom. Children sit on a scrim
5
+ // mixed from --bg so text stays readable over any picture.
6
+ import type { Snippet } from 'svelte';
7
+ import Artwork from '../atoms/Artwork.svelte';
8
+ import type { IconName } from '../atoms/Icon.svelte';
9
+
10
+ type Own = {
11
+ as?: 'div' | 'header' | 'section';
12
+ src?: string | null;
13
+ srcset?: string;
14
+ sizes?: string;
15
+ alt: string;
16
+ /** Drives the placeholder gradient hue and initials. Defaults to `alt`. */
17
+ seed?: string;
18
+ /** Any CSS length. Defaults to a viewport-relative height capped by --backdrop-h. */
19
+ height?: string;
20
+ /** Gradient from the image into --bg along the bottom edge. */
21
+ fade?: 'bottom' | 'none';
22
+ /** Soften the picture behind the content. */
23
+ blur?: boolean;
24
+ /** Darken (tint toward --bg) the whole picture, not just the scrim. */
25
+ dim?: boolean;
26
+ fit?: 'cover' | 'contain';
27
+ fallback?: 'initials' | 'icon' | 'none';
28
+ icon?: IconName;
29
+ onerror?: () => void;
30
+ class?: string;
31
+ children?: Snippet;
32
+ };
33
+ let {
34
+ as = 'div',
35
+ src,
36
+ srcset,
37
+ sizes,
38
+ alt,
39
+ seed,
40
+ height,
41
+ fade = 'bottom',
42
+ blur = false,
43
+ dim = false,
44
+ fit = 'cover',
45
+ fallback = 'initials',
46
+ icon = 'image',
47
+ onerror,
48
+ class: klass = '',
49
+ children,
50
+ ...rest
51
+ }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
52
+ </script>
53
+
54
+ <svelte:element
55
+ this={as}
56
+ data-tsu="Backdrop"
57
+ class="backdrop fade-{fade} {klass}"
58
+ class:blur
59
+ class:dim
60
+ style:--backdrop-h={height}
61
+ {...rest}
62
+ >
63
+ <div class="media">
64
+ <Artwork {src} {srcset} {sizes} {alt} {seed} {fit} {fallback} {icon} {onerror} aspect="auto" radius="sm" />
65
+ </div>
66
+ {#if children}
67
+ <div class="content">{@render children()}</div>
68
+ {/if}
69
+ </svelte:element>
70
+
71
+ <style>
72
+ .backdrop {
73
+ --backdrop-scrim: 86%;
74
+ --backdrop-fade: 60%;
75
+ --backdrop-blur: 12px;
76
+ --backdrop-dim: 35%;
77
+ position: relative;
78
+ display: flex;
79
+ flex-direction: column;
80
+ justify-content: flex-end;
81
+ min-height: var(--backdrop-h, min(45svh, 22rem));
82
+ overflow: hidden;
83
+ background: var(--bg);
84
+ color: var(--text);
85
+ }
86
+ .media {
87
+ position: absolute;
88
+ inset: 0;
89
+ }
90
+ .media > :global([data-tsu='Artwork']) {
91
+ position: absolute;
92
+ inset: 0;
93
+ width: 100%;
94
+ height: 100%;
95
+ border: 0;
96
+ border-radius: 0;
97
+ }
98
+ .blur .media > :global([data-tsu='Artwork']) {
99
+ filter: blur(var(--backdrop-blur));
100
+ scale: 1.08;
101
+ }
102
+ .media::after {
103
+ content: '';
104
+ position: absolute;
105
+ inset: 0;
106
+ pointer-events: none;
107
+ background-image: linear-gradient(
108
+ to bottom,
109
+ transparent calc(100% - var(--backdrop-fade)),
110
+ var(--bg) 100%
111
+ );
112
+ }
113
+ .fade-none .media::after {
114
+ background-image: none;
115
+ }
116
+ .dim .media::after {
117
+ background-color: color-mix(in srgb, var(--bg) var(--backdrop-dim), transparent);
118
+ }
119
+ .content {
120
+ position: relative;
121
+ padding: var(--sp-6) max(var(--sp-4), var(--safe-left)) var(--sp-4) max(var(--sp-4), var(--safe-right));
122
+ background: color-mix(in srgb, var(--bg) var(--backdrop-scrim), transparent);
123
+ }
124
+ /* Feather the scrim upward so it never shows a hard edge over the picture. */
125
+ .content::before {
126
+ content: '';
127
+ position: absolute;
128
+ inset: auto 0 100% 0;
129
+ height: var(--sp-12);
130
+ pointer-events: none;
131
+ background: linear-gradient(
132
+ to top,
133
+ color-mix(in srgb, var(--bg) var(--backdrop-scrim), transparent),
134
+ transparent
135
+ );
136
+ }
137
+ @media (prefers-reduced-transparency: reduce) {
138
+ .backdrop {
139
+ --backdrop-scrim: 100%;
140
+ }
141
+ .blur .media > :global([data-tsu='Artwork']) {
142
+ filter: none;
143
+ scale: 1;
144
+ }
145
+ }
146
+ @media (prefers-contrast: more) {
147
+ .backdrop {
148
+ --backdrop-scrim: 100%;
149
+ --backdrop-dim: 60%;
150
+ }
151
+ }
152
+ </style>
@@ -0,0 +1,30 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { Snippet } from 'svelte';
3
+ import type { IconName } from '../atoms/Icon.svelte';
4
+ type Own = {
5
+ as?: 'div' | 'header' | 'section';
6
+ src?: string | null;
7
+ srcset?: string;
8
+ sizes?: string;
9
+ alt: string;
10
+ /** Drives the placeholder gradient hue and initials. Defaults to `alt`. */
11
+ seed?: string;
12
+ /** Any CSS length. Defaults to a viewport-relative height capped by --backdrop-h. */
13
+ height?: string;
14
+ /** Gradient from the image into --bg along the bottom edge. */
15
+ fade?: 'bottom' | 'none';
16
+ /** Soften the picture behind the content. */
17
+ blur?: boolean;
18
+ /** Darken (tint toward --bg) the whole picture, not just the scrim. */
19
+ dim?: boolean;
20
+ fit?: 'cover' | 'contain';
21
+ fallback?: 'initials' | 'icon' | 'none';
22
+ icon?: IconName;
23
+ onerror?: () => void;
24
+ class?: string;
25
+ children?: Snippet;
26
+ };
27
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
28
+ declare const Backdrop: import("svelte").Component<$$ComponentProps, {}, "">;
29
+ type Backdrop = ReturnType<typeof Backdrop>;
30
+ export default Backdrop;
@@ -52,7 +52,7 @@
52
52
  .nav-section-label {
53
53
  padding: 0 var(--sp-3);
54
54
  margin-bottom: var(--sp-1);
55
- color: var(--text-faint);
55
+ color: var(--text-muted);
56
56
  font-family: var(--font-label);
57
57
  font-size: var(--fs-xs);
58
58
  font-weight: var(--fw-semibold);
@@ -202,7 +202,7 @@
202
202
  }
203
203
  .size {
204
204
  margin-inline-start: var(--sp-1);
205
- color: var(--text-faint);
205
+ color: var(--text-muted);
206
206
  }
207
207
  li {
208
208
  display: flex;
@@ -406,7 +406,7 @@
406
406
  .caption {
407
407
  grid-column: 2 / 3;
408
408
  font-size: var(--fs-xs);
409
- color: var(--text-faint);
409
+ color: var(--text-muted);
410
410
  }
411
411
  .cap-bar:not(:has(.label)) .caption {
412
412
  grid-column: 1 / 3;
@@ -253,6 +253,6 @@
253
253
  }
254
254
  .hint {
255
255
  font-size: var(--fs-xs);
256
- color: var(--text-faint);
256
+ color: var(--text-muted);
257
257
  }
258
258
  </style>
@@ -257,6 +257,6 @@
257
257
  }
258
258
  .dz-hint {
259
259
  font-size: var(--fs-xs);
260
- color: var(--text-faint);
260
+ color: var(--text-muted);
261
261
  }
262
262
  </style>