@dorsk/tsumikit 0.81.0 → 0.82.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 (104) 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 +22 -3
  12. package/dist/components/layouts/AppShell.svelte.d.ts +3 -2
  13. package/dist/components/layouts/Backdrop.svelte +152 -0
  14. package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
  15. package/dist/components/layouts/NavSection.svelte +1 -1
  16. package/dist/components/molecules/AttachmentList.svelte +1 -1
  17. package/dist/components/molecules/CapBar.svelte +1 -1
  18. package/dist/components/molecules/Composer.svelte +1 -1
  19. package/dist/components/molecules/Dropzone.svelte +1 -1
  20. package/dist/components/molecules/EmptyState.svelte +13 -6
  21. package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
  22. package/dist/components/molecules/Field.svelte +1 -1
  23. package/dist/components/molecules/FilterInput.svelte +5 -3
  24. package/dist/components/molecules/Meter.svelte +1 -1
  25. package/dist/components/molecules/Metric.svelte +3 -1
  26. package/dist/components/molecules/Modal.svelte +8 -5
  27. package/dist/components/molecules/Pagination.svelte +26 -2
  28. package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
  29. package/dist/components/molecules/Prose.svelte +1 -0
  30. package/dist/components/molecules/RadioGroup.svelte +2 -2
  31. package/dist/components/molecules/RichSelect.svelte +8 -1
  32. package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
  33. package/dist/components/molecules/SectionHeader.svelte +1 -1
  34. package/dist/components/molecules/SegmentedControl.svelte +55 -22
  35. package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
  36. package/dist/components/molecules/SortableList.svelte +1 -1
  37. package/dist/components/molecules/StatusBar.svelte +2 -2
  38. package/dist/components/molecules/Tabs.svelte +32 -0
  39. package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
  40. package/dist/components/molecules/ThemePicker.svelte +3 -3
  41. package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
  42. package/dist/components/organisms/DataTable.svelte +2 -2
  43. package/dist/dev-audit.d.ts +19 -0
  44. package/dist/dev-audit.js +259 -0
  45. package/dist/index.d.ts +6 -1
  46. package/dist/index.js +6 -1
  47. package/dist/media.svelte.d.ts +8 -0
  48. package/dist/media.svelte.js +26 -0
  49. package/dist/pending.svelte.d.ts +16 -0
  50. package/dist/pending.svelte.js +75 -0
  51. package/dist/stores/toast.svelte.d.ts +22 -0
  52. package/dist/stores/toast.svelte.js +54 -2
  53. package/dist/styles/app.css +2 -1
  54. package/dist/styles/font.css +19 -0
  55. package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
  56. package/dist/styles/fonts/inter.woff2 +0 -0
  57. package/dist/styles/themes.css +58 -58
  58. package/dist/styles/tokens.css +15 -9
  59. package/dist/url-state.svelte.d.ts +22 -0
  60. package/dist/url-state.svelte.js +64 -0
  61. package/lint/breakpoints.mjs +33 -0
  62. package/lint/cli.mjs +99 -0
  63. package/lint/context.mjs +242 -0
  64. package/lint/index.mjs +97 -0
  65. package/lint/rules/breakpoints.mjs +25 -0
  66. package/lint/rules/img-alt.mjs +13 -0
  67. package/lint/rules/motion.mjs +85 -0
  68. package/lint/rules/no-raw-color.mjs +59 -0
  69. package/lint/rules/no-raw-control.mjs +23 -0
  70. package/lint/rules/no-raw-font.mjs +64 -0
  71. package/lint/rules/one-primary.mjs +74 -0
  72. package/lint/rules/spacing-scale.mjs +66 -0
  73. package/package.json +14 -3
  74. package/principles/01-visibility-of-status.md +32 -0
  75. package/principles/02-one-primary-action.md +31 -0
  76. package/principles/03-emphasise-by-de-emphasising.md +31 -0
  77. package/principles/04-labels-last-resort.md +30 -0
  78. package/principles/05-tint-the-greys.md +30 -0
  79. package/principles/06-depth-from-shade.md +30 -0
  80. package/principles/07-one-accent.md +31 -0
  81. package/principles/08-spacing-scale.md +30 -0
  82. package/principles/09-group-with-space.md +31 -0
  83. package/principles/10-real-typeface-fixed-scale.md +33 -0
  84. package/principles/11-mono-only-for-code.md +30 -0
  85. package/principles/12-measure.md +29 -0
  86. package/principles/13-imagery-sets-the-mood.md +32 -0
  87. package/principles/14-instant-calm-feedback.md +32 -0
  88. package/principles/15-undo-over-confirm.md +30 -0
  89. package/principles/16-empty-states.md +31 -0
  90. package/principles/17-errors-in-plain-words.md +31 -0
  91. package/principles/18-match-the-real-world.md +31 -0
  92. package/principles/19-consistency-and-standards.md +32 -0
  93. package/principles/20-error-prevention.md +31 -0
  94. package/principles/21-recognition-over-recall.md +31 -0
  95. package/principles/22-flexibility-and-shortcuts.md +31 -0
  96. package/principles/23-url-is-state.md +31 -0
  97. package/principles/24-links-are-links.md +31 -0
  98. package/principles/25-visible-focus.md +32 -0
  99. package/principles/26-generous-targets.md +31 -0
  100. package/principles/27-cheap-motion-respect-preferences.md +32 -0
  101. package/principles/28-less-but-better.md +31 -0
  102. package/principles/29-colour-is-not-the-only-cue.md +31 -0
  103. package/principles/30-help-where-needed.md +31 -0
  104. package/principles/README.md +62 -0
@@ -11,6 +11,8 @@
11
11
  title?: string;
12
12
  /** Per-tab override of the strip's `closable` (pin one tab open). */
13
13
  closable?: boolean;
14
+ /** Render this tab as a real link; overrides the strip's `href`. */
15
+ href?: string;
14
16
  /**
15
17
  * Extra attributes for this tab's button — `data-*`, `title`…
16
18
  * The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
@@ -37,11 +39,13 @@
37
39
  import Icon from '../atoms/Icon.svelte';
38
40
  import IconButton from './IconButton.svelte';
39
41
  import { playSound } from '../../sound';
42
+ import { isModifiedClick } from '../../url-state.svelte';
40
43
 
41
44
  let {
42
45
  tabs,
43
46
  value = $bindable(),
44
47
  label = 'Tabs',
48
+ href,
45
49
  panel,
46
50
  closable = false,
47
51
  onclose,
@@ -60,6 +64,8 @@
60
64
  tabs: TabItem[];
61
65
  value?: string;
62
66
  label?: string;
67
+ /** Tabs become real links (`<a aria-current>`): middle-click opens the tab's URL in a new tab. */
68
+ href?: (id: string) => string;
63
69
  /** Content for the active tab. Omit it for a strip-only tablist. */
64
70
  panel?: Snippet<[string]>;
65
71
  /** Every tab gets a close control; Delete and middle-click close too. */
@@ -101,6 +107,10 @@
101
107
  function canClose(t: TabItem): boolean {
102
108
  return t.closable ?? closable;
103
109
  }
110
+ function linkTo(t: TabItem): string | undefined {
111
+ if (t.disabled || canClose(t)) return undefined;
112
+ return t.href ?? href?.(t.id);
113
+ }
104
114
 
105
115
  // One indicator slides under the selected tab on translate/scale (never
106
116
  // width/left). It snaps on mount and on reflow, and only animates on a
@@ -288,6 +298,27 @@
288
298
  }}
289
299
  />
290
300
  </div>
301
+ {:else if linkTo(t) !== undefined}
302
+ <a
303
+ {...t.attrs as HTMLAttributes<HTMLAnchorElement>}
304
+ href={linkTo(t)}
305
+ role="tab"
306
+ id="{baseId}-tab-{t.id}"
307
+ title={t.title ?? t.attrs?.title}
308
+ aria-selected={value === t.id}
309
+ aria-current={value === t.id ? 'page' : undefined}
310
+ aria-controls={panelId}
311
+ tabindex={stop?.id === t.id ? 0 : -1}
312
+ class="tab"
313
+ class:selected={value === t.id}
314
+ onclick={(e) => {
315
+ if (isModifiedClick(e)) return;
316
+ inkFrom(e);
317
+ select(t.id);
318
+ }}
319
+ >
320
+ {@render body(t)}
321
+ </a>
291
322
  {:else}
292
323
  <button
293
324
  type="button"
@@ -362,6 +393,7 @@
362
393
  border: none;
363
394
  background: none;
364
395
  color: var(--text-muted);
396
+ text-decoration: none;
365
397
  font-size: var(--control-fs-md);
366
398
  font-weight: var(--fw-medium);
367
399
  --icon-size: var(--control-icon-md);
@@ -10,6 +10,8 @@ export interface TabItem {
10
10
  title?: string;
11
11
  /** Per-tab override of the strip's `closable` (pin one tab open). */
12
12
  closable?: boolean;
13
+ /** Render this tab as a real link; overrides the strip's `href`. */
14
+ href?: string;
13
15
  /**
14
16
  * Extra attributes for this tab's button — `data-*`, `title`…
15
17
  * The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
@@ -23,6 +25,8 @@ type Own = {
23
25
  tabs: TabItem[];
24
26
  value?: string;
25
27
  label?: string;
28
+ /** Tabs become real links (`<a aria-current>`): middle-click opens the tab's URL in a new tab. */
29
+ href?: (id: string) => string;
26
30
  /** Content for the active tab. Omit it for a strip-only tablist. */
27
31
  panel?: Snippet<[string]>;
28
32
  /** Every tab gets a close control; Delete and middle-click close too. */
@@ -181,7 +181,7 @@
181
181
  --c-bg: #0f1115;
182
182
  --c-surface: #21262f;
183
183
  --c-text: #e6e9ef;
184
- --c-accent: #5ad6a0;
184
+ --c-accent: #4cc3f0;
185
185
  }
186
186
  .q {
187
187
  display: block;
@@ -212,7 +212,7 @@
212
212
  font-size: var(--fs-xs);
213
213
  text-transform: uppercase;
214
214
  letter-spacing: 0.06em;
215
- color: var(--text-faint);
215
+ color: var(--text-muted);
216
216
  }
217
217
  .grid {
218
218
  display: grid;
@@ -297,7 +297,7 @@
297
297
  }
298
298
  .auto-help {
299
299
  font-size: var(--fs-xs);
300
- color: var(--text-faint);
300
+ color: var(--text-muted);
301
301
  white-space: normal;
302
302
  }
303
303
  .auto-pair {
@@ -27,6 +27,6 @@ type Own = {
27
27
  style?: string;
28
28
  };
29
29
  type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
30
- declare const VolumeSlider: import("svelte").Component<$$ComponentProps, {}, "muted" | "value">;
30
+ declare const VolumeSlider: import("svelte").Component<$$ComponentProps, {}, "value" | "muted">;
31
31
  type VolumeSlider = ReturnType<typeof VolumeSlider>;
32
32
  export default VolumeSlider;
@@ -631,7 +631,7 @@
631
631
  [data-stacked] td[data-role='meta']::before {
632
632
  content: attr(data-label) ' ';
633
633
  font-family: var(--font-sans);
634
- color: var(--text-faint);
634
+ color: var(--text-muted);
635
635
  }
636
636
  [data-stacked] td[data-role='detail'][data-stack-label='false'] {
637
637
  flex: 0 1 auto;
@@ -675,7 +675,7 @@
675
675
  }
676
676
  .dt-empty {
677
677
  text-align: center;
678
- color: var(--text-faint);
678
+ color: var(--text-muted);
679
679
  padding: var(--sp-8);
680
680
  }
681
681
  .dt-more {
@@ -0,0 +1,19 @@
1
+ export type DesignIssueKind = 'multiple-primary' | 'unnamed-control' | 'small-target' | 'text-over-image';
2
+ export type DesignIssue = {
3
+ kind: DesignIssueKind;
4
+ el: Element;
5
+ message: string;
6
+ };
7
+ export type DesignAuditOptions = {
8
+ root?: ParentNode;
9
+ /** Debounce window after a DOM mutation, in ms. */
10
+ delay?: number;
11
+ /** Minimum hit box, in px; defaults to the `--touch-target` token (44px). */
12
+ touchTarget?: number;
13
+ /** Treat the pointer as coarse regardless of the media query. */
14
+ coarse?: boolean;
15
+ };
16
+ export declare function auditDesign(options?: DesignAuditOptions): DesignIssue[];
17
+ export declare function reportDesignIssues(issues: DesignIssue[]): DesignIssue[];
18
+ /** Idempotent: a second call while running returns the same stop function. */
19
+ export declare function startDesignAudit(options?: DesignAuditOptions): () => void;
@@ -0,0 +1,259 @@
1
+ const PRIMARY = '[data-tsu="Button"].btn-primary';
2
+ const CONTROL = '[data-tsu="Button"], [data-tsu="IconButton"]';
3
+ const LANDMARK = 'dialog, [role="dialog"], [role="alertdialog"], main, [role="main"]';
4
+ const INTERACTIVE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"], [role="slider"]';
5
+ const SCRIM = '[data-tsu="Scrim"], .scrim, [data-scrim]';
6
+ function view(el) {
7
+ return el.ownerDocument.defaultView ?? window;
8
+ }
9
+ function isVisible(el) {
10
+ if (el.closest('[hidden], [inert]'))
11
+ return false;
12
+ const win = view(el);
13
+ for (let node = el; node; node = node.parentElement) {
14
+ const style = win.getComputedStyle(node);
15
+ if (style.display === 'none' || style.visibility === 'hidden')
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ function isDisabled(el) {
21
+ return el.matches(':disabled, [aria-disabled="true"]');
22
+ }
23
+ function ownText(el) {
24
+ let out = '';
25
+ for (const node of el.childNodes) {
26
+ if (node.nodeType === 3)
27
+ out += node.textContent ?? '';
28
+ else if (node.nodeType === 1 &&
29
+ !node.matches('[aria-hidden="true"], script, style'))
30
+ out += ownText(node);
31
+ }
32
+ return out;
33
+ }
34
+ function accessibleName(el) {
35
+ const label = el.getAttribute('aria-label')?.trim();
36
+ if (label)
37
+ return label;
38
+ const by = el.getAttribute('aria-labelledby');
39
+ if (by) {
40
+ const text = by
41
+ .split(/\s+/)
42
+ .map((id) => el.ownerDocument.getElementById(id)?.textContent?.trim() ?? '')
43
+ .join(' ')
44
+ .trim();
45
+ if (text)
46
+ return text;
47
+ }
48
+ const text = ownText(el).trim();
49
+ if (text)
50
+ return text;
51
+ for (const child of el.querySelectorAll('img[alt], svg title, [aria-label]')) {
52
+ const alt = child.getAttribute('alt') ?? child.getAttribute('aria-label') ?? child.textContent;
53
+ if (alt?.trim())
54
+ return alt.trim();
55
+ }
56
+ return el.getAttribute('title')?.trim() ?? '';
57
+ }
58
+ function describe(el) {
59
+ const tsu = el.getAttribute('data-tsu');
60
+ return tsu ? `<${tsu}>` : `<${el.tagName.toLowerCase()}>`;
61
+ }
62
+ function landmarkOf(el) {
63
+ return el.closest(LANDMARK) ?? el.ownerDocument.body;
64
+ }
65
+ function landmarkName(el) {
66
+ return el === el.ownerDocument.body
67
+ ? 'page'
68
+ : el.matches('main, [role="main"]')
69
+ ? 'main'
70
+ : 'dialog';
71
+ }
72
+ function readToken(root, name, fallback) {
73
+ const doc = root.ownerDocument ?? root;
74
+ const raw = doc.defaultView?.getComputedStyle(doc.documentElement).getPropertyValue(name);
75
+ const n = Number.parseFloat(raw ?? '');
76
+ return Number.isFinite(n) && n > 0 ? n : fallback;
77
+ }
78
+ /* Kit controls are sized to --touch-target-compact by design (see tokens.css). */
79
+ function isKitControl(el) {
80
+ return el.matches('[data-tsu], .btn');
81
+ }
82
+ /** Kit controls stretch their hit box with an absolutely positioned ::before/::after slab. */
83
+ function hasHitSlab(el) {
84
+ const win = view(el);
85
+ for (const pseudo of ['::before', '::after']) {
86
+ const s = win.getComputedStyle(el, pseudo);
87
+ if (s.position === 'absolute' && s.content && s.content !== 'none' && s.content !== 'normal')
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+ function hitBox(el) {
93
+ const own = el.getBoundingClientRect();
94
+ const label = el.closest('label');
95
+ if (!label || label === el)
96
+ return own;
97
+ const lr = label.getBoundingClientRect();
98
+ return lr.width * lr.height > own.width * own.height ? lr : own;
99
+ }
100
+ function hasOpaqueBackground(el) {
101
+ const s = view(el).getComputedStyle(el);
102
+ if (s.backgroundImage && s.backgroundImage !== 'none')
103
+ return true;
104
+ const bg = s.backgroundColor;
105
+ return (!!bg && bg !== 'transparent' && !/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)$/.test(bg));
106
+ }
107
+ function hasOwnText(el) {
108
+ for (const node of el.childNodes)
109
+ if (node.nodeType === 3 && node.textContent?.trim())
110
+ return true;
111
+ return false;
112
+ }
113
+ function imageUnder(text) {
114
+ const artwork = text.closest('[data-tsu="Artwork"]');
115
+ if (artwork)
116
+ return text.matches('[data-tsu="Artwork"] > .initials') ? null : artwork;
117
+ const win = view(text);
118
+ let positioned = false;
119
+ for (let node = text; node; node = node.parentElement) {
120
+ const pos = win.getComputedStyle(node).position;
121
+ if (pos === 'absolute' || pos === 'fixed')
122
+ positioned = true;
123
+ if (!positioned)
124
+ continue;
125
+ if (pos !== 'static' && node !== text) {
126
+ const img = node.querySelector('img, picture, video, [data-tsu="Artwork"]');
127
+ if (img && !img.contains(text))
128
+ return node;
129
+ }
130
+ }
131
+ return null;
132
+ }
133
+ function scrimmed(text, container) {
134
+ if (container.querySelector(SCRIM))
135
+ return true;
136
+ for (let node = text; node && node !== container; node = node.parentElement)
137
+ if (hasOpaqueBackground(node))
138
+ return true;
139
+ return false;
140
+ }
141
+ function coarsePointer(root) {
142
+ const win = (root.ownerDocument ?? root).defaultView;
143
+ return !!win?.matchMedia?.('(pointer: coarse)').matches;
144
+ }
145
+ export function auditDesign(options = {}) {
146
+ if (typeof document === 'undefined' && !options.root)
147
+ return [];
148
+ const root = options.root ?? document;
149
+ const issues = [];
150
+ const byLandmark = new Map();
151
+ for (const btn of root.querySelectorAll(PRIMARY)) {
152
+ if (!isVisible(btn))
153
+ continue;
154
+ const lm = landmarkOf(btn);
155
+ const list = byLandmark.get(lm) ?? [];
156
+ list.push(btn);
157
+ byLandmark.set(lm, list);
158
+ }
159
+ for (const [lm, list] of byLandmark) {
160
+ if (list.length < 2)
161
+ continue;
162
+ for (const el of list.slice(1))
163
+ issues.push({
164
+ kind: 'multiple-primary',
165
+ el,
166
+ message: `${list.length} primary Buttons visible in the same ${landmarkName(lm)}; keep one primary per view`,
167
+ });
168
+ }
169
+ for (const el of root.querySelectorAll(CONTROL)) {
170
+ if (accessibleName(el))
171
+ continue;
172
+ issues.push({ kind: 'unnamed-control', el, message: `${describe(el)} has no accessible name` });
173
+ }
174
+ const coarse = options.coarse ?? coarsePointer(root);
175
+ if (coarse) {
176
+ const target = options.touchTarget ?? readToken(root, '--touch-target', 44);
177
+ const compact = Math.min(target, readToken(root, '--touch-target-compact', 40));
178
+ for (const el of root.querySelectorAll(INTERACTIVE)) {
179
+ if (el.matches('.hit-compact, [tabindex="-1"]') || isDisabled(el) || !isVisible(el))
180
+ continue;
181
+ const box = hitBox(el);
182
+ if (box.width === 0 || box.height === 0)
183
+ continue;
184
+ const min = isKitControl(el) ? compact : target;
185
+ if (box.width >= min && box.height >= min)
186
+ continue;
187
+ if (hasHitSlab(el))
188
+ continue;
189
+ issues.push({
190
+ kind: 'small-target',
191
+ el,
192
+ message: `${describe(el)} hit box is ${Math.round(box.width)}x${Math.round(box.height)}px, under the ${min}px touch target`,
193
+ });
194
+ }
195
+ }
196
+ for (const el of root.querySelectorAll('*')) {
197
+ if (!hasOwnText(el) || el.closest('[aria-hidden="true"]'))
198
+ continue;
199
+ const container = imageUnder(el);
200
+ if (!container || scrimmed(el, container))
201
+ continue;
202
+ issues.push({
203
+ kind: 'text-over-image',
204
+ el,
205
+ message: `${describe(el)} text sits on an image without a Scrim`,
206
+ });
207
+ }
208
+ return issues;
209
+ }
210
+ const reported = new WeakMap();
211
+ export function reportDesignIssues(issues) {
212
+ const fresh = [];
213
+ for (const issue of issues) {
214
+ const seen = reported.get(issue.el) ?? new Set();
215
+ if (seen.has(issue.kind))
216
+ continue;
217
+ seen.add(issue.kind);
218
+ reported.set(issue.el, seen);
219
+ fresh.push(issue);
220
+ console.warn(`[tsumikit] ${issue.message}`, issue.el);
221
+ }
222
+ return fresh;
223
+ }
224
+ let active;
225
+ /** Idempotent: a second call while running returns the same stop function. */
226
+ export function startDesignAudit(options = {}) {
227
+ if (!import.meta.env.DEV || typeof document === 'undefined')
228
+ return () => { };
229
+ if (active)
230
+ return active;
231
+ const root = options.root ?? document;
232
+ const win = (root.ownerDocument ?? root).defaultView ?? window;
233
+ const delay = options.delay ?? 250;
234
+ let timer;
235
+ const run = () => {
236
+ timer = undefined;
237
+ reportDesignIssues(auditDesign(options));
238
+ };
239
+ const schedule = () => {
240
+ if (timer !== undefined)
241
+ win.clearTimeout(timer);
242
+ timer = win.setTimeout(run, delay);
243
+ };
244
+ const observer = new win.MutationObserver(schedule);
245
+ observer.observe(root === document ? document.documentElement : root, {
246
+ subtree: true,
247
+ childList: true,
248
+ attributes: true,
249
+ characterData: true,
250
+ });
251
+ schedule();
252
+ active = () => {
253
+ observer.disconnect();
254
+ if (timer !== undefined)
255
+ win.clearTimeout(timer);
256
+ active = undefined;
257
+ };
258
+ return active;
259
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export type { AnchorAttributes } from './anchor';
2
2
  export { artworkGradient, artworkHue, initials } from './artwork';
3
3
  export { autoresize } from './autoresize';
4
4
  export { avatarHue, avatarInitial } from './avatar';
5
+ export { type Breakpoint, breakpoints, breakpointsPx, maxWidth, minWidth } from './breakpoints';
5
6
  export { copyToClipboard } from './clipboard';
6
7
  export { default as Artwork } from './components/atoms/Artwork.svelte';
7
8
  export { default as Avatar } from './components/atoms/Avatar.svelte';
@@ -35,6 +36,7 @@ export { default as Thinking } from './components/atoms/Thinking.svelte';
35
36
  export { default as TimeDisplay } from './components/atoms/TimeDisplay.svelte';
36
37
  export { default as AppShell } from './components/layouts/AppShell.svelte';
37
38
  export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
39
+ export { default as Backdrop } from './components/layouts/Backdrop.svelte';
38
40
  export { default as Cluster } from './components/layouts/Cluster.svelte';
39
41
  export { default as Container } from './components/layouts/Container.svelte';
40
42
  export { default as MasterDetail } from './components/layouts/MasterDetail.svelte';
@@ -116,7 +118,9 @@ export { EMOJI_GROUPS, type EmojiEntry, type EmojiGroup, searchEmoji } from './e
116
118
  export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
117
119
  export { DEFAULT_FX, FX, FX_FAMILIES, type FxFamily, type FxSettings, type FxVariant, fxAttr, fxDiff, fxPrepaint, isFxVariant, parseFx, } from './fx';
118
120
  export { escapeHtml, type Highlight, type RenderMarkdownOptions, renderMarkdown, stripAnsi, } from './markdown';
121
+ export { type Media, media, mq } from './media.svelte';
119
122
  export { describeMediaTime, formatMediaTime, isLiveDuration } from './media-time';
123
+ export { PENDING_DELAY_MS, PENDING_MIN_MS, type Pending, type PendingOptions, pending, } from './pending.svelte';
120
124
  export { trackPointer } from './pointer';
121
125
  export * as filterQuery from './query';
122
126
  export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, singleQuery, suggest, suggestSingle, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
@@ -127,9 +131,10 @@ export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.sve
127
131
  export { FX_PREPAINT, fx } from './stores/fx.svelte';
128
132
  export { sound } from './stores/sound.svelte';
129
133
  export { type Mode, THEME_PREPAINT, THEMES, theme } from './stores/theme.svelte';
130
- export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, } from './stores/toast.svelte';
134
+ export { type Toast, type ToastAction, type ToastOptions, type ToastTone, type ToastToneInput, toasts, UNDO_FAIL_MESSAGE, type UndoOptions, } from './stores/toast.svelte';
131
135
  export { AUTO_THEME, chooseTheme, DEFAULT_LOOK, DEFAULT_THEME_PREFERENCE, LOOKS, type LookId, type LookStatus, parseLook, pickerValue, preferenceFrom, resolveTheme, type SlotOf, type ThemeChoice, type ThemePreference, type ThemeSlot, themePrepaint, } from './theme-mode';
132
136
  export { formatTimestamp, localTimeZone, relativeTime, type TimeInput, type TimestampMode, } from './timestamp';
133
137
  export { canonicalTone, type Tone } from './tone';
134
138
  export { pathCandidates, type TruncateMode, type TruncateOptions, truncate } from './truncate';
139
+ export { isModifiedClick, type UrlState, type UrlStateOptions, urlState } from './url-state.svelte';
135
140
  export { normalizeWorkingDir, type WorkingDirOptions, workingDirCandidates } from './working-dir';
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  export { artworkGradient, artworkHue, initials } from './artwork';
8
8
  export { autoresize } from './autoresize';
9
9
  export { avatarHue, avatarInitial } from './avatar';
10
+ export { breakpoints, breakpointsPx, maxWidth, minWidth } from './breakpoints';
10
11
  export { copyToClipboard } from './clipboard';
11
12
  export { default as Artwork } from './components/atoms/Artwork.svelte';
12
13
  export { default as Avatar } from './components/atoms/Avatar.svelte';
@@ -39,6 +40,7 @@ export { default as TimeDisplay } from './components/atoms/TimeDisplay.svelte';
39
40
  // ---- layouts ----
40
41
  export { default as AppShell } from './components/layouts/AppShell.svelte';
41
42
  export { default as AutoGrid } from './components/layouts/AutoGrid.svelte';
43
+ export { default as Backdrop } from './components/layouts/Backdrop.svelte';
42
44
  export { default as Cluster } from './components/layouts/Cluster.svelte';
43
45
  export { default as Container } from './components/layouts/Container.svelte';
44
46
  export { default as MasterDetail } from './components/layouts/MasterDetail.svelte';
@@ -124,7 +126,9 @@ export { EMOJI_GROUPS, searchEmoji } from './emoji';
124
126
  export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
125
127
  export { DEFAULT_FX, FX, FX_FAMILIES, fxAttr, fxDiff, fxPrepaint, isFxVariant, parseFx, } from './fx';
126
128
  export { escapeHtml, renderMarkdown, stripAnsi, } from './markdown';
129
+ export { media, mq } from './media.svelte';
127
130
  export { describeMediaTime, formatMediaTime, isLiveDuration } from './media-time';
131
+ export { PENDING_DELAY_MS, PENDING_MIN_MS, pending, } from './pending.svelte';
128
132
  export { trackPointer } from './pointer';
129
133
  // ---- query core (headless: schema / parser / AST / suggest / compilers) ----
130
134
  export * as filterQuery from './query';
@@ -136,9 +140,10 @@ export { FX_PREPAINT, fx } from './stores/fx.svelte';
136
140
  // ---- stores / actions ----
137
141
  export { sound } from './stores/sound.svelte';
138
142
  export { THEME_PREPAINT, THEMES, theme } from './stores/theme.svelte';
139
- export { toasts, } from './stores/toast.svelte';
143
+ export { toasts, UNDO_FAIL_MESSAGE, } from './stores/toast.svelte';
140
144
  export { AUTO_THEME, chooseTheme, DEFAULT_LOOK, DEFAULT_THEME_PREFERENCE, LOOKS, parseLook, pickerValue, preferenceFrom, resolveTheme, themePrepaint, } from './theme-mode';
141
145
  export { formatTimestamp, localTimeZone, relativeTime, } from './timestamp';
142
146
  export { canonicalTone } from './tone';
143
147
  export { pathCandidates, truncate } from './truncate';
148
+ export { isModifiedClick, urlState } from './url-state.svelte';
144
149
  export { normalizeWorkingDir, workingDirCandidates } from './working-dir';
@@ -0,0 +1,8 @@
1
+ import { type Breakpoint } from './breakpoints';
2
+ export interface Media {
3
+ /** Reactive inside effects and templates; `false` during SSR. */
4
+ readonly matches: boolean;
5
+ }
6
+ export declare function media(query: string): Media;
7
+ /** `mq.md` is true when the viewport is at least `breakpoints.md` wide. */
8
+ export declare const mq: Readonly<Record<Breakpoint, boolean>>;
@@ -0,0 +1,26 @@
1
+ import { MediaQuery } from 'svelte/reactivity';
2
+ import { breakpoints, minWidth } from './breakpoints';
3
+ const cache = new Map();
4
+ export function media(query) {
5
+ let m = cache.get(query);
6
+ if (!m) {
7
+ if (typeof window === 'undefined') {
8
+ m = { matches: false };
9
+ }
10
+ else {
11
+ const mq = new MediaQuery(query);
12
+ m = {
13
+ get matches() {
14
+ return mq.current;
15
+ },
16
+ };
17
+ }
18
+ cache.set(query, m);
19
+ }
20
+ return m;
21
+ }
22
+ /** `mq.md` is true when the viewport is at least `breakpoints.md` wide. */
23
+ export const mq = Object.defineProperties({}, Object.fromEntries(Object.keys(breakpoints).map((bp) => [
24
+ bp,
25
+ { enumerable: true, get: () => media(minWidth(bp)).matches },
26
+ ])));
@@ -0,0 +1,16 @@
1
+ /** Default show delay (ms): work that finishes sooner never shows an indicator. */
2
+ export declare const PENDING_DELAY_MS = 200;
3
+ /** Default minimum visible time (ms) once an indicator has appeared. */
4
+ export declare const PENDING_MIN_MS = 500;
5
+ export interface PendingOptions {
6
+ /** Wait this long before showing; 0 shows at once. */
7
+ delay?: number;
8
+ /** Once shown, stay visible at least this long; 0 hides at once. */
9
+ min?: number;
10
+ }
11
+ export interface Pending {
12
+ /** The calmed indicator state: delayed in, held out. */
13
+ readonly visible: boolean;
14
+ }
15
+ /** Call during component init; keep `aria-busy` on the real flag, only the visual is calmed. */
16
+ export declare function pending(busy: () => boolean, opts?: PendingOptions | (() => PendingOptions)): Pending;
@@ -0,0 +1,75 @@
1
+ import { untrack } from 'svelte';
2
+ /** Default show delay (ms): work that finishes sooner never shows an indicator. */
3
+ export const PENDING_DELAY_MS = 200;
4
+ /** Default minimum visible time (ms) once an indicator has appeared. */
5
+ export const PENDING_MIN_MS = 500;
6
+ /** Call during component init; keep `aria-busy` on the real flag, only the visual is calmed. */
7
+ export function pending(busy, opts = {}) {
8
+ if (typeof document === 'undefined')
9
+ return { visible: false };
10
+ const options = () => (typeof opts === 'function' ? opts() : opts);
11
+ const delay = () => Math.max(0, options().delay ?? PENDING_DELAY_MS);
12
+ const min = () => Math.max(0, options().min ?? PENDING_MIN_MS);
13
+ let visible = $state(false);
14
+ let showTimer;
15
+ let minTimer;
16
+ let holding = false;
17
+ let hideWhenReleased = false;
18
+ const clearShow = () => {
19
+ if (showTimer !== undefined)
20
+ clearTimeout(showTimer);
21
+ showTimer = undefined;
22
+ };
23
+ const show = () => {
24
+ showTimer = undefined;
25
+ visible = true;
26
+ const hold = min();
27
+ if (hold > 0) {
28
+ holding = true;
29
+ minTimer = setTimeout(() => {
30
+ holding = false;
31
+ minTimer = undefined;
32
+ if (hideWhenReleased) {
33
+ hideWhenReleased = false;
34
+ visible = false;
35
+ }
36
+ }, hold);
37
+ }
38
+ };
39
+ const start = () => {
40
+ if (visible) {
41
+ hideWhenReleased = false;
42
+ return;
43
+ }
44
+ if (showTimer !== undefined)
45
+ return;
46
+ const wait = delay();
47
+ if (wait === 0)
48
+ show();
49
+ else
50
+ showTimer = setTimeout(show, wait);
51
+ };
52
+ const stop = () => {
53
+ clearShow();
54
+ if (!visible)
55
+ return;
56
+ if (holding)
57
+ hideWhenReleased = true;
58
+ else
59
+ visible = false;
60
+ };
61
+ $effect(() => {
62
+ const on = busy();
63
+ untrack(() => (on ? start() : stop()));
64
+ });
65
+ $effect(() => () => {
66
+ clearShow();
67
+ if (minTimer !== undefined)
68
+ clearTimeout(minTimer);
69
+ });
70
+ return {
71
+ get visible() {
72
+ return visible;
73
+ },
74
+ };
75
+ }
@@ -21,6 +21,21 @@ export interface ToastOptions {
21
21
  duration?: number;
22
22
  action?: ToastAction;
23
23
  }
24
+ export interface UndoOptions {
25
+ message: string;
26
+ /** Runs at once: the optimistic UI change. */
27
+ apply: () => void;
28
+ /** Runs when the toast times out or is dismissed; a rejection reverts and reports. */
29
+ commit: () => void | Promise<void>;
30
+ /** Runs on Undo, and after a failed commit. */
31
+ revert: () => void | Promise<void>;
32
+ duration?: number;
33
+ tone?: ToastToneInput;
34
+ undoLabel?: string;
35
+ /** Error toast shown when commit() rejects. */
36
+ failMessage?: string;
37
+ }
38
+ export declare const UNDO_FAIL_MESSAGE = "The change could not be saved, so it was undone.";
24
39
  export declare const TOAST_MS = 4000;
25
40
  /** Toasts with an action linger longer: the action is only reachable while visible. */
26
41
  export declare const ACTION_TOAST_MS = 7000;
@@ -36,6 +51,13 @@ declare class Toasts {
36
51
  ok(message: string, duration?: number, action?: ToastAction): number;
37
52
  error(message: string, duration?: number, action?: ToastAction): number;
38
53
  info(message: string, duration?: number, action?: ToastAction): number;
54
+ /**
55
+ * Optimistic change with an Undo toast instead of a confirm dialog: apply()
56
+ * now, commit() when the toast goes away, revert() on Undo or a failed commit.
57
+ */
58
+ undo(opts: UndoOptions): number;
59
+ /** Commit every undo toast still waiting; the page is going away. */
60
+ flush(): void;
39
61
  dismiss(id: number): void;
40
62
  /** Drop a toast at once, skipping the exit. */
41
63
  remove(id: number): void;