@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.
- package/README.md +180 -2
- package/dist/breakpoints.d.ts +15 -0
- package/dist/breakpoints.js +14 -0
- package/dist/components/atoms/Badge.svelte +1 -0
- package/dist/components/atoms/Button.svelte +16 -5
- package/dist/components/atoms/Button.svelte.d.ts +2 -0
- package/dist/components/atoms/Input.svelte +1 -0
- package/dist/components/atoms/Slider.svelte +1 -1
- package/dist/components/atoms/Text.svelte +4 -4
- package/dist/components/atoms/Text.svelte.d.ts +1 -1
- package/dist/components/layouts/AppShell.svelte +14 -0
- package/dist/components/layouts/Backdrop.svelte +152 -0
- package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
- package/dist/components/layouts/NavSection.svelte +1 -1
- package/dist/components/molecules/AttachmentList.svelte +1 -1
- package/dist/components/molecules/CapBar.svelte +1 -1
- package/dist/components/molecules/Composer.svelte +1 -1
- package/dist/components/molecules/Dropzone.svelte +1 -1
- package/dist/components/molecules/EmptyState.svelte +13 -6
- package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
- package/dist/components/molecules/Field.svelte +1 -1
- package/dist/components/molecules/FilterInput.svelte +5 -3
- package/dist/components/molecules/Meter.svelte +1 -1
- package/dist/components/molecules/Metric.svelte +3 -1
- package/dist/components/molecules/Modal.svelte +4 -4
- package/dist/components/molecules/Pagination.svelte +26 -2
- package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
- package/dist/components/molecules/Prose.svelte +1 -0
- package/dist/components/molecules/RadioGroup.svelte +2 -2
- package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
- package/dist/components/molecules/SectionHeader.svelte +1 -1
- package/dist/components/molecules/SegmentedControl.svelte +55 -22
- package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
- package/dist/components/molecules/SortableList.svelte +1 -1
- package/dist/components/molecules/StatusBar.svelte +2 -2
- package/dist/components/molecules/Tabs.svelte +32 -0
- package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
- package/dist/components/molecules/ThemePicker.svelte +3 -3
- package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
- package/dist/components/organisms/DataTable.svelte +2 -2
- package/dist/dev-audit.d.ts +19 -0
- package/dist/dev-audit.js +259 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/media.svelte.d.ts +8 -0
- package/dist/media.svelte.js +26 -0
- package/dist/pending.svelte.d.ts +16 -0
- package/dist/pending.svelte.js +75 -0
- package/dist/stores/toast.svelte.d.ts +22 -0
- package/dist/stores/toast.svelte.js +54 -2
- package/dist/styles/app.css +2 -1
- package/dist/styles/font.css +19 -0
- package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
- package/dist/styles/fonts/inter.woff2 +0 -0
- package/dist/styles/themes.css +58 -58
- package/dist/styles/tokens.css +15 -9
- package/dist/url-state.svelte.d.ts +22 -0
- package/dist/url-state.svelte.js +64 -0
- package/lint/breakpoints.mjs +33 -0
- package/lint/cli.mjs +99 -0
- package/lint/context.mjs +242 -0
- package/lint/index.mjs +97 -0
- package/lint/rules/breakpoints.mjs +25 -0
- package/lint/rules/img-alt.mjs +13 -0
- package/lint/rules/motion.mjs +85 -0
- package/lint/rules/no-raw-color.mjs +59 -0
- package/lint/rules/no-raw-control.mjs +23 -0
- package/lint/rules/no-raw-font.mjs +64 -0
- package/lint/rules/one-primary.mjs +74 -0
- package/lint/rules/spacing-scale.mjs +66 -0
- package/package.json +12 -2
- package/principles/01-visibility-of-status.md +32 -0
- package/principles/02-one-primary-action.md +31 -0
- package/principles/03-emphasise-by-de-emphasising.md +31 -0
- package/principles/04-labels-last-resort.md +30 -0
- package/principles/05-tint-the-greys.md +30 -0
- package/principles/06-depth-from-shade.md +30 -0
- package/principles/07-one-accent.md +31 -0
- package/principles/08-spacing-scale.md +30 -0
- package/principles/09-group-with-space.md +31 -0
- package/principles/10-real-typeface-fixed-scale.md +33 -0
- package/principles/11-mono-only-for-code.md +30 -0
- package/principles/12-measure.md +29 -0
- package/principles/13-imagery-sets-the-mood.md +32 -0
- package/principles/14-instant-calm-feedback.md +32 -0
- package/principles/15-undo-over-confirm.md +30 -0
- package/principles/16-empty-states.md +31 -0
- package/principles/17-errors-in-plain-words.md +31 -0
- package/principles/18-match-the-real-world.md +31 -0
- package/principles/19-consistency-and-standards.md +32 -0
- package/principles/20-error-prevention.md +31 -0
- package/principles/21-recognition-over-recall.md +31 -0
- package/principles/22-flexibility-and-shortcuts.md +31 -0
- package/principles/23-url-is-state.md +31 -0
- package/principles/24-links-are-links.md +31 -0
- package/principles/25-visible-focus.md +32 -0
- package/principles/26-generous-targets.md +31 -0
- package/principles/27-cheap-motion-respect-preferences.md +32 -0
- package/principles/28-less-but-better.md +31 -0
- package/principles/29-colour-is-not-the-only-cue.md +31 -0
- package/principles/30-help-where-needed.md +31 -0
- 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
|
|
68
|
-
|
|
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
|
+
}
|
|
@@ -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={
|
|
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
|
|
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:
|
|
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.
|
|
711
|
+
.btn.btn-busy {
|
|
701
712
|
cursor: wait;
|
|
702
713
|
}
|
|
703
|
-
.btn.
|
|
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;
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
wrap?: 'normal' | 'anywhere' | 'balance';
|
|
33
33
|
uppercase?: boolean;
|
|
34
34
|
leading?: 'tight' | 'normal' | 'none';
|
|
35
|
-
//
|
|
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-
|
|
114
|
+
color: var(--txt-fg, var(--text-muted));
|
|
115
115
|
}
|
|
116
116
|
.v-code {
|
|
117
117
|
font-family: var(--font-mono);
|
|
@@ -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;
|