@urbicon-ui/blocks 6.23.0 → 6.25.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 (68) hide show
  1. package/README.md +14 -0
  2. package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
  3. package/dist/components/Calendar/calendar.variants.js +5 -3
  4. package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
  5. package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
  6. package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
  7. package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
  8. package/dist/components/CommandPalette/index.d.ts +19 -0
  9. package/dist/components/CompositionBar/CompositionBar.svelte +1 -1
  10. package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
  11. package/dist/components/Planner/planner.variants.js +1 -1
  12. package/dist/components/Sankey/sankey.variants.js +1 -1
  13. package/dist/components/Sparkline/Sparkline.svelte +10 -4
  14. package/dist/components/Sparkline/index.d.ts +9 -1
  15. package/dist/internal/charts/variants.js +2 -2
  16. package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
  17. package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
  18. package/dist/internal/date-grid/date-grid.svelte.js +63 -7
  19. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  20. package/dist/primitives/Avatar/index.d.ts +1 -1
  21. package/dist/primitives/Badge/Badge.svelte +8 -2
  22. package/dist/primitives/Badge/index.d.ts +67 -32
  23. package/dist/primitives/Button/Button.svelte +12 -25
  24. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  25. package/dist/primitives/Checkbox/Checkbox.svelte +1 -1
  26. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  27. package/dist/primitives/Collapsible/index.d.ts +13 -2
  28. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  29. package/dist/primitives/ConfirmDialog/index.d.ts +15 -3
  30. package/dist/primitives/Dialog/Dialog.svelte +31 -8
  31. package/dist/primitives/Drawer/Drawer.svelte +29 -7
  32. package/dist/primitives/Input/Input.svelte +7 -1
  33. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  34. package/dist/primitives/Menu/Menu.svelte +8 -0
  35. package/dist/primitives/Menu/index.d.ts +11 -13
  36. package/dist/primitives/Pagination/Pagination.svelte +28 -15
  37. package/dist/primitives/Popover/Popover.svelte +107 -4
  38. package/dist/primitives/Popover/index.d.ts +15 -2
  39. package/dist/primitives/Popover/index.js +1 -1
  40. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  41. package/dist/primitives/Popover/popover.variants.js +35 -0
  42. package/dist/primitives/Progress/progress.variants.js +1 -1
  43. package/dist/primitives/RadioGroup/RadioGroup.svelte +6 -2
  44. package/dist/primitives/RadioGroup/RadioItem.svelte +1 -1
  45. package/dist/primitives/RadioGroup/radioGroup.variants.js +1 -1
  46. package/dist/primitives/Select/index.d.ts +8 -1
  47. package/dist/primitives/Slider/Slider.svelte +32 -3
  48. package/dist/primitives/Stepper/stepper.variants.js +1 -1
  49. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  50. package/dist/primitives/Toast/Toaster.svelte +81 -7
  51. package/dist/primitives/Toggle/Toggle.svelte +6 -2
  52. package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
  53. package/dist/style/foundation.css +30 -3
  54. package/dist/style/interaction.css +19 -2
  55. package/dist/style/semantic.css +72 -1
  56. package/dist/style/themes/neutral.css +10 -1
  57. package/dist/style/themes/ocean.css +5 -1
  58. package/dist/utils/compose-handlers.d.ts +72 -0
  59. package/dist/utils/compose-handlers.js +65 -0
  60. package/dist/utils/figma-token-export.js +1 -1
  61. package/dist/utils/guide.svelte.js +92 -29
  62. package/dist/utils/index.d.ts +1 -1
  63. package/dist/utils/index.js +1 -1
  64. package/dist/utils/overlay-tokens.d.ts +17 -0
  65. package/dist/utils/overlay-tokens.js +30 -0
  66. package/dist/utils/use-floating-panel.svelte.js +9 -3
  67. package/dist/utils/variants.js +5 -1
  68. package/package.json +3 -3
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { useBlocksI18n } from '../..';
3
+ import { untrack } from 'svelte';
3
4
  import { fly } from 'svelte/transition';
4
5
  import { getOverlayMotion } from '../../utils';
5
6
  import { toastVariants, type ToastVariants } from './toast.variants';
@@ -88,6 +89,42 @@
88
89
 
89
90
  const visibleToasts = $derived(toaster.toasts.slice(-max));
90
91
 
92
+ // Stranded-pause guard. A toast removed from under the cursor (e.g. its own
93
+ // close button) does not reliably fire `pointerout`, so `pointerInside` can
94
+ // stay stuck `true` and freeze every remaining timer — worst case a fresh
95
+ // toast is then born paused and never counts down. Rather than trust the flag,
96
+ // when the rendered stack SHRINKS we re-derive containment from the real cursor
97
+ // position. The cursor is tracked from the region's own bubbling pointer events
98
+ // (the container is `pointer-events-none`, so these fire only over a toast
99
+ // child, which is exactly when the coords matter).
100
+ let containerEl: HTMLElement | undefined;
101
+ let pointerX = 0;
102
+ let pointerY = 0;
103
+ let prevVisibleCount = 0;
104
+
105
+ function reconcilePointerInside() {
106
+ // Only a stranded `true` needs rescuing; SSR-safe — effects/handlers never
107
+ // run on the server, and `document` is guarded regardless.
108
+ if (typeof document === 'undefined' || !pointerInside || !containerEl) return;
109
+ const hit = document.elementFromPoint(pointerX, pointerY);
110
+ const toastEl = (hit?.closest('[data-toast-id]') ?? null) as HTMLElement | null;
111
+ // Inside only if the cursor sits over a toast that is (a) in THIS region and
112
+ // (b) still live — not the just-removed one, whose node lingers through its
113
+ // fly-out outro.
114
+ pointerInside =
115
+ !!toastEl &&
116
+ containerEl.contains(toastEl) &&
117
+ visibleToasts.some((t) => t.id === toastEl.dataset.toastId);
118
+ }
119
+
120
+ $effect(() => {
121
+ const count = visibleToasts.length;
122
+ untrack(() => {
123
+ if (count < prevVisibleCount) reconcilePointerInside();
124
+ prevVisibleCount = count;
125
+ });
126
+ });
127
+
91
128
  const styles = $derived(
92
129
  unstyled
93
130
  ? {
@@ -151,10 +188,19 @@
151
188
  </script>
152
189
 
153
190
  <div
191
+ bind:this={containerEl}
154
192
  class={[slot('container'), className].filter(Boolean).join(' ')}
155
193
  aria-live="polite"
156
194
  aria-relevant="additions removals"
157
- onpointerover={() => (pointerInside = true)}
195
+ onpointerover={(e) => {
196
+ pointerX = e.clientX;
197
+ pointerY = e.clientY;
198
+ pointerInside = true;
199
+ }}
200
+ onpointermove={(e) => {
201
+ pointerX = e.clientX;
202
+ pointerY = e.clientY;
203
+ }}
158
204
  onpointerout={(e) => {
159
205
  if (leftRegion(e)) pointerInside = false;
160
206
  }}
@@ -166,7 +212,13 @@
166
212
  >
167
213
  {#each visibleToasts as toast (toast.id)}
168
214
  {@const IntentIcon = INTENT_ICON_MAP[toast.intent] ?? INTENT_ICON_MAP.neutral}
169
- <div class={slot('toast', toast.intent)} role="alert" transition:fly={flyParams()}>
215
+ <div
216
+ class={slot('toast', toast.intent)}
217
+ role="alert"
218
+ aria-atomic="true"
219
+ data-toast-id={toast.id}
220
+ transition:fly={flyParams()}
221
+ >
170
222
  {#if toast.loading}
171
223
  <span class={slot('icon', toast.intent)}><Spinner size="sm" /></span>
172
224
  {:else}
@@ -216,13 +268,17 @@
216
268
  {/if}
217
269
 
218
270
  {#if toast.showProgress && Number.isFinite(toast.duration) && toast.duration > 0}
219
- <!-- Duration comes from the toast itself (the real countdown), not a token.
220
- `animation-play-state` tracks the store's paused flag so the bar freezes
221
- with the timer on hover/focus and resumes in place — changing only the
271
+ <!-- The countdown animation lives in the `<style>` rule below (not inline)
272
+ so `@media (prefers-reduced-motion: reduce)` can reach it an inline
273
+ `animation` shorthand is invisible to media queries. Only the real
274
+ per-toast duration is passed inline, as a custom property.
275
+ `animation-play-state` (also inline, bound to the store's paused flag)
276
+ freezes the bar with the timer on hover/focus and resumes it in place;
277
+ inline wins over the rule's implicit `running`, and toggling only the
222
278
  play-state never restarts the animation. -->
223
279
  <div
224
- class={slot('progress', toast.intent)}
225
- style="animation: blocks-toast-progress {toast.duration}ms linear forwards; animation-play-state: {toaster.paused
280
+ class={[slot('progress', toast.intent), 'blocks-toast-progress-bar']}
281
+ style="--toast-progress-duration: {toast.duration}ms; animation-play-state: {toaster.paused
226
282
  ? 'paused'
227
283
  : 'running'};"
228
284
  ></div>
@@ -240,4 +296,22 @@
240
296
  width: 0%;
241
297
  }
242
298
  }
299
+
300
+ /* The countdown bar animates via this class (not an inline `animation`) so the
301
+ reduced-motion query below can reach it; the per-toast duration arrives as
302
+ the `--toast-progress-duration` custom property. `:global` keeps it off
303
+ Svelte's scoping, consistent with the global keyframe above. */
304
+ :global(.blocks-toast-progress-bar) {
305
+ animation: blocks-toast-progress var(--toast-progress-duration) linear forwards;
306
+ }
307
+
308
+ /* A countdown bar carries no information once it can't animate, so under
309
+ reduced motion we hide it outright rather than leave it frozen. This mirrors
310
+ interaction.css collapsing the motion tokens; the auto-dismiss timing itself
311
+ is unaffected. */
312
+ @media (prefers-reduced-motion: reduce) {
313
+ :global(.blocks-toast-progress-bar) {
314
+ display: none;
315
+ }
316
+ }
243
317
  </style>
@@ -29,6 +29,7 @@
29
29
  preset,
30
30
  withBorder = false,
31
31
  'aria-describedby': ariaDescribedby,
32
+ 'aria-label': ariaLabel,
32
33
  ...restProps
33
34
  }: ToggleProps = $props();
34
35
 
@@ -110,7 +111,11 @@
110
111
  : styles.control({ class: slotClasses?.control })}
111
112
  for={ff.fieldId}
112
113
  >
114
+ <!-- aria-label: a visible `label` wins (stays undefined); with no label a
115
+ consumer-supplied aria-label is preferred over the generic i18n fallback
116
+ so external labelling survives the restProps-first spread. -->
113
117
  <input
118
+ {...restProps}
114
119
  id={ff.fieldId}
115
120
  type="checkbox"
116
121
  role="switch"
@@ -121,11 +126,10 @@
121
126
  {required}
122
127
  class="peer sr-only"
123
128
  aria-checked={checked}
124
- aria-label={label ? undefined : bt('accessibility.toggle') || 'Toggle'}
129
+ aria-label={label ? undefined : (ariaLabel ?? (bt('accessibility.toggle') || 'Toggle'))}
125
130
  aria-describedby={describedBy}
126
131
  aria-invalid={ff.invalid ? 'true' : undefined}
127
132
  onchange={handleChange}
128
- {...restProps}
129
133
  />
130
134
 
131
135
  <span
@@ -26,7 +26,16 @@ export const tooltipVariants = tv({
26
26
  // guards the inline-override path (an inline duration bypasses the token that
27
27
  // reduced-motion collapses to 1ms). The default easing token resolves to
28
28
  // Tailwind's implicit transition curve, so the resting fade is unchanged.
29
- 'transition-opacity duration-[var(--blocks-tooltip-duration)] ease-[var(--blocks-tooltip-easing)]',
29
+ //
30
+ // display/overlay + `transition-discrete` and the `starting:` before-state
31
+ // are what make the fade actually PLAY in top-layer mode (same mechanism
32
+ // as popoverMotion): without them, showPopover() reveals the chip with no
33
+ // before-state (enter pops to opacity 1) and hidePopover() yanks it to
34
+ // display:none in the same recalc (exit never paints). Verified 2026-07-14
35
+ // — the original transition-opacity fade only ever ran in the rare
36
+ // in-dialog fallback, where visibility is purely opacity-driven.
37
+ 'transition-[opacity,display,overlay] transition-discrete',
38
+ 'duration-[var(--blocks-tooltip-duration)] ease-[var(--blocks-tooltip-easing)]',
30
39
  'motion-reduce:duration-[1ms]',
31
40
  'bg-surface-inverted text-text-inverted'
32
41
  ],
@@ -34,7 +43,9 @@ export const tooltipVariants = tv({
34
43
  },
35
44
  variants: {
36
45
  open: {
37
- true: { base: 'opacity-100' },
46
+ // `starting:` supplies the @starting-style before-state for the frame
47
+ // showPopover() first renders the chip — the enter half of the fade.
48
+ true: { base: 'opacity-100 starting:opacity-0' },
38
49
  false: { base: 'opacity-0' }
39
50
  },
40
51
  intent: {
@@ -53,10 +53,19 @@
53
53
  --color-warm-neutral-950: oklch(0.12 0.006 45);
54
54
 
55
55
  /* Darkened *-500 / *-600 / *-700 so text-on-primary (white) passes WCAG
56
- AA (4.5:1) on the solid intent backgrounds. Previous values had
56
+ AA (4.5:1) on the solid intent backgrounds **in light mode**, where
57
+ --color-<intent> resolves to the -500/-600 shades. Previous values had
57
58
  L=0.65 which produced ~3:1 contrast — below AA. Warning keeps its
58
59
  yellow hue at L=0.75 because it pairs with text-on-surface (dark),
59
- not white. */
60
+ not white.
61
+
62
+ Scope, measured (contrast.test.ts, 2026-07-14): this holds for light
63
+ mode only. In dark mode --color-<intent> resolves to the *lighter*
64
+ -400/-300/-200 shades while --color-text-on-primary stays white, so
65
+ every filled intent lands at 1.5–4.1:1. That is a semantic.css issue
66
+ (text-on-primary is not mode-aware), not something these ramps can fix
67
+ — see the KNOWN SHORTFALLS block in contrast.test.ts. Do not "fix" it
68
+ by lightening these ramps; that would break light mode instead. */
60
69
  --color-success-50: oklch(0.95 0.03 140);
61
70
  --color-success-100: oklch(0.9 0.05 140);
62
71
  --color-success-200: oklch(0.82 0.08 140);
@@ -101,13 +110,31 @@
101
110
  --color-info-200: oklch(0.84 0.075 220);
102
111
  --color-info-300: oklch(0.76 0.105 220);
103
112
  --color-info-400: oklch(0.66 0.13 220);
104
- --color-info-500: oklch(0.55 0.14 220);
113
+ /* L=0.54, not 0.55: hue 220 is luminous enough that 0.55 measures 4.41:1
114
+ against text-on-primary (white) — below AA, despite the promise above.
115
+ 0.54 is the smallest step that clears it (4.58:1) and keeps the ramp's
116
+ 0.12 step down from info-400. Guarded by contrast.test.ts. */
117
+ --color-info-500: oklch(0.54 0.14 220);
105
118
  --color-info-600: oklch(0.48 0.13 220);
106
119
  --color-info-700: oklch(0.4 0.11 220);
107
120
  --color-info-800: oklch(0.32 0.09 220);
108
121
  --color-info-900: oklch(0.24 0.07 220);
109
122
  --color-info-950: oklch(0.16 0.05 220);
110
123
 
124
+ /* Sub-xs Type Scale — extends Tailwind's own `--text-*` namespace downwards.
125
+ Tailwind bottoms out at --text-xs (12px), but dense data marks (chart axis
126
+ labels, mini-calendar day cells, kbd hints) need 10–11px. Defining them here
127
+ rather than hardcoding `text-[11px]` at the call site is what makes those
128
+ sizes reachable by a consumer's own @theme override.
129
+
130
+ Deliberately size-only: NO paired `--text-*--line-height`. Tailwind emits
131
+ `line-height` for a scale entry only when the paired key exists, so adding
132
+ one would inject a line-height into every call site that today inherits it
133
+ from the cascade — a rendering change, not a refactor. Consumers who want
134
+ one can add the paired key in their own @theme; the utility picks it up. */
135
+ --text-2xs: 0.6875rem;
136
+ --text-3xs: 0.625rem;
137
+
111
138
  /* Border Radius Scale — physical sizes */
112
139
  --radius-xs: 0.125rem;
113
140
  --radius-sm: 0.25rem;
@@ -98,8 +98,8 @@
98
98
  transitions, so they read these through the JS mirror in
99
99
  `utils/overlay-tokens.ts` (`getOverlayMotion`) — Svelte transitions need
100
100
  numeric input. Tooltip runs a deliberately faster CSS opacity fade off its
101
- own `--blocks-tooltip-*` tokens below; Popover has no enter/exit motion yet
102
- (ACC-3 follow-up).
101
+ own `--blocks-tooltip-*` tokens below; Popover (and Menu through it) runs a
102
+ CSS-native fade/scale off the `--blocks-popover-*` tokens below.
103
103
 
104
104
  Override globally with a `BlocksProvider` style block, or per instance via
105
105
  the component's `transitionDuration` / `transitionEasing` props.
@@ -130,6 +130,23 @@
130
130
  --blocks-tooltip-duration: var(--blocks-duration-fast);
131
131
  --blocks-tooltip-easing: var(--blocks-ease-confident);
132
132
 
133
+ /* === POPOVER MOTION TOKENS (ACC-3 rest) ===
134
+ The CSS-native enter/exit fade+scale for Popover (and Menu, which passes
135
+ the same motion fragment to its inner Popover). Anchored panels should
136
+ feel snappier than the 200ms modal overlays but calmer than an instant
137
+ toggle, so they share the tooltip's `fast`/`confident` pairing. Enter is
138
+ driven by `@starting-style` (the panel un-hides via `showPopover()`, so a
139
+ plain transition has no before-state); exit relies on
140
+ `transition-behavior: allow-discrete` on `display`/`overlay` to keep the
141
+ panel painted in the top layer while it fades. Both default through
142
+ `--blocks-duration-fast`, so `prefers-reduced-motion` below collapses them
143
+ to 1ms for free. Override globally here, or per instance via Popover's
144
+ `transitionDuration` / `transitionEasing` props (which set these inline +
145
+ carry a `motion-reduce` guard, since an inline duration can't see the
146
+ media query). */
147
+ --blocks-popover-duration: var(--blocks-duration-fast);
148
+ --blocks-popover-easing: var(--blocks-ease-confident);
149
+
133
150
  /* === COLLAPSE ANIMATION TOKENS (ACC-3) ===
134
151
  The CSS-driven grid-template-rows collapse shared by Accordion +
135
152
  Collapsible (and their chevron spin). Default to the normal/smooth
@@ -54,8 +54,31 @@
54
54
  --color-text-tertiary: light-dark(var(--color-neutral-600), var(--color-neutral-300));
55
55
  --color-text-quaternary: light-dark(var(--color-neutral-500), var(--color-neutral-400));
56
56
  --color-text-disabled: light-dark(var(--color-neutral-300), var(--color-neutral-600));
57
+ /* The two on-colors below pair text with a *fill* rather than a surface.
58
+ Their fills are dark in light mode and light in dark mode, so the label
59
+ has to travel the opposite way — white, then near-black. They are
60
+ deliberately NOT aliased to one another (`text-on-primary: var(
61
+ --color-text-on-dark)`), even though they carry the same value today —
62
+ they answer to different fills and must stay independently overridable:
63
+
64
+ - text-on-dark is the partner of the avatar identity palette, whose 12
65
+ slots hold ONE lightness per mode (0.45 / 0.72) by construction.
66
+ - text-on-primary is the partner of the intent fills, whose ramps every
67
+ theme re-tunes, and which move again on hover/active.
68
+
69
+ Aliasing would couple them asymmetrically: a consumer re-tuning the avatar
70
+ palette's on-color would silently repaint every filled Button/Badge label.
71
+ Their equality is a consequence of both fill families straddling the same
72
+ lightness midpoint — not a definition. */
57
73
  --color-text-on-dark: light-dark(var(--color-neutral-0), var(--color-neutral-900));
58
- --color-text-on-primary: var(--color-neutral-0);
74
+ /* Dark mode pairs the *lighter* -400/-500 intent fills with dark text (the
75
+ Material-3 `onPrimary` pattern). White here was unconditional until
76
+ 2026-07-14, which put 125 of the 126 dark-mode intent fills under WCAG AA
77
+ — down to 1.51:1, and getting *worse* on press. The mode-aware branch
78
+ clears all 108 fills this token governs; `warning` is not one of them (it
79
+ pairs with text-on-surface and is still under AA in dark mode — see the
80
+ KNOWN SHORTFALLS block in style/contrast.test.ts, which guards all of it). */
81
+ --color-text-on-primary: light-dark(var(--color-neutral-0), var(--color-neutral-900));
59
82
  --color-text-on-surface: light-dark(var(--color-neutral-900), var(--color-neutral-100));
60
83
 
61
84
  /* === PRIMARY INTENT ===
@@ -152,6 +175,54 @@
152
175
  --color-chart-5: light-dark(var(--color-info-500), var(--color-info-400));
153
176
  --color-chart-6: light-dark(var(--color-danger-500), var(--color-danger-400));
154
177
 
178
+ /* === AVATAR IDENTITY PALETTE ===
179
+ An *unordered* identity palette: Avatar's `randomColor` hashes a name to a
180
+ fixed slot, so the same person always keeps the same hue. Deliberately NOT
181
+ the chart palette and NOT the intent tokens:
182
+
183
+ - chart-* only has 6 slots and derives from the intent ramps, so a brand
184
+ that retunes `secondary` would silently reshuffle people's identities.
185
+ Its dark values are the *-400 tints (L≈0.66) — light enough that white
186
+ initials drop to ~2.6:1, below AA. Charts colour marks; avatars carry
187
+ text on top, which is a different contrast contract.
188
+ - intent tokens are semantic (a person is not "danger").
189
+
190
+ Construction: a single lightness per mode, 12 hues at an even 30° spacing.
191
+ Because OKLCH lightness is perceptual, holding L constant means every slot
192
+ shares one guaranteed contrast partner — `--color-text-on-dark`, which
193
+ flips white → neutral-900 with the mode exactly as these fills flip dark →
194
+ light. So legibility is a property of the construction, not 12 separate
195
+ checks. Even spacing also beats the 15 hardcoded hex values this replaced,
196
+ which clustered (red/orange/amber/yellow within ~20°) and blurred when
197
+ avatars sat side by side.
198
+
199
+ L=0.45 (light) / L=0.72 (dark) mirror the foundation ramps' *-700 / *-300
200
+ steps, so the palette reads as part of the same chassis. Rebrand by
201
+ overriding these 12 tokens; the text partner follows automatically. */
202
+ --color-avatar-1: light-dark(oklch(0.45 0.13 25), oklch(0.72 0.12 25));
203
+ --color-avatar-2: light-dark(oklch(0.45 0.13 55), oklch(0.72 0.12 55));
204
+ --color-avatar-3: light-dark(oklch(0.45 0.13 85), oklch(0.72 0.12 85));
205
+ --color-avatar-4: light-dark(oklch(0.45 0.13 115), oklch(0.72 0.12 115));
206
+ --color-avatar-5: light-dark(oklch(0.45 0.13 145), oklch(0.72 0.12 145));
207
+ --color-avatar-6: light-dark(oklch(0.45 0.13 175), oklch(0.72 0.12 175));
208
+ --color-avatar-7: light-dark(oklch(0.45 0.13 205), oklch(0.72 0.12 205));
209
+ --color-avatar-8: light-dark(oklch(0.45 0.13 235), oklch(0.72 0.12 235));
210
+ --color-avatar-9: light-dark(oklch(0.45 0.13 265), oklch(0.72 0.12 265));
211
+ --color-avatar-10: light-dark(oklch(0.45 0.13 295), oklch(0.72 0.12 295));
212
+ --color-avatar-11: light-dark(oklch(0.45 0.13 325), oklch(0.72 0.12 325));
213
+ --color-avatar-12: light-dark(oklch(0.45 0.13 355), oklch(0.72 0.12 355));
214
+
215
+ /* === LIVE / "NOW" ACCENT ===
216
+ Single-purpose accent for live/now markers — the Calendar current-time
217
+ line and dot, and future live/recording indicators. Red is the
218
+ convention (Google Calendar et al.), but the *purpose* is not `danger`:
219
+ a now-line states "you are here", not "something is wrong", and the two
220
+ must stay independently themeable. Defaults ride the danger foundation
221
+ ramp, so dark mode and per-theme danger re-tunes flow through
222
+ automatically; override `--color-live` alone to restyle live indicators
223
+ without touching error styling. */
224
+ --color-live: light-dark(var(--color-danger-500), var(--color-danger-400));
225
+
155
226
  /* === BORDERS ===
156
227
  border-hairline is the quietest tier — structural
157
228
  trennlinien (row dividers, Card header/footer separators, inline
@@ -17,7 +17,16 @@
17
17
  --color-primary-200: oklch(0.84 0.008 240);
18
18
  --color-primary-300: oklch(0.76 0.01 240);
19
19
  --color-primary-400: oklch(0.65 0.012 240);
20
- --color-primary-500: oklch(0.53 0.012 240);
20
+ /* L 0.58, not the 0.53 this ramp's spacing would suggest: primary-500 is the
21
+ dark-mode `--color-primary` fill, and dark mode carries dark text on it
22
+ (`--color-text-on-primary`). At 0.53 this grey measured 3.74:1 against
23
+ neutral-900 — the one intent fill in the whole system that missed AA after
24
+ the on-color became mode-aware, because a desaturated mid-grey is the
25
+ hardest case: no lightness clears AA against BOTH white and neutral-900
26
+ (proved in style/contrast.test.ts). 0.58 is the lowest step on this
27
+ ramp's 0.01 grid that clears it (4.59:1; the true crossing is 0.574 =
28
+ 4.50:1, i.e. no margin at all). Monotonicity holds (0.65 > 0.58 > 0.43). */
29
+ --color-primary-500: oklch(0.58 0.012 240);
21
30
  --color-primary-600: oklch(0.43 0.012 240);
22
31
  --color-primary-700: oklch(0.36 0.01 240);
23
32
  --color-primary-800: oklch(0.29 0.008 240);
@@ -26,7 +26,11 @@
26
26
  --color-secondary-200: oklch(0.82 0.08 190);
27
27
  --color-secondary-300: oklch(0.74 0.11 190);
28
28
  --color-secondary-400: oklch(0.66 0.13 190);
29
- --color-secondary-500: oklch(0.55 0.12 190);
29
+ /* L=0.54, not the foundation ramp's 0.55: at hue 190 (teal) the same
30
+ lightness measures 4.39:1 against text-on-primary (white) — below AA.
31
+ Lightness is not contrast-neutral across hues in OKLCH. Guarded by
32
+ contrast.test.ts. */
33
+ --color-secondary-500: oklch(0.54 0.12 190);
30
34
  --color-secondary-600: oklch(0.48 0.12 190);
31
35
  --color-secondary-700: oklch(0.4 0.1 190);
32
36
  --color-secondary-800: oklch(0.32 0.08 190);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `composeHandlers` — run a component's own DOM event handler *and* a
3
+ * consumer-supplied one that arrived through `restProps`.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The library's `restProps` contract (see docs/COMPONENT-API-CONVENTIONS.md)
8
+ * spreads `{...restProps}` **first**, so the component's own attributes win: a
9
+ * consumer can't silently cancel state the component owns. Applied naively to
10
+ * event handlers that rule would go too far in the other direction — a
11
+ * `restProps`-first spread makes the component's `onkeydown` clobber the
12
+ * *consumer's*, which is just the original bug pointed the other way.
13
+ *
14
+ * Neither side should lose. Handlers are additive by nature (the DOM itself
15
+ * allows many listeners per event), so the component destructures the handler
16
+ * out of `restProps` and composes it: **internal first, consumer second**.
17
+ *
18
+ * ## Ordering: internal first
19
+ *
20
+ * 1. The component's behaviour is then unconditional — it cannot be skipped by
21
+ * a consumer handler that throws, which is exactly what "internal wins"
22
+ * has to mean for a dismiss/focus-trap path.
23
+ * 2. The consumer observes the event *after* the component reacted, so
24
+ * `event.defaultPrevented` tells them whether the component claimed it
25
+ * (e.g. Dialog preventDefaults `Escape` when it closes on it).
26
+ *
27
+ * ## `preventDefault` is deliberately NOT a veto
28
+ *
29
+ * The consumer's handler runs after the internal one, so calling
30
+ * `event.preventDefault()` there cannot suppress the component's behaviour —
31
+ * by design. Overloading `preventDefault` as "also disable this component's
32
+ * dismissal" would re-create the silent-disable bug through a different door:
33
+ * `preventDefault` has an established DOM meaning (suppress the *browser's*
34
+ * default action), and a consumer calling it for that reason would lose
35
+ * dismissal without ever asking to. Opting out of a behaviour is spelled with
36
+ * the named, discoverable prop that already exists for it — `closeOnEscape`,
37
+ * `closeOnBackdropClick` — not with a magic event side-effect.
38
+ *
39
+ * @example
40
+ * ```svelte
41
+ * <script lang="ts">
42
+ * let { onkeydown: onkeydownProp, ...restProps }: Props = $props();
43
+ * function handleKeydown(event: KeyboardEvent) { ... }
44
+ * </script>
45
+ *
46
+ * <dialog {...restProps} onkeydown={composeHandlers(handleKeydown, onkeydownProp)}>
47
+ * ```
48
+ */
49
+ /**
50
+ * Structural mirror of svelte/elements' internal `EventHandler` type, which is
51
+ * declared there but not exported. Matching the shape keeps the composed result
52
+ * assignable to the `on*` props of `HTMLAttributes` (`MouseEventHandler<T>`,
53
+ * `KeyboardEventHandler<T>`, …).
54
+ */
55
+ type DomEventHandler<TEvent extends Event, TElement extends EventTarget> = (event: TEvent & {
56
+ currentTarget: EventTarget & TElement;
57
+ }) => unknown;
58
+ /**
59
+ * Combine an internal handler with an optional consumer handler from `restProps`.
60
+ *
61
+ * The internal handler always runs first and always runs; the consumer handler
62
+ * runs afterwards when present. See the module doc for the ordering and
63
+ * `preventDefault` rationale.
64
+ *
65
+ * @param internal The component's own handler. Runs first, unconditionally.
66
+ * @param consumer The consumer's handler from `restProps`. May be `null`/`undefined`.
67
+ * @returns A handler assignable to the element's `on*` attribute.
68
+ */
69
+ export declare function composeHandlers<TEvent extends Event, TElement extends EventTarget = Element>(internal: (event: TEvent & {
70
+ currentTarget: EventTarget & TElement;
71
+ }) => unknown, consumer: DomEventHandler<TEvent, TElement> | null | undefined): DomEventHandler<TEvent, TElement>;
72
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `composeHandlers` — run a component's own DOM event handler *and* a
3
+ * consumer-supplied one that arrived through `restProps`.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The library's `restProps` contract (see docs/COMPONENT-API-CONVENTIONS.md)
8
+ * spreads `{...restProps}` **first**, so the component's own attributes win: a
9
+ * consumer can't silently cancel state the component owns. Applied naively to
10
+ * event handlers that rule would go too far in the other direction — a
11
+ * `restProps`-first spread makes the component's `onkeydown` clobber the
12
+ * *consumer's*, which is just the original bug pointed the other way.
13
+ *
14
+ * Neither side should lose. Handlers are additive by nature (the DOM itself
15
+ * allows many listeners per event), so the component destructures the handler
16
+ * out of `restProps` and composes it: **internal first, consumer second**.
17
+ *
18
+ * ## Ordering: internal first
19
+ *
20
+ * 1. The component's behaviour is then unconditional — it cannot be skipped by
21
+ * a consumer handler that throws, which is exactly what "internal wins"
22
+ * has to mean for a dismiss/focus-trap path.
23
+ * 2. The consumer observes the event *after* the component reacted, so
24
+ * `event.defaultPrevented` tells them whether the component claimed it
25
+ * (e.g. Dialog preventDefaults `Escape` when it closes on it).
26
+ *
27
+ * ## `preventDefault` is deliberately NOT a veto
28
+ *
29
+ * The consumer's handler runs after the internal one, so calling
30
+ * `event.preventDefault()` there cannot suppress the component's behaviour —
31
+ * by design. Overloading `preventDefault` as "also disable this component's
32
+ * dismissal" would re-create the silent-disable bug through a different door:
33
+ * `preventDefault` has an established DOM meaning (suppress the *browser's*
34
+ * default action), and a consumer calling it for that reason would lose
35
+ * dismissal without ever asking to. Opting out of a behaviour is spelled with
36
+ * the named, discoverable prop that already exists for it — `closeOnEscape`,
37
+ * `closeOnBackdropClick` — not with a magic event side-effect.
38
+ *
39
+ * @example
40
+ * ```svelte
41
+ * <script lang="ts">
42
+ * let { onkeydown: onkeydownProp, ...restProps }: Props = $props();
43
+ * function handleKeydown(event: KeyboardEvent) { ... }
44
+ * </script>
45
+ *
46
+ * <dialog {...restProps} onkeydown={composeHandlers(handleKeydown, onkeydownProp)}>
47
+ * ```
48
+ */
49
+ /**
50
+ * Combine an internal handler with an optional consumer handler from `restProps`.
51
+ *
52
+ * The internal handler always runs first and always runs; the consumer handler
53
+ * runs afterwards when present. See the module doc for the ordering and
54
+ * `preventDefault` rationale.
55
+ *
56
+ * @param internal The component's own handler. Runs first, unconditionally.
57
+ * @param consumer The consumer's handler from `restProps`. May be `null`/`undefined`.
58
+ * @returns A handler assignable to the element's `on*` attribute.
59
+ */
60
+ export function composeHandlers(internal, consumer) {
61
+ return (event) => {
62
+ internal(event);
63
+ consumer?.(event);
64
+ };
65
+ }
@@ -123,7 +123,7 @@ const foundationColors = {
123
123
  '200': 'oklch(0.84 0.075 220)',
124
124
  '300': 'oklch(0.76 0.105 220)',
125
125
  '400': 'oklch(0.66 0.13 220)',
126
- '500': 'oklch(0.55 0.14 220)',
126
+ '500': 'oklch(0.54 0.14 220)',
127
127
  '600': 'oklch(0.48 0.13 220)',
128
128
  '700': 'oklch(0.4 0.11 220)',
129
129
  '800': 'oklch(0.32 0.09 220)',