@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
@@ -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;
@@ -1,7 +1,9 @@
1
1
  // Toast manager. A tiny reactive queue; mount one <Toaster /> at the app root
2
2
  // and call toasts.show()/ok()/error()/info() from anywhere. Auto-dismisses after
3
- // `duration` ms (0 = sticky). The Toaster renders an aria-live region so screen
4
- // readers announce messages.
3
+ // `duration` ms (0 = sticky). toasts.undo() is the optimistic alternative to a
4
+ // confirm dialog. The Toaster renders an aria-live region so screen readers
5
+ // announce messages.
6
+ export const UNDO_FAIL_MESSAGE = 'The change could not be saved, so it was undone.';
5
7
  export const TOAST_MS = 4000;
6
8
  /** Toasts with an action linger longer: the action is only reachable while visible. */
7
9
  export const ACTION_TOAST_MS = 7000;
@@ -13,6 +15,8 @@ class Toasts {
13
15
  #timers = new Map();
14
16
  #due = new Map();
15
17
  #paused = new Map();
18
+ #commits = new Map();
19
+ #flushBound = false;
16
20
  #arm(id, ms) {
17
21
  this.#due.set(id, Date.now() + ms);
18
22
  this.#timers.set(id, setTimeout(() => this.dismiss(id), ms));
@@ -62,8 +66,55 @@ class Toasts {
62
66
  info(message, duration, action) {
63
67
  return this.show(message, { tone: 'info', duration, action });
64
68
  }
69
+ /**
70
+ * Optimistic change with an Undo toast instead of a confirm dialog: apply()
71
+ * now, commit() when the toast goes away, revert() on Undo or a failed commit.
72
+ */
73
+ undo(opts) {
74
+ const { message, apply, commit, revert, duration = ACTION_TOAST_MS } = opts;
75
+ apply();
76
+ let settled = false;
77
+ const id = this.show(message, {
78
+ tone: opts.tone,
79
+ duration,
80
+ action: {
81
+ label: opts.undoLabel ?? 'Undo',
82
+ run: () => {
83
+ settled = true;
84
+ this.#commits.delete(id);
85
+ return revert();
86
+ },
87
+ },
88
+ });
89
+ this.#commits.set(id, () => {
90
+ if (settled)
91
+ return;
92
+ settled = true;
93
+ this.#commits.delete(id);
94
+ Promise.resolve()
95
+ .then(commit)
96
+ .catch(async () => {
97
+ try {
98
+ await revert();
99
+ }
100
+ catch { }
101
+ this.error(opts.failMessage ?? UNDO_FAIL_MESSAGE);
102
+ });
103
+ });
104
+ if (!this.#flushBound && typeof window !== 'undefined') {
105
+ this.#flushBound = true;
106
+ window.addEventListener('pagehide', () => this.flush());
107
+ }
108
+ return id;
109
+ }
110
+ /** Commit every undo toast still waiting; the page is going away. */
111
+ flush() {
112
+ for (const run of [...this.#commits.values()])
113
+ run();
114
+ }
65
115
  dismiss(id) {
66
116
  this.#disarm(id);
117
+ this.#commits.get(id)?.();
67
118
  const t = this.items.find((x) => x.id === id);
68
119
  if (!t || t.leaving)
69
120
  return;
@@ -73,6 +124,7 @@ class Toasts {
73
124
  /** Drop a toast at once, skipping the exit. */
74
125
  remove(id) {
75
126
  this.#disarm(id);
127
+ this.#commits.get(id)?.();
76
128
  this.items = this.items.filter((t) => t.id !== id);
77
129
  }
78
130
  /** Run a toast's action once: the toast stays (button loading) until run settles, then dismisses. */
@@ -1,9 +1,10 @@
1
1
  /*
2
- * All-in-one stylesheet: tokens + built-in themes, reset + element defaults,
2
+ * All-in-one stylesheet: Inter @font-face, tokens + built-in themes, reset + element defaults,
3
3
  * generic utilities and the syntax-highlight token mapping (global so it
4
4
  * reaches {@html} output). Import the parts individually to compose your own
5
5
  * stack — see README "Theming".
6
6
  */
7
+ @import './font.css';
7
8
  @import './variables.css';
8
9
  @import './reset.css';
9
10
  @import './utilities.css';
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Inter, self-hosted (variable, latin subset; OFL-1.1, see fonts/LICENSE-Inter.txt).
3
+ * --font-sans in tokens.css lists "Inter" first, so importing this file turns it
4
+ * on for the whole kit. Opt out by importing tokens.css/themes.css without it,
5
+ * or by overriding --font-sans.
6
+ */
7
+
8
+ @font-face {
9
+ font-family: "Inter";
10
+ font-style: normal;
11
+ font-weight: 100 900;
12
+ font-display: swap;
13
+ src: url("./fonts/inter.woff2") format("woff2");
14
+ }
15
+
16
+ /* cv11: single-storey a · ss01: open digits, so 0/O and 1/l/I stay apart. */
17
+ :where(body) {
18
+ font-feature-settings: "cv11", "ss01";
19
+ }