@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
@@ -9,9 +9,10 @@
9
9
  // • an optional action area (slot via `action`, or a built-in button when
10
10
  // `actionLabel`+`onAction` are passed)
11
11
  // Everything is token-driven. `size` picks the layout: `default`, `compact`
12
- // (tighter spacing), or `inline` (a single muted line). `loading` swaps the
13
- // chip for a spinner and marks the root busy.
12
+ // (tighter spacing), or `inline` (a single muted line). `loading` marks the
13
+ // root busy at once and swaps the chip for a spinner once `pending()` lets it.
14
14
  import type { Snippet } from 'svelte';
15
+ import { pending } from '../../pending.svelte';
15
16
  import Button from '../atoms/Button.svelte';
16
17
  import Icon, { type IconName } from '../atoms/Icon.svelte';
17
18
  import Spinner from '../atoms/Spinner.svelte';
@@ -29,6 +30,8 @@
29
30
  compact = false,
30
31
  size,
31
32
  loading = false,
33
+ loadingDelay,
34
+ loadingMin,
32
35
  class: klass = '',
33
36
  // Raw SVG markup for a custom glyph — passed straight to `Icon`.
34
37
  iconChildren,
@@ -46,6 +49,9 @@
46
49
  compact?: boolean;
47
50
  size?: 'inline' | 'compact' | 'default';
48
51
  loading?: boolean;
52
+ /** Show delay / minimum visible time for the spinner (ms); 0 follows `loading` at once. */
53
+ loadingDelay?: number;
54
+ loadingMin?: number;
49
55
  class?: string;
50
56
  iconChildren?: Snippet;
51
57
  action?: Snippet;
@@ -53,18 +59,19 @@
53
59
  } = $props();
54
60
 
55
61
  const sz = $derived(size ?? (compact ? 'compact' : 'default'));
62
+ const spin = pending(() => loading, () => ({ delay: loadingDelay, min: loadingMin }));
56
63
  </script>
57
64
 
58
65
  <div
59
66
  class="empty empty-{canonicalTone(tone)} {klass}"
60
67
  class:empty-compact={sz === 'compact'}
61
68
  class:empty-inline={sz === 'inline'}
62
- class:empty-loading={loading}
69
+ class:empty-loading={spin.visible}
63
70
  aria-busy={loading ? 'true' : undefined}
64
71
  data-tsu="EmptyState"
65
72
  {...rest}
66
73
  >
67
- {#if loading}
74
+ {#if spin.visible}
68
75
  <span class="empty-chip">
69
76
  <Spinner />
70
77
  </span>
@@ -161,7 +168,7 @@
161
168
  font-size: var(--fs-sm);
162
169
  }
163
170
  .empty-inline .empty-desc {
164
- max-width: none;
171
+ max-inline-size: none;
165
172
  }
166
173
  .empty-inline .empty-action {
167
174
  margin-top: 0;
@@ -175,7 +182,7 @@
175
182
  }
176
183
  .empty-desc {
177
184
  margin: 0;
178
- max-width: 32ch;
185
+ max-inline-size: var(--measure, 68ch);
179
186
  font-size: var(--fs-sm);
180
187
  color: var(--text-muted);
181
188
  line-height: var(--lh-normal);
@@ -12,6 +12,9 @@ type $$ComponentProps = {
12
12
  compact?: boolean;
13
13
  size?: 'inline' | 'compact' | 'default';
14
14
  loading?: boolean;
15
+ /** Show delay / minimum visible time for the spinner (ms); 0 follows `loading` at once. */
16
+ loadingDelay?: number;
17
+ loadingMin?: number;
15
18
  class?: string;
16
19
  iconChildren?: Snippet;
17
20
  action?: Snippet;
@@ -97,7 +97,7 @@
97
97
  }
98
98
  .hint {
99
99
  font-size: var(--fs-xs);
100
- color: var(--text-faint);
100
+ color: var(--text-muted);
101
101
  }
102
102
  .error {
103
103
  font-size: var(--fs-xs);
@@ -549,6 +549,7 @@
549
549
  background: transparent;
550
550
  color: var(--text);
551
551
  font-family: var(--font-numeric);
552
+ font-variant-numeric: tabular-nums;
552
553
  font-size: var(--control-fs-md);
553
554
  outline: none;
554
555
  min-width: 0;
@@ -576,7 +577,7 @@
576
577
  border: 0;
577
578
  background: transparent;
578
579
  font-size: var(--fi-icon);
579
- color: var(--text-faint);
580
+ color: var(--text-muted);
580
581
  cursor: pointer;
581
582
  padding: var(--sp-1);
582
583
  border-radius: var(--r-sm);
@@ -632,7 +633,7 @@
632
633
  font-size: var(--fs-xs);
633
634
  text-transform: uppercase;
634
635
  letter-spacing: 0.06em;
635
- color: var(--text-faint);
636
+ color: var(--text-muted);
636
637
  padding: var(--sp-2) var(--sp-2) var(--sp-1);
637
638
  }
638
639
  .fi__opt {
@@ -659,12 +660,13 @@
659
660
  font-weight: var(--fw-medium);
660
661
  }
661
662
  .fi__opt-hint {
662
- color: var(--text-faint);
663
+ color: var(--text-muted);
663
664
  font-size: var(--fs-xs);
664
665
  }
665
666
  .fi__opt-code {
666
667
  margin-left: auto;
667
668
  font-family: var(--font-numeric);
669
+ font-variant-numeric: tabular-nums;
668
670
  font-size: var(--fs-xs);
669
671
  color: var(--text-muted);
670
672
  background: var(--bg-elevated-2);
@@ -86,6 +86,6 @@
86
86
  }
87
87
  .caption {
88
88
  font-size: var(--fs-xs);
89
- color: var(--text-faint);
89
+ color: var(--text-muted);
90
90
  }
91
91
  </style>
@@ -208,6 +208,7 @@
208
208
  align-items: baseline;
209
209
  gap: var(--sp-1);
210
210
  font-family: var(--font-numeric);
211
+ font-variant-numeric: tabular-nums;
211
212
  line-height: var(--lh-none);
212
213
  }
213
214
  .metric-num {
@@ -228,7 +229,7 @@
228
229
  }
229
230
  .metric-sub {
230
231
  font-size: var(--fs-xs);
231
- color: var(--text-faint);
232
+ color: var(--text-muted);
232
233
  line-height: var(--lh-snug);
233
234
  }
234
235
  :global(.metric-sm) .metric-num {
@@ -275,6 +276,7 @@
275
276
  align-items: baseline;
276
277
  gap: var(--sp-1) var(--sp-3);
277
278
  font-family: var(--font-numeric);
279
+ font-variant-numeric: tabular-nums;
278
280
  font-size: var(--fs-xs);
279
281
  color: var(--text-muted);
280
282
  }
@@ -271,7 +271,7 @@
271
271
  opacity: 0;
272
272
  }
273
273
  }
274
- @media (min-width: 640px) {
274
+ @media (min-width: 40rem) {
275
275
  .modal {
276
276
  align-items: center;
277
277
  padding: var(--sp-6);
@@ -309,7 +309,7 @@
309
309
  translate: 0 100%;
310
310
  }
311
311
  }
312
- @media (min-width: 640px) {
312
+ @media (min-width: 40rem) {
313
313
  .sheet {
314
314
  border-radius: var(--r-lg);
315
315
  padding-bottom: 0;
@@ -394,7 +394,7 @@
394
394
  .sheet-resize {
395
395
  display: none;
396
396
  }
397
- @media (min-width: 640px) {
397
+ @media (min-width: 40rem) {
398
398
  .sheet-resize {
399
399
  display: block;
400
400
  position: absolute;
@@ -439,7 +439,7 @@
439
439
  background: var(--accent);
440
440
  }
441
441
  }
442
- @media (min-width: 640px) and (hover: hover) and (pointer: fine) {
442
+ @media (min-width: 40rem) and (hover: hover) and (pointer: fine) {
443
443
  .sheet-resize:hover::before {
444
444
  background: color-mix(in srgb, var(--accent) 45%, transparent);
445
445
  }
@@ -6,6 +6,7 @@
6
6
  // next when the container is narrower than 24rem.
7
7
  import Text from '../atoms/Text.svelte';
8
8
  import { playSound } from '../../sound';
9
+ import { isModifiedClick } from '../../url-state.svelte';
9
10
  import IconButton from './IconButton.svelte';
10
11
 
11
12
  let {
@@ -15,6 +16,7 @@
15
16
  limit,
16
17
  total,
17
18
  onchange,
19
+ href,
18
20
  siblings = 1,
19
21
  showEdges = true,
20
22
  showRange = false,
@@ -34,6 +36,8 @@
34
36
  limit?: number;
35
37
  total?: number;
36
38
  onchange?: (page: number) => void;
39
+ /** Pages become real links (`<a aria-current="page">`): middle-click opens a page in a new tab. */
40
+ href?: (page: number) => string;
37
41
  /** Numbered pages shown on each side of the current one. */
38
42
  siblings?: number;
39
43
  /** Always show the first and last page. */
@@ -105,6 +109,9 @@
105
109
  if (offsetMode) offset = (next - 1) * (limit as number);
106
110
  onchange?.(next);
107
111
  }
112
+ function onlinkclick(e: MouseEvent, p: number) {
113
+ if (!isModifiedClick(e)) go(p);
114
+ }
108
115
  </script>
109
116
 
110
117
  <nav
@@ -121,14 +128,26 @@
121
128
  label="Previous page"
122
129
  box={size === 'sm' ? 'sm' : 'md'}
123
130
  disabled={current <= 1}
131
+ href={href?.(current - 1)}
124
132
  sound={false}
125
- onclick={() => go(current - 1)}
133
+ onclick={(e) => onlinkclick(e, current - 1)}
126
134
  />
127
135
  <ol class="pages">
128
136
  {#each items as item, i (item === ELLIPSIS ? `e${i}` : item)}
129
137
  <li>
130
138
  {#if item === ELLIPSIS}
131
139
  <span class="ellipsis" aria-hidden="true">…</span>
140
+ {:else if href}
141
+ <a
142
+ href={href(item)}
143
+ class="page"
144
+ class:on={item === current}
145
+ aria-current={item === current ? 'page' : undefined}
146
+ aria-label="Page {item}"
147
+ onclick={(e) => onlinkclick(e, item)}
148
+ >
149
+ {item}
150
+ </a>
132
151
  {:else}
133
152
  <button
134
153
  type="button"
@@ -150,8 +169,9 @@
150
169
  label="Next page"
151
170
  box={size === 'sm' ? 'sm' : 'md'}
152
171
  disabled={current >= count}
172
+ href={href?.(current + 1)}
153
173
  sound={false}
154
- onclick={() => go(current + 1)}
174
+ onclick={(e) => onlinkclick(e, current + 1)}
155
175
  />
156
176
  {#if showRange}
157
177
  <Text class="range" size="sm" numeric tone="muted">{rangeText}</Text>
@@ -186,6 +206,9 @@
186
206
  padding: 0;
187
207
  }
188
208
  .page {
209
+ display: inline-flex;
210
+ align-items: center;
211
+ justify-content: center;
189
212
  min-width: var(--box-md);
190
213
  height: var(--box-md);
191
214
  padding: 0 var(--control-px-md);
@@ -195,6 +218,7 @@
195
218
  color: var(--text-muted);
196
219
  font-size: var(--control-fs-md);
197
220
  font-variant-numeric: tabular-nums;
221
+ text-decoration: none;
198
222
  cursor: pointer;
199
223
  --page-scale-dur: var(--dur-fast);
200
224
  transition:
@@ -8,6 +8,8 @@ type $$ComponentProps = {
8
8
  limit?: number;
9
9
  total?: number;
10
10
  onchange?: (page: number) => void;
11
+ /** Pages become real links (`<a aria-current="page">`): middle-click opens a page in a new tab. */
12
+ href?: (page: number) => string;
11
13
  /** Numbered pages shown on each side of the current one. */
12
14
  siblings?: number;
13
15
  /** Always show the first and last page. */
@@ -26,6 +28,6 @@ type $$ComponentProps = {
26
28
  sound?: boolean;
27
29
  class?: string;
28
30
  };
29
- declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "offset" | "page">;
31
+ declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "page" | "offset">;
30
32
  type Pagination = ReturnType<typeof Pagination>;
31
33
  export default Pagination;
@@ -30,6 +30,7 @@
30
30
  color: var(--md-text, var(--text));
31
31
  font-size: var(--fs-sm);
32
32
  line-height: var(--lh-normal);
33
+ max-inline-size: var(--measure);
33
34
  overflow-wrap: anywhere;
34
35
  --prose-gap: var(--sp-3);
35
36
  }
@@ -277,7 +277,7 @@
277
277
  }
278
278
  .hint {
279
279
  font-size: var(--fs-xs);
280
- color: var(--text-faint);
280
+ color: var(--text-muted);
281
281
  }
282
282
 
283
283
  .rows {
@@ -324,7 +324,7 @@
324
324
  }
325
325
  .note {
326
326
  font-size: var(--fs-xs);
327
- color: var(--text-faint);
327
+ color: var(--text-muted);
328
328
  }
329
329
  .description {
330
330
  font-size: var(--fs-xs);
@@ -45,7 +45,7 @@ declare function $$render<T>(): {
45
45
  style?: string;
46
46
  };
47
47
  exports: {};
48
- bindings: "open" | "value";
48
+ bindings: "value" | "open";
49
49
  slots: {};
50
50
  events: {};
51
51
  };
@@ -53,7 +53,7 @@ declare class __sveltets_Render<T> {
53
53
  props(): ReturnType<typeof $$render<T>>['props'];
54
54
  events(): ReturnType<typeof $$render<T>>['events'];
55
55
  slots(): ReturnType<typeof $$render<T>>['slots'];
56
- bindings(): "open" | "value";
56
+ bindings(): "value" | "open";
57
57
  exports(): {};
58
58
  }
59
59
  interface $$IsomorphicComponent {
@@ -140,7 +140,7 @@
140
140
  <Heading {level} {size} class="sh-title">{text}</Heading>
141
141
  {/if}
142
142
  {#if count !== undefined}
143
- <Text tone="faint" weight="normal" numeric class="sh-count">{count}</Text>
143
+ <Text tone="muted" weight="normal" numeric class="sh-count">{count}</Text>
144
144
  {/if}
145
145
  {/snippet}
146
146
 
@@ -11,6 +11,8 @@
11
11
  icon?: import('../atoms/Icon.svelte').IconName;
12
12
  /** Greyed out, not selectable, skipped by keyboard navigation. */
13
13
  disabled?: boolean;
14
+ /** Render this segment as a real link; overrides the control's `href`. */
15
+ href?: string;
14
16
  }
15
17
  </script>
16
18
 
@@ -28,6 +30,7 @@
28
30
  import type { Attachment } from 'svelte/attachments';
29
31
  import Icon from '../atoms/Icon.svelte';
30
32
  import { playSound } from '../../sound';
33
+ import { isModifiedClick } from '../../url-state.svelte';
31
34
  import { formatSegmentCount } from './segmented-control-count.js';
32
35
  import { nextEnabledSegment } from './segmented-control-keyboard.js';
33
36
 
@@ -35,6 +38,7 @@
35
38
  options,
36
39
  value = $bindable(),
37
40
  onchange,
41
+ href,
38
42
  justify,
39
43
  wrap = false,
40
44
  // `icon` packs the segments into compact square buttons (label hidden as an
@@ -60,6 +64,8 @@
60
64
  value?: string;
61
65
  /** Fires after a selection (click or keyboard). */
62
66
  onchange?: (value: string) => void;
67
+ /** Segments become real links (`<a aria-current>`): middle-click opens the segment's URL in a new tab. */
68
+ href?: (value: string) => string;
63
69
  /** Pin the control to the start/end of a flex row. */
64
70
  justify?: 'start' | 'end';
65
71
  /** Allow segments to wrap onto multiple lines. */
@@ -100,6 +106,7 @@
100
106
  options.find((o) => o.value === value && !o.disabled) ?? options.find((o) => !o.disabled)
101
107
  );
102
108
  const segId = (val: string) => `${baseId}-${val}`;
109
+ const linkTo = (o: SegmentOption) => (o.disabled ? undefined : (o.href ?? href?.(o.value)));
103
110
 
104
111
  // A pill wider than its container falls back to the `scroll` layout; the
105
112
  // class is dropped again once the row fits so the touch slab stays unclipped.
@@ -185,6 +192,16 @@
185
192
  }
186
193
  </script>
187
194
 
195
+ {#snippet content(o: SegmentOption, selected: boolean)}
196
+ {#if optionSnippet}
197
+ {@render optionSnippet(o, selected)}
198
+ {:else}
199
+ {#if o.icon}<Icon name={o.icon} />{/if}
200
+ {#if o.label}<span class="seg-label">{o.label}</span>{/if}
201
+ {#if o.count !== undefined}<span class="seg-count">{formatSegmentCount(o.count)}</span>{/if}
202
+ {/if}
203
+ {/snippet}
204
+
188
205
  <div
189
206
  bind:this={listEl}
190
207
  role="radiogroup"
@@ -209,27 +226,42 @@
209
226
  <span bind:this={thumb} class="seg-thumb" aria-hidden="true"></span>
210
227
  {#each options as o (o.value)}
211
228
  {@const selected = value === o.value}
212
- <button
213
- type="button"
214
- id={segId(o.value)}
215
- role="radio"
216
- aria-checked={selected}
217
- aria-label={variant === 'icon' && !o.label ? o.value : o.label}
218
- tabindex={stop?.value === o.value ? 0 : -1}
219
- class="seg-item"
220
- class:has-label={!!o.label}
221
- class:selected
222
- disabled={o.disabled}
223
- onclick={() => select(o.value)}
224
- >
225
- {#if optionSnippet}
226
- {@render optionSnippet(o, selected)}
227
- {:else}
228
- {#if o.icon}<Icon name={o.icon} />{/if}
229
- {#if o.label}<span class="seg-label">{o.label}</span>{/if}
230
- {#if o.count !== undefined}<span class="seg-count">{formatSegmentCount(o.count)}</span>{/if}
231
- {/if}
232
- </button>
229
+ {@const link = linkTo(o)}
230
+ {#if link !== undefined}
231
+ <a
232
+ href={link}
233
+ id={segId(o.value)}
234
+ role="radio"
235
+ aria-checked={selected}
236
+ aria-current={selected ? 'page' : undefined}
237
+ aria-label={variant === 'icon' && !o.label ? o.value : o.label}
238
+ tabindex={stop?.value === o.value ? 0 : -1}
239
+ class="seg-item"
240
+ class:has-label={!!o.label}
241
+ class:selected
242
+ onclick={(e) => {
243
+ if (!isModifiedClick(e)) select(o.value);
244
+ }}
245
+ >
246
+ {@render content(o, selected)}
247
+ </a>
248
+ {:else}
249
+ <button
250
+ type="button"
251
+ id={segId(o.value)}
252
+ role="radio"
253
+ aria-checked={selected}
254
+ aria-label={variant === 'icon' && !o.label ? o.value : o.label}
255
+ tabindex={stop?.value === o.value ? 0 : -1}
256
+ class="seg-item"
257
+ class:has-label={!!o.label}
258
+ class:selected
259
+ disabled={o.disabled}
260
+ onclick={() => select(o.value)}
261
+ >
262
+ {@render content(o, selected)}
263
+ </button>
264
+ {/if}
233
265
  {/each}
234
266
  </div>
235
267
 
@@ -354,6 +386,7 @@
354
386
  line-height: var(--lh-none);
355
387
  --icon-size: var(--control-icon-md);
356
388
  white-space: nowrap;
389
+ text-decoration: none;
357
390
  cursor: pointer;
358
391
  transition:
359
392
  background var(--dur-fast) var(--ease),
@@ -455,7 +488,7 @@
455
488
  padding: 0 var(--sp-1-5);
456
489
  border-radius: var(--r-pill);
457
490
  background: var(--bg-elevated-2);
458
- color: var(--text-faint);
491
+ color: var(--text-muted);
459
492
  font-size: var(--fs-xs);
460
493
  font-weight: var(--fw-semibold);
461
494
  font-variant-numeric: tabular-nums;
@@ -10,6 +10,8 @@ export interface SegmentOption {
10
10
  icon?: import('../atoms/Icon.svelte').IconName;
11
11
  /** Greyed out, not selectable, skipped by keyboard navigation. */
12
12
  disabled?: boolean;
13
+ /** Render this segment as a real link; overrides the control's `href`. */
14
+ href?: string;
13
15
  }
14
16
  import type { ControlSize } from '../../size';
15
17
  import type { Snippet } from 'svelte';
@@ -18,6 +20,8 @@ type $$ComponentProps = {
18
20
  value?: string;
19
21
  /** Fires after a selection (click or keyboard). */
20
22
  onchange?: (value: string) => void;
23
+ /** Segments become real links (`<a aria-current>`): middle-click opens the segment's URL in a new tab. */
24
+ href?: (value: string) => string;
21
25
  /** Pin the control to the start/end of a flex row. */
22
26
  justify?: 'start' | 'end';
23
27
  /** Allow segments to wrap onto multiple lines. */
@@ -656,7 +656,7 @@
656
656
  .sl-ordinal {
657
657
  flex: none;
658
658
  min-width: 1.5em;
659
- color: var(--text-faint);
659
+ color: var(--text-muted);
660
660
  font-size: var(--fs-sm);
661
661
  font-variant-numeric: tabular-nums;
662
662
  text-align: end;
@@ -65,7 +65,7 @@
65
65
  color: var(--text-muted);
66
66
  }
67
67
  .status-bar.idle {
68
- color: var(--text-faint);
68
+ color: var(--text-muted);
69
69
  }
70
70
  .status-bar :global(.status-glyph) {
71
71
  flex: none;
@@ -85,7 +85,7 @@
85
85
  display: inline-flex;
86
86
  align-items: center;
87
87
  gap: var(--sp-2);
88
- color: var(--text-faint);
88
+ color: var(--text-muted);
89
89
  font-variant-numeric: tabular-nums;
90
90
  white-space: nowrap;
91
91
  }
@@ -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 {