@dorsk/tsumikit 0.71.0 → 0.73.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 (84) hide show
  1. package/README.md +54 -5
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +4 -2
  5. package/dist/components/atoms/Avatar.svelte +4 -3
  6. package/dist/components/atoms/Badge.svelte +13 -0
  7. package/dist/components/atoms/Button.svelte +25 -9
  8. package/dist/components/atoms/Card.svelte +2 -0
  9. package/dist/components/atoms/Dot.svelte +1 -1
  10. package/dist/components/atoms/Icon.svelte +7 -0
  11. package/dist/components/atoms/Icon.svelte.d.ts +7 -0
  12. package/dist/components/atoms/Kbd.svelte +28 -16
  13. package/dist/components/atoms/Link.svelte +6 -4
  14. package/dist/components/atoms/Scrim.svelte +18 -1
  15. package/dist/components/atoms/Slider.svelte +11 -1
  16. package/dist/components/atoms/Swatch.svelte +12 -0
  17. package/dist/components/atoms/TimeDisplay.svelte +98 -0
  18. package/dist/components/atoms/TimeDisplay.svelte.d.ts +24 -0
  19. package/dist/components/layouts/AppShell.svelte +72 -40
  20. package/dist/components/layouts/Container.svelte +25 -3
  21. package/dist/components/layouts/MasterDetail.svelte +59 -33
  22. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  23. package/dist/components/layouts/NavItem.svelte +8 -4
  24. package/dist/components/layouts/NavSection.svelte +8 -2
  25. package/dist/components/layouts/ResizablePanel.svelte +19 -4
  26. package/dist/components/layouts/TabBar.svelte +217 -0
  27. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  28. package/dist/components/layouts/Toolbar.svelte +5 -0
  29. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  30. package/dist/components/layouts/toolbar-roving.js +47 -1
  31. package/dist/components/molecules/Accordion.svelte +2 -1
  32. package/dist/components/molecules/AttachmentList.svelte +1 -0
  33. package/dist/components/molecules/Breadcrumb.svelte +7 -6
  34. package/dist/components/molecules/Breadcrumb.svelte.d.ts +2 -1
  35. package/dist/components/molecules/CapBar.svelte +3 -1
  36. package/dist/components/molecules/Composer.svelte +27 -5
  37. package/dist/components/molecules/Drawer.svelte +10 -1
  38. package/dist/components/molecules/Dropzone.svelte +2 -1
  39. package/dist/components/molecules/FilterInput.svelte +17 -1
  40. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  41. package/dist/components/molecules/Metric.svelte +28 -3
  42. package/dist/components/molecules/Modal.svelte +20 -4
  43. package/dist/components/molecules/Popover.svelte +7 -3
  44. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  45. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  46. package/dist/components/molecules/SectionHeader.svelte +29 -14
  47. package/dist/components/molecules/SeekSlider.svelte +370 -0
  48. package/dist/components/molecules/SeekSlider.svelte.d.ts +37 -0
  49. package/dist/components/molecules/SegmentedControl.svelte +10 -6
  50. package/dist/components/molecules/SelectButton.svelte +33 -22
  51. package/dist/components/molecules/SwipeRow.svelte +349 -0
  52. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  53. package/dist/components/molecules/Tabs.svelte +11 -5
  54. package/dist/components/molecules/Timestamp.svelte +1 -1
  55. package/dist/components/molecules/Toaster.svelte +70 -13
  56. package/dist/components/molecules/Tooltip.svelte +19 -10
  57. package/dist/components/molecules/VolumeSlider.svelte +367 -0
  58. package/dist/components/molecules/VolumeSlider.svelte.d.ts +30 -0
  59. package/dist/components/molecules/combobox-keyboard.js +2 -1
  60. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  61. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  62. package/dist/components/organisms/DataTable.svelte +22 -5
  63. package/dist/components/organisms/DataTable.svelte.d.ts +4 -2
  64. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  65. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  66. package/dist/index.d.ts +7 -0
  67. package/dist/index.js +7 -0
  68. package/dist/markdown.js +15 -7
  69. package/dist/media-time.d.ts +6 -0
  70. package/dist/media-time.js +25 -0
  71. package/dist/query/edit.js +1 -16
  72. package/dist/query/parser.js +68 -28
  73. package/dist/query/query.js +9 -5
  74. package/dist/query/quotes.d.ts +24 -0
  75. package/dist/query/quotes.js +78 -0
  76. package/dist/query/single.js +1 -3
  77. package/dist/query/suggest.js +25 -32
  78. package/dist/stores/fontscale.svelte.js +3 -2
  79. package/dist/stores/storage.d.ts +2 -0
  80. package/dist/stores/storage.js +16 -0
  81. package/dist/stores/theme.svelte.js +4 -7
  82. package/dist/styles/reset.css +30 -0
  83. package/dist/styles/syntax.css +0 -29
  84. package/package.json +1 -1
package/README.md CHANGED
@@ -102,7 +102,7 @@ individually, in this order:
102
102
  | --- | --- |
103
103
  | `styles/tokens.css` | `:root` only — every token the kit reads (`--c-*` palette, `--bg`/`--text`/`--accent` aliases, type scale, spacing, radii, shadows, fonts, `--control-height`, …). Theme-less. |
104
104
  | `styles/themes.css` | the built-in `[data-theme="id"]` blocks, one per `THEMES` entry. |
105
- | `styles/reset.css` | reset + element defaults. |
105
+ | `styles/reset.css` | reset + element defaults, global reduced-motion / forced-colors / prefers-contrast rules. |
106
106
  | `styles/utilities.css` | `.container`, `.stack`, `.row`, `.sr-only`, icon sizing, … |
107
107
  | `styles/syntax.css` | highlight.js / Prism class → `--syn-*` mapping. |
108
108
 
@@ -273,7 +273,7 @@ else aria-hidden, `interactive` renders a button with `onclick` + `aria-pressed`
273
273
  from `selected`), Divider (standalone
274
274
  horizontal/vertical hairline, `tone`, `spacing`, optional centred label,
275
275
  `decorative` to drop the `separator` role), Link (`tone`, `underline`
276
- always/hover/none, `align`), Icon (open registry — pass a `children` snippet for
276
+ always/hover/none, default hover for navigation and titles; pass `always` inside prose, `align`), Icon (open registry — pass a `children` snippet for
277
277
  any custom SVG).
278
278
 
279
279
  **Molecules:** Field (`grow`), IconButton (`filled` forwards to `Icon`, for a
@@ -344,7 +344,8 @@ replaces a header cell (a select-all checkbox);
344
344
  paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
345
345
  for a hover/focus-revealed trailing cell (always visible on touch; under
346
346
  `layout="fixed"` its column is `--dt-actions-w`, default 3.5rem),
347
- `stickyOffset` for the sticky header's `top`, `size="sm"`, `loading`,
347
+ `stickyOffset` for the sticky header's `top` (it sticks within the table's own
348
+ scroller, so give the table a bounded height), `size="sm"`, `loading`,
348
349
  `onloadmore` footer button, `empty` as string or snippet; `data-part`
349
350
  hooks on head/row/cell; `responsive="stack"` turns rows into cards below
350
351
  `stackBelow` (48rem, measured on the table's own box) using `Column.role`
@@ -355,7 +356,7 @@ literal `inherit` falls back to mono), stacked tables use `table-layout: fixed`,
355
356
  `stackActions="end"` closes every card with its actions, cells that render
356
357
  nothing get `data-empty` and are hidden and skipped by the `·` separators, the `<table>`
357
358
  stays a table for assistive tech), FilterSearchBar /
358
- FilterInput (`size="sm"` compact bar on `--control-height-compact`, text
359
+ FilterInput (`font="sans"` for a plain search box, `size="sm"` compact bar on `--control-height-compact`, text
359
360
  floored at 16px under `pointer: coarse` so iOS does not zoom on focus,
360
361
  `shape="pill"`, `surface`, `hotkey="/"` focuses the input from anywhere
361
362
  outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`;
@@ -383,7 +384,8 @@ insets it as an elevated rounded card), NavItem
383
384
  (collapses to an icon rail when the sidebar is narrow; `icon` from the
384
385
  registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
385
386
  for the inset-bar active look), MasterDetail (list + detail columns that
386
- become two pages — one pane at a time with a sticky 44px back header — below
387
+ become two pages — one pane at a time with a sticky 44px back header, keeping the
388
+ list's scroll and focus — below
387
389
  their own `breakpoint`, so `selected` can be driven from the URL; `gap` spaces
388
390
  the columns and `divider="none"` drops the seam, both also settable as the
389
391
  `--md-gap` / `--md-divider` custom properties), Container, Stack
@@ -865,6 +867,53 @@ proportionally with no code. That's the recommended path for magnification.
865
867
  text tokens only) for reading-dense apps that want larger body text while
866
868
  keeping chrome compact. It isn't wired into AppShell or any default.
867
869
 
870
+ ### Mobile: TabBar, SwipeRow, PullToRefresh
871
+
872
+ `TabBar` is a phone bottom tab bar: `items: { id, label, icon?, href?, badge? }[]`,
873
+ `bind:value` (the selected `id`), `onselect(id, item)`. Items without `href` are a
874
+ `tablist` with roving focus (←/→/Home/End); items with `href` are links with
875
+ `aria-current="page"`. It is fixed to the bottom with `env(safe-area-inset-bottom)`
876
+ (`placement="inline"` flows with the page) and, while mounted as a bar, publishes its
877
+ height as `--tabbar-h` on `<html>`, so content clears it with
878
+ `padding-bottom: var(--tabbar-h)`. Targets are at least 48px; numeric badges cap at `99+`.
879
+
880
+ `SwipeRow` wraps a list row with one action per side: `leadingAction` on a rightward
881
+ swipe, `trailingAction` on a leftward one, each `{ label, icon?, tone?, onaction }`
882
+ (or `leading` / `trailing` snippets receiving the action and an `armed` flag).
883
+ Releasing past `threshold` (0.35 of the width) fires `onaction`. Drags that are more
884
+ vertical than horizontal are left to the scroller (`touch-action: pan-y`). The same
885
+ actions are visually hidden buttons for keyboard and screen-reader users.
886
+
887
+ `PullToRefresh` is a scroll container with pull-down-to-refresh for touch and pen.
888
+ `onrefresh` returns a promise; the Spinner holds and the container is `aria-busy`
889
+ with `label` ("Refreshing…") announced politely until it settles. It starts only at
890
+ `scrollTop === 0`, fires once past `threshold` (72px), and contains overscroll so the
891
+ browser's own pull does not fight it. Keep a visible refresh control for mouse users.
892
+
893
+ ### Player: SeekSlider, VolumeSlider, TimeDisplay
894
+
895
+ `SeekSlider` is a media scrubber: `bind:value` (seconds), `max` (duration;
896
+ `null`/`Infinity`/`0` = live, track disabled), `buffered` (`[start, end][]` drawn
897
+ as a muted fill), `step` (5 s arrows), `bigStep` (10 % PageUp/Down), `size="sm" | "md"`,
898
+ `formatTime`, `previewSnippet(time)` for a thumbnail in the hover tooltip. `oninput`
899
+ fires while dragging and never seeks; `onchange` fires once per release or keyboard
900
+ step, so seek the player only there. `onscrubstart`/`onscrubend` bracket a drag. The
901
+ hit slab is `--touch-target-compact` tall on coarse pointers while the track stays
902
+ 3–4 px, and horizontal scrubbing never scrolls the page. `aria-valuetext` reads
903
+ "1:02 of 23:45".
904
+
905
+ `VolumeSlider` pairs a mute toggle (glyph follows the level) with a short slider:
906
+ `bind:value` (0–1), `bind:muted`, `orientation="vertical"`, `step` (5 %, also the
907
+ wheel notch), `onchange`/`oninput`/`onmutechange`. Changing the level unmutes.
908
+ `hideOnTouch` (default) hides the slider, not the mute button, on touch screens.
909
+
910
+ `TimeDisplay` renders "1:02 / 23:45" in tabular numbers; clicking toggles
911
+ `bind:remaining`. `formatMediaTime`, `describeMediaTime` and `isLiveDuration` are
912
+ exported for custom readouts.
913
+
914
+ AppShell exposes `--shell-header-h` (the header plus the top safe-area inset) for
915
+ sticky offsets under `viewport-fit=cover`.
916
+
868
917
  ### Toolbar control heights
869
918
 
870
919
  `size="sm"` is the shared compact-toolbar contract for `Button`, `Popover`
@@ -32,6 +32,18 @@ export function autoresize(node, _value) {
32
32
  };
33
33
  resize();
34
34
  node.addEventListener('input', resize);
35
+ // Re-wrap on width changes, and measure once a hidden textarea is revealed.
36
+ let width = node.clientWidth;
37
+ const ro = typeof ResizeObserver === 'undefined'
38
+ ? undefined
39
+ : new ResizeObserver(([entry]) => {
40
+ const w = entry.contentRect.width;
41
+ if (w === width)
42
+ return;
43
+ width = w;
44
+ resize();
45
+ });
46
+ ro?.observe(node);
35
47
  return {
36
48
  update() {
37
49
  // re-measure when the bound value changes from outside (e.g. cleared)
@@ -39,6 +51,7 @@ export function autoresize(node, _value) {
39
51
  },
40
52
  destroy() {
41
53
  node.removeEventListener('input', resize);
54
+ ro?.disconnect();
42
55
  },
43
56
  };
44
57
  }
package/dist/clipboard.js CHANGED
@@ -13,15 +13,27 @@ export async function copyToClipboard(value) {
13
13
  /* fall through to legacy path */
14
14
  }
15
15
  // Fallback for insecure contexts / older browsers.
16
+ const prev = document.activeElement;
17
+ const sel = document.getSelection();
18
+ const ranges = sel
19
+ ? Array.from({ length: sel.rangeCount }, (_, i) => sel.getRangeAt(i).cloneRange())
20
+ : [];
16
21
  try {
17
22
  const ta = document.createElement('textarea');
18
23
  ta.value = value;
24
+ ta.setAttribute('readonly', '');
19
25
  ta.style.position = 'fixed';
20
26
  ta.style.opacity = '0';
21
27
  document.body.appendChild(ta);
22
28
  ta.select();
23
29
  const ok = document.execCommand('copy');
24
30
  document.body.removeChild(ta);
31
+ prev?.focus?.({ preventScroll: true });
32
+ if (sel && ranges.length) {
33
+ sel.removeAllRanges();
34
+ for (const r of ranges)
35
+ sel.addRange(r);
36
+ }
25
37
  return ok;
26
38
  }
27
39
  catch {
@@ -60,6 +60,7 @@
60
60
 
61
61
  const key = $derived(seed ?? alt);
62
62
  const showImage = $derived(Boolean(src) && !failed);
63
+ const tile = $derived(showImage ? undefined : alt.trim() ? 'named' : 'decorative');
63
64
  const gradient = $derived(showImage ? undefined : artworkGradient(key));
64
65
  const text = $derived(fallback === 'initials' ? initialsOf(key) : '');
65
66
 
@@ -76,8 +77,9 @@
76
77
  style:aspect-ratio={aspect}
77
78
  style:width={size}
78
79
  style:background={gradient}
79
- role={showImage ? undefined : 'img'}
80
- aria-label={showImage ? undefined : alt}
80
+ role={tile === 'named' ? 'img' : undefined}
81
+ aria-label={tile === 'named' ? alt : undefined}
82
+ aria-hidden={tile === 'decorative' ? 'true' : undefined}
81
83
  {...rest}
82
84
  >
83
85
  {#if showImage}
@@ -38,6 +38,7 @@
38
38
 
39
39
  const text = $derived(glyph?.trim() || avatarInitial(name) || '?');
40
40
  const hashed = $derived(tone === undefined);
41
+ const hidden = $derived(decorative || !name.trim());
41
42
  const resolvedHue = $derived(hashed ? (hue ?? avatarHue(seed ?? name)) : undefined);
42
43
  </script>
43
44
 
@@ -55,9 +56,9 @@
55
56
  class:size-lg={size === 'lg'}
56
57
  style:--avatar-size={typeof size === 'number' ? `${size}px` : undefined}
57
58
  style:--avatar-hue={resolvedHue}
58
- role={decorative ? undefined : 'img'}
59
- aria-label={decorative ? undefined : name}
60
- aria-hidden={decorative ? 'true' : undefined}
59
+ role={hidden ? undefined : 'img'}
60
+ aria-label={hidden ? undefined : name}
61
+ aria-hidden={hidden ? 'true' : undefined}
61
62
  {...rest}
62
63
  >
63
64
  <span class="text" aria-hidden="true">{text}</span>
@@ -140,6 +140,7 @@
140
140
  class:interactive={as === 'button' || !!href}
141
141
  style:--badge-tone={toneColor}
142
142
  style:--badge-max-width={maxWidth}
143
+ type={!href && as === 'button' ? 'button' : undefined}
143
144
  {...rest}
144
145
  >
145
146
  {#if dot}
@@ -295,4 +296,16 @@
295
296
  .action:hover {
296
297
  opacity: 1;
297
298
  }
299
+ @media (pointer: coarse) {
300
+ .action {
301
+ position: relative;
302
+ }
303
+ .action::before {
304
+ content: '';
305
+ position: absolute;
306
+ inset-block: var(--touch-slab);
307
+ inset-inline-start: var(--touch-slab);
308
+ inset-inline-end: 0;
309
+ }
310
+ }
298
311
  </style>
@@ -114,14 +114,23 @@
114
114
  const t = $derived(canonicalTone(tone));
115
115
 
116
116
  // A link-flavoured Button is a real <a>; native `disabled`/`type` don't apply,
117
- // so a disabled link is expressed via aria-disabled + inert pointer handling.
117
+ // so an inactive link drops `href` (no focus, no keyboard activation).
118
118
  const tag = $derived(href !== undefined || as === 'a' ? 'a' : 'button');
119
119
  const inactive = $derived(disabled || loading);
120
120
  const elementAttrs = $derived(
121
121
  tag === 'a'
122
- ? { href, 'aria-disabled': inactive || undefined }
122
+ ? inactive
123
+ ? { href: undefined, role: 'link', 'aria-disabled': true }
124
+ : { href }
123
125
  : { type, disabled: inactive }
124
126
  );
127
+ const handleClick = (e: Parameters<NonNullable<typeof onclick>>[0]) => {
128
+ if (inactive) {
129
+ e.preventDefault();
130
+ return;
131
+ }
132
+ onclick?.(e);
133
+ };
125
134
 
126
135
  const counted = $derived(hasCount(count));
127
136
  const countText = $derived(hasCount(count) ? formatCount(count, countMax) : '');
@@ -137,12 +146,19 @@
137
146
  const ariaLabel = $derived(rest['aria-label'] ? withCount(rest['aria-label']) : undefined);
138
147
  $effect(() => {
139
148
  if (!el || collapseLabel === 'never' || explicitName) return;
140
- const labels = Array.from(el.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
141
- .filter(Boolean)
142
- .join(' ');
143
- const text = labels && withCount(labels);
144
- if (text) el.setAttribute('aria-label', text);
145
- else el.removeAttribute('aria-label');
149
+ const node = el;
150
+ const sync = () => {
151
+ const labels = Array.from(node.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
152
+ .filter(Boolean)
153
+ .join(' ');
154
+ const text = labels && withCount(labels);
155
+ if (text) node.setAttribute('aria-label', text);
156
+ else node.removeAttribute('aria-label');
157
+ };
158
+ sync();
159
+ const observer = new MutationObserver(sync);
160
+ observer.observe(node, { childList: true, characterData: true, subtree: true });
161
+ return () => observer.disconnect();
146
162
  });
147
163
  </script>
148
164
 
@@ -188,7 +204,7 @@
188
204
  class:btn-counted={counted}
189
205
  class:btn-stacked={stacked}
190
206
  class:btn-hinted={keyHint !== undefined}
191
- onclick={onclick}
207
+ onclick={handleClick}
192
208
  >
193
209
  {#if loading}<span class="btn-spinner" aria-hidden="true"></span>{/if}
194
210
  {#if glyph !== undefined}
@@ -65,6 +65,7 @@
65
65
  tap = false,
66
66
  interactive = false,
67
67
  as = 'div',
68
+ type = 'button',
68
69
  padding = 'md',
69
70
  surface = 'base',
70
71
  border = 'solid',
@@ -147,6 +148,7 @@
147
148
  style={`${stackStyle}${style}`}
148
149
  role={interactive && !native ? 'button' : undefined}
149
150
  tabindex={interactive && !native ? 0 : undefined}
151
+ type={as === 'button' ? type : undefined}
150
152
  onclick={handleClick}
151
153
  onkeydown={handleKeydown}
152
154
  {...rest}
@@ -53,7 +53,7 @@
53
53
  </span>
54
54
  {:else}
55
55
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
56
- <span class="dot {klass}" class:glow class:ring style="--dot-color:{resolved}" data-tsu="Dot" role={interactive ? 'img' : undefined} tabindex={interactive ? 0 : undefined} {...rest}></span>
56
+ <span class="dot {klass}" class:glow class:ring style="--dot-color:{resolved}" data-tsu="Dot" role={interactive ? 'img' : undefined} tabindex={interactive ? 0 : undefined} aria-label={interactive && !color ? status : undefined} {...rest}></span>
57
57
  {/if}
58
58
 
59
59
  <style>
@@ -45,11 +45,17 @@
45
45
  play: '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" />',
46
46
  pause: '<rect x="14" y="3" width="5" height="18" rx="1" /><rect x="5" y="3" width="5" height="18" rx="1" />',
47
47
  stop: '<rect width="18" height="18" x="3" y="3" rx="2" />',
48
+ volume: '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />',
49
+ 'volume-low': '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" /><path d="M16 9a5 5 0 0 1 0 6" />',
50
+ 'volume-high': '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" /><path d="M16 9a5 5 0 0 1 0 6" /><path d="M19.364 18.364a9 9 0 0 0 0-12.728" />',
51
+ 'volume-mute': '<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" /><path d="m22 9-6 6" /><path d="m16 9 6 6" />',
52
+ 'volume-off': '<path d="M16 9a5 5 0 0 1 .95 2.293" /><path d="M19.364 5.636a9 9 0 0 1 1.889 9.96" /><path d="m2 2 20 20" /><path d="m7 7-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11" /><path d="M9.828 4.172A.686.686 0 0 1 11 4.657v.686" />',
48
53
 
49
54
  // — media types —
50
55
  music: '<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />',
51
56
  tv: '<path d="m17 2-5 5-5-5" /><rect width="20" height="15" x="2" y="7" rx="2" />',
52
57
  film: '<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M7 3v18" /><path d="M3 7.5h4" /><path d="M3 12h18" /><path d="M3 16.5h4" /><path d="M17 3v18" /><path d="M17 7.5h4" /><path d="M17 16.5h4" />',
58
+ video: '<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /><rect x="2" y="6" width="14" height="12" rx="2" />',
53
59
  disc: '<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="2" />',
54
60
 
55
61
  // — files / content —
@@ -68,6 +74,7 @@
68
74
  unlink: '<path d="m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71" /><path d="m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71" /><line x1="8" x2="8" y1="2" y2="5" /><line x1="2" x2="5" y1="8" y2="8" /><line x1="16" x2="16" y1="19" y2="22" /><line x1="19" x2="22" y1="16" y2="16" />',
69
75
  'text-cursor': '<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1" /><path d="M7 22h1a4 4 0 0 0 4-4" /><path d="M7 2h1a4 4 0 0 1 4 4" />',
70
76
  tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" /><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />',
77
+ map: '<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" /><path d="M15 5.764v15" /><path d="M9 3.236v15" />',
71
78
  pin: '<path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />',
72
79
  'pin-off': '<path d="M12 17v5" /><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" /><path d="m2 2 20 20" /><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11" />',
73
80
  'life-buoy': '<circle cx="12" cy="12" r="10" /><path d="m4.93 4.93 4.24 4.24" /><path d="m14.83 9.17 4.24-4.24" /><path d="m14.83 14.83 4.24 4.24" /><path d="m9.17 14.83-4.24 4.24" /><circle cx="12" cy="12" r="4" />',
@@ -34,9 +34,15 @@ declare const ICONS: {
34
34
  readonly play: "<path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\" />";
35
35
  readonly pause: "<rect x=\"14\" y=\"3\" width=\"5\" height=\"18\" rx=\"1\" /><rect x=\"5\" y=\"3\" width=\"5\" height=\"18\" rx=\"1\" />";
36
36
  readonly stop: "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />";
37
+ readonly volume: "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" />";
38
+ readonly 'volume-low': "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" /><path d=\"M16 9a5 5 0 0 1 0 6\" />";
39
+ readonly 'volume-high': "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" /><path d=\"M16 9a5 5 0 0 1 0 6\" /><path d=\"M19.364 18.364a9 9 0 0 0 0-12.728\" />";
40
+ readonly 'volume-mute': "<path d=\"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z\" /><path d=\"m22 9-6 6\" /><path d=\"m16 9 6 6\" />";
41
+ readonly 'volume-off': "<path d=\"M16 9a5 5 0 0 1 .95 2.293\" /><path d=\"M19.364 5.636a9 9 0 0 1 1.889 9.96\" /><path d=\"m2 2 20 20\" /><path d=\"m7 7-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11\" /><path d=\"M9.828 4.172A.686.686 0 0 1 11 4.657v.686\" />";
37
42
  readonly music: "<path d=\"M9 18V5l12-2v13\" /><circle cx=\"6\" cy=\"18\" r=\"3\" /><circle cx=\"18\" cy=\"16\" r=\"3\" />";
38
43
  readonly tv: "<path d=\"m17 2-5 5-5-5\" /><rect width=\"20\" height=\"15\" x=\"2\" y=\"7\" rx=\"2\" />";
39
44
  readonly film: "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" /><path d=\"M7 3v18\" /><path d=\"M3 7.5h4\" /><path d=\"M3 12h18\" /><path d=\"M3 16.5h4\" /><path d=\"M17 3v18\" /><path d=\"M17 7.5h4\" /><path d=\"M17 16.5h4\" />";
45
+ readonly video: "<path d=\"m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5\" /><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\" />";
40
46
  readonly disc: "<circle cx=\"12\" cy=\"12\" r=\"10\" /><circle cx=\"12\" cy=\"12\" r=\"2\" />";
41
47
  readonly file: "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /><path d=\"M14 2v5a1 1 0 0 0 1 1h5\" />";
42
48
  readonly 'file-text': "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /><path d=\"M14 2v5a1 1 0 0 0 1 1h5\" /><path d=\"M10 9H8\" /><path d=\"M16 13H8\" /><path d=\"M16 17H8\" />";
@@ -51,6 +57,7 @@ declare const ICONS: {
51
57
  readonly unlink: "<path d=\"m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71\" /><path d=\"m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71\" /><line x1=\"8\" x2=\"8\" y1=\"2\" y2=\"5\" /><line x1=\"2\" x2=\"5\" y1=\"8\" y2=\"8\" /><line x1=\"16\" x2=\"16\" y1=\"19\" y2=\"22\" /><line x1=\"19\" x2=\"22\" y1=\"16\" y2=\"16\" />";
52
58
  readonly 'text-cursor': "<path d=\"M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1\" /><path d=\"M7 22h1a4 4 0 0 0 4-4\" /><path d=\"M7 2h1a4 4 0 0 1 4 4\" />";
53
59
  readonly tag: "<path d=\"M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z\" /><circle cx=\"7.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />";
60
+ readonly map: "<path d=\"M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z\" /><path d=\"M15 5.764v15\" /><path d=\"M9 3.236v15\" />";
54
61
  readonly pin: "<path d=\"M12 17v5\" /><path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\" />";
55
62
  readonly 'pin-off': "<path d=\"M12 17v5\" /><path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\" /><path d=\"m2 2 20 20\" /><path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\" />";
56
63
  readonly 'life-buoy': "<circle cx=\"12\" cy=\"12\" r=\"10\" /><path d=\"m4.93 4.93 4.24 4.24\" /><path d=\"m14.83 9.17 4.24-4.24\" /><path d=\"m14.83 14.83 4.24 4.24\" /><path d=\"m9.17 14.83-4.24 4.24\" /><circle cx=\"12\" cy=\"12\" r=\"4\" />";
@@ -18,43 +18,46 @@
18
18
  typeof navigator !== 'undefined' &&
19
19
  /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '');
20
20
 
21
- function label(token: string): string {
21
+ type Key = { glyph: string; name: string };
22
+ const key = (glyph: string, name = glyph): Key => ({ glyph, name });
23
+
24
+ function label(token: string): Key {
22
25
  const t = token.trim().toLowerCase();
23
26
  switch (t) {
24
27
  case 'mod':
25
- return isMac ? '⌘' : 'Ctrl';
28
+ return isMac ? key('⌘', 'Command') : key('Ctrl', 'Control');
26
29
  case 'ctrl':
27
30
  case 'control':
28
- return 'Ctrl';
31
+ return key('Ctrl', 'Control');
29
32
  case 'meta':
30
33
  case 'cmd':
31
34
  case 'command':
32
- return '⌘';
35
+ return key('⌘', 'Command');
33
36
  case 'alt':
34
37
  case 'option':
35
- return isMac ? '⌥' : 'Alt';
38
+ return isMac ? key('⌥', 'Option') : key('Alt');
36
39
  case 'shift':
37
- return '⇧';
40
+ return key('⇧', 'Shift');
38
41
  case 'enter':
39
42
  case 'return':
40
- return '↵';
43
+ return key('↵', 'Enter');
41
44
  case 'esc':
42
45
  case 'escape':
43
- return 'Esc';
46
+ return key('Esc', 'Escape');
44
47
  case 'tab':
45
- return 'Tab';
48
+ return key('Tab');
46
49
  case 'space':
47
- return 'Space';
50
+ return key('Space');
48
51
  case 'up':
49
- return '↑';
52
+ return key('↑', 'Up arrow');
50
53
  case 'down':
51
- return '↓';
54
+ return key('↓', 'Down arrow');
52
55
  case 'left':
53
- return '←';
56
+ return key('←', 'Left arrow');
54
57
  case 'right':
55
- return '→';
58
+ return key('→', 'Right arrow');
56
59
  default:
57
- return t.charAt(0).toUpperCase() + t.slice(1);
60
+ return key(t.charAt(0).toUpperCase() + t.slice(1));
58
61
  }
59
62
  }
60
63
 
@@ -73,7 +76,7 @@
73
76
  >
74
77
  {#each tokens as token, i (i)}
75
78
  {#if i > 0}<span class="kbd-sep" aria-hidden="true">+</span>{/if}
76
- <kbd class="kbd">{token}</kbd>
79
+ <kbd class="kbd">{#if token.name === token.glyph}{token.glyph}{:else}<span aria-hidden="true">{token.glyph}</span><span class="kbd-name">{token.name}</span>{/if}</kbd>
77
80
  {/each}
78
81
  </span>
79
82
 
@@ -111,6 +114,15 @@
111
114
  background: var(--bg-elevated-2);
112
115
  color: inherit;
113
116
  }
117
+ .kbd-name {
118
+ position: absolute;
119
+ width: 1px;
120
+ height: 1px;
121
+ margin: -1px;
122
+ overflow: hidden;
123
+ clip: rect(0, 0, 0, 0);
124
+ white-space: nowrap;
125
+ }
114
126
  .kbd-sep {
115
127
  font-size: 0.85em;
116
128
  color: var(--text-faint);
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- // Inline text link — underlined accent text with no button chrome. Renders an
4
- // <a> when `href` is given, otherwise a <button> (for in-page actions that
5
- // read as links, e.g. "Open ChatGPT again").
3
+ // Inline text link — accent text with no button chrome. Renders an <a> when
4
+ // `href` is given, otherwise a <button> (for in-page actions that read as
5
+ // links, e.g. "Open ChatGPT again").
6
+ // Underline: the default `hover` is for navigation, lists and entity titles;
7
+ // use `always` for links inside running prose.
6
8
  import type { Snippet } from 'svelte';
7
9
 
8
10
  type Own = {
@@ -20,7 +22,7 @@
20
22
  let {
21
23
  href,
22
24
  tone = 'accent',
23
- underline = 'always',
25
+ underline = 'hover',
24
26
  align = 'start',
25
27
  class: klass = '',
26
28
  children,
@@ -1,3 +1,7 @@
1
+ <script lang="ts" module>
2
+ const open: object[] = [];
3
+ </script>
4
+
1
5
  <script lang="ts">
2
6
  import { browser } from '../../env';
3
7
 
@@ -38,15 +42,27 @@
38
42
  return () => query.removeEventListener('change', sync);
39
43
  });
40
44
 
45
+ let el = $state<HTMLButtonElement | null>(null);
46
+
47
+ // Only the topmost scrim reacts, and never to an Escape aimed at a dialog
48
+ // or popover layered above it.
41
49
  $effect(() => {
42
50
  if (!browser) return;
51
+ const self = {};
52
+ open.push(self);
43
53
  const onKeydown = (event: KeyboardEvent) => {
44
54
  if (event.key !== 'Escape' || event.defaultPrevented) return;
55
+ if (open.at(-1) !== self) return;
56
+ const layer = (event.target as Element | null)?.closest?.('dialog[open], [popover]');
57
+ if (layer && !(el && layer.contains(el))) return;
45
58
  event.preventDefault();
46
59
  onclose?.();
47
60
  };
48
61
  document.addEventListener('keydown', onKeydown);
49
- return () => document.removeEventListener('keydown', onKeydown);
62
+ return () => {
63
+ document.removeEventListener('keydown', onKeydown);
64
+ open.splice(open.indexOf(self), 1);
65
+ };
50
66
  });
51
67
  </script>
52
68
 
@@ -54,6 +70,7 @@
54
70
  <button
55
71
  type="button"
56
72
  class="scrim {klass}"
73
+ bind:this={el}
57
74
  style={styleProp}
58
75
  style:z-index={z}
59
76
  aria-label={label}
@@ -15,7 +15,7 @@
15
15
  step = 1,
16
16
  label,
17
17
  showValue = false,
18
- format = (v: number) => String(v),
18
+ format: formatProp,
19
19
  id: idProp,
20
20
  'aria-describedby': ariaDescribedby,
21
21
  'aria-invalid': ariaInvalid,
@@ -53,6 +53,10 @@
53
53
  const fallbackId = $props.id();
54
54
  const id = $derived(idProp ?? field?.id ?? fallbackId);
55
55
  const isInvalid = $derived(invalid || !!field?.invalid);
56
+ const format = $derived(formatProp ?? ((v: number) => String(v)));
57
+ const valueText = $derived(
58
+ marks.find((m) => m.value === Number(value))?.label ?? formatProp?.(Number(value))
59
+ );
56
60
 
57
61
  $effect(() => warnUnlabelled(el, 'Slider'));
58
62
 
@@ -78,6 +82,7 @@
78
82
  {step}
79
83
  bind:value
80
84
  aria-label={label}
85
+ aria-valuetext={valueText}
81
86
  {...rest}
82
87
  aria-describedby={ariaDescribedby ?? field?.describedBy}
83
88
  aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
@@ -212,6 +217,11 @@
212
217
  outline: var(--focus-ring);
213
218
  outline-offset: 2px;
214
219
  }
220
+ @media (pointer: coarse) {
221
+ input[type='range'] {
222
+ min-height: var(--touch-target-compact);
223
+ }
224
+ }
215
225
  input[type='range']:disabled {
216
226
  opacity: 0.45;
217
227
  cursor: not-allowed;
@@ -111,4 +111,16 @@
111
111
  outline: 2px solid var(--accent);
112
112
  outline-offset: 3px;
113
113
  }
114
+ @media (pointer: coarse) {
115
+ .interactive {
116
+ position: relative;
117
+ }
118
+ .interactive::before {
119
+ content: '';
120
+ position: absolute;
121
+ inset-block: var(--touch-slab);
122
+ inset-inline-start: var(--touch-slab);
123
+ inset-inline-end: 0;
124
+ }
125
+ }
114
126
  </style>