@urbicon-ui/blocks 6.24.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 (58) 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/internal/charts/variants.js +2 -2
  14. package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
  15. package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
  16. package/dist/internal/date-grid/date-grid.svelte.js +63 -7
  17. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  18. package/dist/primitives/Avatar/index.d.ts +1 -1
  19. package/dist/primitives/Button/Button.svelte +12 -25
  20. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  21. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  22. package/dist/primitives/Collapsible/index.d.ts +13 -2
  23. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  24. package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
  25. package/dist/primitives/Dialog/Dialog.svelte +31 -8
  26. package/dist/primitives/Drawer/Drawer.svelte +29 -7
  27. package/dist/primitives/Input/Input.svelte +7 -1
  28. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  29. package/dist/primitives/Menu/Menu.svelte +8 -0
  30. package/dist/primitives/Menu/index.d.ts +11 -13
  31. package/dist/primitives/Popover/Popover.svelte +107 -4
  32. package/dist/primitives/Popover/index.d.ts +15 -2
  33. package/dist/primitives/Popover/index.js +1 -1
  34. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  35. package/dist/primitives/Popover/popover.variants.js +35 -0
  36. package/dist/primitives/Progress/progress.variants.js +1 -1
  37. package/dist/primitives/RadioGroup/radioGroup.variants.js +1 -1
  38. package/dist/primitives/Select/index.d.ts +8 -1
  39. package/dist/primitives/Slider/Slider.svelte +32 -3
  40. package/dist/primitives/Stepper/stepper.variants.js +1 -1
  41. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  42. package/dist/primitives/Tooltip/tooltip.variants.js +13 -2
  43. package/dist/style/foundation.css +30 -3
  44. package/dist/style/interaction.css +19 -2
  45. package/dist/style/semantic.css +72 -1
  46. package/dist/style/themes/neutral.css +10 -1
  47. package/dist/style/themes/ocean.css +5 -1
  48. package/dist/utils/compose-handlers.d.ts +72 -0
  49. package/dist/utils/compose-handlers.js +65 -0
  50. package/dist/utils/figma-token-export.js +1 -1
  51. package/dist/utils/guide.svelte.js +92 -29
  52. package/dist/utils/index.d.ts +1 -1
  53. package/dist/utils/index.js +1 -1
  54. package/dist/utils/overlay-tokens.d.ts +17 -0
  55. package/dist/utils/overlay-tokens.js +30 -0
  56. package/dist/utils/use-floating-panel.svelte.js +9 -3
  57. package/dist/utils/variants.js +5 -1
  58. package/package.json +3 -3
@@ -1,9 +1,44 @@
1
1
  import { tv } from '../../utils/variants.js';
2
+ /**
3
+ * CSS-native enter/exit motion for the floating panel (ACC-3 rest), keyed on
4
+ * the `data-state` attribute Popover always stamps. Kept as its own fragment
5
+ * because two call sites need exactly these classes: `popoverVariants.base`
6
+ * below, and Menu — which renders its inner Popover `unstyled` (to avoid a
7
+ * double surface) and re-applies the fragment via Popover's `class` prop.
8
+ *
9
+ * How the two halves work:
10
+ * - **Enter** — the panel un-hides via `showPopover()` (top layer) or a
11
+ * `display` flip (in-place mode), so a plain transition has no before-state;
12
+ * `starting:` (`@starting-style`) supplies it.
13
+ * - **Exit** — `hidePopover()` / a native light dismiss yank the panel to
14
+ * `display: none` in the same style recalc that flips `data-state`;
15
+ * `transition-discrete` (`transition-behavior: allow-discrete`) on
16
+ * `display`/`overlay` keeps it painted (and in the top layer) until the
17
+ * fade lands. Popover lags the children-teardown to match — see the
18
+ * exit-motion block in Popover.svelte.
19
+ *
20
+ * Browsers without `@starting-style`/`allow-discrete` simply skip the motion
21
+ * and keep today's instant toggle. Duration/easing resolve through the
22
+ * `--blocks-popover-*` tokens (interaction.css), which reduced motion
23
+ * collapses to 1ms; `motion-reduce:duration-[1ms]` guards the inline
24
+ * per-instance override path, which can't see the media query.
25
+ */
26
+ export const popoverMotion = [
27
+ 'transition-[opacity,scale,display,overlay] transition-discrete',
28
+ 'duration-[var(--blocks-popover-duration)] ease-[var(--blocks-popover-easing)]',
29
+ 'motion-reduce:duration-[1ms]',
30
+ // pointer-events-none while closed: during the exit fade the children are
31
+ // still mounted and painted — without this, a quick click on the fading
32
+ // panel would fire consumer handlers from a visually dismissed surface.
33
+ 'data-[state=closed]:opacity-0 data-[state=closed]:scale-[0.98] data-[state=closed]:pointer-events-none',
34
+ 'starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-[0.98]'
35
+ ].join(' ');
2
36
  export const popoverVariants = tv({
3
37
  // tier: contain — floating panel surface.
4
38
  base: [
5
39
  'bg-surface-elevated border border-border-hairline rounded-contain',
6
40
  'shadow-[var(--blocks-shadow-md)] backdrop-blur-sm',
41
+ popoverMotion,
7
42
  // `calc(100dvh-4rem)` is the static design cap; Floating UI's `size`
8
43
  // middleware (via useFloatingPanel) narrows it to the room actually left
9
44
  // between the anchor and the visual viewport edge through
@@ -35,7 +35,7 @@ export const progressVariants = tv({
35
35
  xs: {
36
36
  track: 'h-1',
37
37
  fill: 'h-1',
38
- circularLabel: 'text-[10px]'
38
+ circularLabel: 'text-3xs'
39
39
  },
40
40
  sm: {
41
41
  track: 'h-1.5',
@@ -66,7 +66,7 @@ export const radioItemVariants = tv({
66
66
  indicator: 'w-3.5 h-3.5 mt-px',
67
67
  dot: 'w-1.5 h-1.5',
68
68
  label: 'text-xs',
69
- description: 'text-[10px]'
69
+ description: 'text-3xs'
70
70
  },
71
71
  sm: {
72
72
  item: 'gap-2',
@@ -256,7 +256,14 @@ interface SelectBaseProps<T extends SelectValue = string> extends Omit<SelectVar
256
256
  * and make the custom look reusable across the project.
257
257
  */
258
258
  preset?: string;
259
- /** Explicit `id` for the component. Auto-generated if omitted. */
259
+ /**
260
+ * Explicit `id` for the component. Auto-generated if omitted.
261
+ *
262
+ * The id lands on the component's wrapper, which is not a labelable element.
263
+ * The focusable trigger is derived from it as `` `${id}-trigger` `` — that is
264
+ * the id an external `<label for>` has to address, since a `for` pointing at
265
+ * the wrapper focuses nothing.
266
+ */
260
267
  id?: string;
261
268
  }
262
269
  /**
@@ -35,6 +35,7 @@
35
35
  slotClasses: slotClassesProp = {},
36
36
  preset,
37
37
  'aria-describedby': ariaDescribedby,
38
+ 'aria-labelledby': ariaLabelledby,
38
39
  ...restProps
39
40
  }: SliderProps = $props();
40
41
 
@@ -53,6 +54,8 @@
53
54
  disabled
54
55
  }));
55
56
  const statusId = `${uid}-status`;
57
+ const minLabelId = `${uid}-min-label`;
58
+ const maxLabelId = `${uid}-max-label`;
56
59
 
57
60
  let trackRef = $state<HTMLDivElement>();
58
61
  let dragging = $state<'single' | 'start' | 'end' | null>(null);
@@ -251,6 +254,17 @@
251
254
  .join(' ') || undefined
252
255
  );
253
256
 
257
+ // `aria-labelledby` has the same reach problem as `aria-describedby` above:
258
+ // restProps land on the roleless wrapper div, so an external visible label
259
+ // would never name the focusable thumbs. On the range arm one external label
260
+ // would name both thumbs identically ("Padding" / "Padding"), so it is
261
+ // composed with a visually-hidden minimum/maximum qualifier —
262
+ // aria-labelledby concatenates its references in order, yielding
263
+ // "Padding Minimum" / "Padding Maximum" and preserving the distinction the
264
+ // `label`-prop arm gets for free.
265
+ const startLabelledBy = $derived(ariaLabelledby ? `${ariaLabelledby} ${minLabelId}` : undefined);
266
+ const endLabelledBy = $derived(ariaLabelledby ? `${ariaLabelledby} ${maxLabelId}` : undefined);
267
+
254
268
  $effect(() => {
255
269
  if (trackRef && mint && mint !== 'none' && !disabled) {
256
270
  return mintRegistry.apply(trackRef, mint);
@@ -429,13 +443,22 @@
429
443
  {/if}
430
444
 
431
445
  {#if range}
446
+ {#if ariaLabelledby}
447
+ <span id={minLabelId} class="sr-only">{bt('accessibility.minimum') || 'Minimum'}</span>
448
+ <span id={maxLabelId} class="sr-only">{bt('accessibility.maximum') || 'Maximum'}</span>
449
+ {/if}
432
450
  <div
433
451
  role="slider"
434
452
  tabindex={disabled ? -1 : 0}
435
453
  aria-valuemin={min}
436
454
  aria-valuemax={rangeValue[1]}
437
455
  aria-valuenow={rangeValue[0]}
438
- aria-label={label ? `${label} minimum` : bt('accessibility.minimum') || 'Minimum'}
456
+ aria-labelledby={startLabelledBy}
457
+ aria-label={startLabelledBy
458
+ ? undefined
459
+ : label
460
+ ? `${label} minimum`
461
+ : bt('accessibility.minimum') || 'Minimum'}
439
462
  aria-describedby={describedBy}
440
463
  aria-disabled={disabled || undefined}
441
464
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
@@ -449,7 +472,12 @@
449
472
  aria-valuemin={rangeValue[0]}
450
473
  aria-valuemax={max}
451
474
  aria-valuenow={rangeValue[1]}
452
- aria-label={label ? `${label} maximum` : bt('accessibility.maximum') || 'Maximum'}
475
+ aria-labelledby={endLabelledBy}
476
+ aria-label={endLabelledBy
477
+ ? undefined
478
+ : label
479
+ ? `${label} maximum`
480
+ : bt('accessibility.maximum') || 'Maximum'}
453
481
  aria-describedby={describedBy}
454
482
  aria-disabled={disabled || undefined}
455
483
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
@@ -464,7 +492,8 @@
464
492
  aria-valuemin={min}
465
493
  aria-valuemax={max}
466
494
  aria-valuenow={singleValue}
467
- aria-label={label || bt('accessibility.slider') || 'Slider'}
495
+ aria-labelledby={ariaLabelledby}
496
+ aria-label={ariaLabelledby ? undefined : label || bt('accessibility.slider') || 'Slider'}
468
497
  aria-describedby={describedBy}
469
498
  aria-disabled={disabled || undefined}
470
499
  class={unstyled ? (slotClasses?.thumb ?? '') : styles.thumb({ class: slotClasses?.thumb })}
@@ -68,7 +68,7 @@ export const stepperVariants = tv({
68
68
  indicator: 'size-7 text-xs',
69
69
  step: 'gap-2',
70
70
  label: 'text-xs',
71
- description: 'text-[11px]',
71
+ description: 'text-2xs',
72
72
  content: 'text-sm',
73
73
  indicatorColumn: 'w-7'
74
74
  },
@@ -29,6 +29,7 @@
29
29
  slotClasses: slotClassesProp = {},
30
30
  preset,
31
31
  oninput: userOnInput,
32
+ id: idProp,
32
33
  'aria-describedby': ariaDescribedby,
33
34
  ...restProps
34
35
  }: TextareaProps = $props();
@@ -42,9 +43,12 @@
42
43
  let textareaRef = $state<HTMLTextAreaElement>();
43
44
 
44
45
  // ARIA wiring is shared with every form primitive — see XC-2.
46
+ // A consumer-supplied `id` wins over the generated one, so an external
47
+ // `<label for>` can address the `<textarea>` (mirrors the Input role model).
45
48
  const propsId = $props.id();
49
+ const fieldId = $derived(idProp ?? `textarea-${propsId}`);
46
50
  const ff = useFormField(() => ({
47
- fieldId: `textarea-${propsId}`,
51
+ fieldId,
48
52
  helper,
49
53
  error,
50
54
  required,
@@ -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)',