@urbicon-ui/blocks 6.24.0 → 6.26.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 (73) hide show
  1. package/README.md +14 -0
  2. package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
  3. package/dist/components/Calendar/calendar.variants.d.ts +63 -63
  4. package/dist/components/Calendar/calendar.variants.js +5 -3
  5. package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
  6. package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
  7. package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
  8. package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
  9. package/dist/components/CommandPalette/index.d.ts +19 -0
  10. package/dist/components/CompositionBar/CompositionBar.svelte +2 -2
  11. package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
  12. package/dist/components/Planner/planner.variants.js +1 -1
  13. package/dist/components/Sankey/sankey.variants.js +1 -1
  14. package/dist/i18n/index.d.ts +378 -2
  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/Alert/alert.variants.d.ts +8 -8
  20. package/dist/primitives/Alert/alert.variants.js +1 -1
  21. package/dist/primitives/Avatar/Avatar.svelte +38 -24
  22. package/dist/primitives/Avatar/index.d.ts +1 -1
  23. package/dist/primitives/Badge/badge.variants.js +1 -1
  24. package/dist/primitives/Button/Button.svelte +12 -25
  25. package/dist/primitives/Button/button.variants.js +2 -2
  26. package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
  27. package/dist/primitives/Checkbox/checkbox.variants.js +2 -2
  28. package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
  29. package/dist/primitives/Collapsible/index.d.ts +13 -2
  30. package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
  31. package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
  32. package/dist/primitives/Dialog/Dialog.svelte +36 -8
  33. package/dist/primitives/Drawer/Drawer.svelte +34 -7
  34. package/dist/primitives/Input/Input.svelte +7 -1
  35. package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +22 -22
  36. package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
  37. package/dist/primitives/Menu/Menu.svelte +8 -0
  38. package/dist/primitives/Menu/index.d.ts +11 -13
  39. package/dist/primitives/Popover/Popover.svelte +107 -4
  40. package/dist/primitives/Popover/index.d.ts +15 -2
  41. package/dist/primitives/Popover/index.js +1 -1
  42. package/dist/primitives/Popover/popover.variants.d.ts +25 -0
  43. package/dist/primitives/Popover/popover.variants.js +35 -0
  44. package/dist/primitives/Progress/progress.variants.js +1 -1
  45. package/dist/primitives/RadioGroup/radioGroup.variants.js +2 -2
  46. package/dist/primitives/Select/index.d.ts +8 -1
  47. package/dist/primitives/Slider/Slider.svelte +32 -3
  48. package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
  49. package/dist/primitives/Stepper/stepper.variants.d.ts +11 -11
  50. package/dist/primitives/Stepper/stepper.variants.js +2 -2
  51. package/dist/primitives/Textarea/Textarea.svelte +5 -1
  52. package/dist/primitives/Toast/toast.variants.d.ts +12 -12
  53. package/dist/primitives/Tooltip/tooltip.variants.d.ts +3 -3
  54. package/dist/primitives/Tooltip/tooltip.variants.js +14 -3
  55. package/dist/style/foundation.css +41 -7
  56. package/dist/style/interaction.css +19 -2
  57. package/dist/style/semantic.css +84 -1
  58. package/dist/style/themes/forest.css +3 -1
  59. package/dist/style/themes/neutral.css +10 -1
  60. package/dist/style/themes/ocean.css +5 -1
  61. package/dist/style/themes/sunset.css +3 -1
  62. package/dist/utils/compose-handlers.d.ts +72 -0
  63. package/dist/utils/compose-handlers.js +65 -0
  64. package/dist/utils/figma-token-export.js +6 -2
  65. package/dist/utils/guide.svelte.js +92 -29
  66. package/dist/utils/index.d.ts +1 -1
  67. package/dist/utils/index.js +1 -1
  68. package/dist/utils/overlay-tokens.d.ts +17 -0
  69. package/dist/utils/overlay-tokens.js +30 -0
  70. package/dist/utils/overlay.js +24 -2
  71. package/dist/utils/use-floating-panel.svelte.js +9 -3
  72. package/dist/utils/variants.js +5 -1
  73. package/package.json +3 -3
@@ -52,11 +52,20 @@
52
52
  --color-warm-neutral-900: oklch(0.2 0.008 45);
53
53
  --color-warm-neutral-950: oklch(0.12 0.006 45);
54
54
 
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
57
- L=0.65 which produced ~3:1 contrast below AA. Warning keeps its
58
- yellow hue at L=0.75 because it pairs with text-on-surface (dark),
59
- not white. */
55
+ /* Darkened *-500 / *-600 / *-700 so text-on-primary (white in light mode)
56
+ passes WCAG AA (4.5:1) on the solid intent backgrounds **in light
57
+ mode**, where --color-<intent> resolves to the -500/-600 shades.
58
+ Previous values had L=0.65 which produced ~3:1 contrast below AA.
59
+ Warning is the one inverted ramp: its fill stays light in both modes
60
+ (500 keeps its yellow at L=0.75) and pairs with the dark,
61
+ non-mode-aware `text-on-warning` (= warning-950), not white.
62
+
63
+ In dark mode --color-<intent> resolves to the *lighter* -400/-300/-200
64
+ shades and pairs with the dark branch of the mode-aware
65
+ `text-on-primary` (semantic.css, 2026-07-14). Both directions are
66
+ measured across all six themes by style/contrast.test.ts. Do not
67
+ lighten these ramps to "help" dark mode; light mode is the branch
68
+ these stops guarantee. */
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);
@@ -76,7 +85,14 @@
76
85
  --color-warning-400: oklch(0.66 0.14 80);
77
86
  --color-warning-500: oklch(0.75 0.15 80);
78
87
  --color-warning-600: oklch(0.65 0.15 80);
79
- --color-warning-700: oklch(0.55 0.13 80);
88
+ /* L=0.59, not the ramp's usual 0.55: warning-700 is the light-mode press
89
+ fill (--color-warning-active) under the dark `text-on-warning`
90
+ (warning-950). Warning's ramp is inverted — pressing moves TOWARD the
91
+ dark label — and at L=0.55 the press state measured 3.90–4.05:1, under
92
+ AA. 0.59 is the lowest 0.01 step at which every themed hue clears
93
+ 4.5:1 (0.58 still fails forest's hue 60 at 4.46:1). Mirrored by the
94
+ forest/sunset ramps; guarded by contrast.test.ts. */
95
+ --color-warning-700: oklch(0.59 0.13 80);
80
96
  --color-warning-800: oklch(0.45 0.11 80);
81
97
  --color-warning-900: oklch(0.25 0.08 80);
82
98
  --color-warning-950: oklch(0.15 0.05 80);
@@ -101,13 +117,31 @@
101
117
  --color-info-200: oklch(0.84 0.075 220);
102
118
  --color-info-300: oklch(0.76 0.105 220);
103
119
  --color-info-400: oklch(0.66 0.13 220);
104
- --color-info-500: oklch(0.55 0.14 220);
120
+ /* L=0.54, not 0.55: hue 220 is luminous enough that 0.55 measures 4.41:1
121
+ against text-on-primary (white) — below AA, despite the promise above.
122
+ 0.54 is the smallest step that clears it (4.58:1) and keeps the ramp's
123
+ 0.12 step down from info-400. Guarded by contrast.test.ts. */
124
+ --color-info-500: oklch(0.54 0.14 220);
105
125
  --color-info-600: oklch(0.48 0.13 220);
106
126
  --color-info-700: oklch(0.4 0.11 220);
107
127
  --color-info-800: oklch(0.32 0.09 220);
108
128
  --color-info-900: oklch(0.24 0.07 220);
109
129
  --color-info-950: oklch(0.16 0.05 220);
110
130
 
131
+ /* Sub-xs Type Scale — extends Tailwind's own `--text-*` namespace downwards.
132
+ Tailwind bottoms out at --text-xs (12px), but dense data marks (chart axis
133
+ labels, mini-calendar day cells, kbd hints) need 10–11px. Defining them here
134
+ rather than hardcoding `text-[11px]` at the call site is what makes those
135
+ sizes reachable by a consumer's own @theme override.
136
+
137
+ Deliberately size-only: NO paired `--text-*--line-height`. Tailwind emits
138
+ `line-height` for a scale entry only when the paired key exists, so adding
139
+ one would inject a line-height into every call site that today inherits it
140
+ from the cascade — a rendering change, not a refactor. Consumers who want
141
+ one can add the paired key in their own @theme; the utility picks it up. */
142
+ --text-2xs: 0.6875rem;
143
+ --text-3xs: 0.625rem;
144
+
111
145
  /* Border Radius Scale — physical sizes */
112
146
  --radius-xs: 0.125rem;
113
147
  --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,43 @@
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 on-colors below pair text with a *fill* rather than a surface.
58
+ text-on-dark and text-on-primary answer fills that are dark in light
59
+ mode and light in dark mode, so the label has to travel the opposite
60
+ way — white, then near-black. They are deliberately NOT aliased to one
61
+ another (`text-on-primary: var(--color-text-on-dark)`), even though
62
+ they carry the same value today — they answer to different fills and
63
+ must stay independently overridable:
64
+
65
+ - text-on-dark is the partner of the avatar identity palette, whose 12
66
+ slots hold ONE lightness per mode (0.45 / 0.72) by construction.
67
+ - text-on-primary is the partner of the intent fills, whose ramps every
68
+ theme re-tunes, and which move again on hover/active.
69
+
70
+ Aliasing would couple them asymmetrically: a consumer re-tuning the avatar
71
+ palette's on-color would silently repaint every filled Button/Badge label.
72
+ Their equality is a consequence of both fill families straddling the same
73
+ lightness midpoint — not a definition. */
57
74
  --color-text-on-dark: light-dark(var(--color-neutral-0), var(--color-neutral-900));
58
- --color-text-on-primary: var(--color-neutral-0);
75
+ /* Dark mode pairs the *lighter* -400/-500 intent fills with dark text (the
76
+ Material-3 `onPrimary` pattern). White here was unconditional until
77
+ 2026-07-14, which put 125 of the 126 dark-mode intent fills under WCAG AA
78
+ — down to 1.51:1, and getting *worse* on press. The mode-aware branch
79
+ clears all 108 fills this token governs; `warning` is not one of them —
80
+ its fill is light in BOTH modes, so it carries its own non-mode-aware
81
+ on-color below (`--color-text-on-warning`). */
82
+ --color-text-on-primary: light-dark(var(--color-neutral-0), var(--color-neutral-900));
83
+ /* Warning's own on-color. Warning is the one intent whose fill stays a
84
+ light amber in BOTH modes (base/hover/active ride L 0.59–0.82), so unlike
85
+ text-on-primary this token is deliberately NOT mode-aware — the label is
86
+ a warm dark in both modes. It rides the warning ramp's own 950 stop
87
+ rather than a neutral, so theme re-hues (forest 60, sunset 92) flow
88
+ through and the label reads as part of the swatch instead of soot on top
89
+ of it. Until 2026-07-20 filled warning paired with `text-on-surface` — a
90
+ *surface* token that flips light in dark mode — which put all 18
91
+ dark-mode warning fills at 1.51–2.80:1. Measured across every theme ×
92
+ mode × state by style/contrast.test.ts. */
93
+ --color-text-on-warning: var(--color-warning-950);
59
94
  --color-text-on-surface: light-dark(var(--color-neutral-900), var(--color-neutral-100));
60
95
 
61
96
  /* === PRIMARY INTENT ===
@@ -152,6 +187,54 @@
152
187
  --color-chart-5: light-dark(var(--color-info-500), var(--color-info-400));
153
188
  --color-chart-6: light-dark(var(--color-danger-500), var(--color-danger-400));
154
189
 
190
+ /* === AVATAR IDENTITY PALETTE ===
191
+ An *unordered* identity palette: Avatar's `randomColor` hashes a name to a
192
+ fixed slot, so the same person always keeps the same hue. Deliberately NOT
193
+ the chart palette and NOT the intent tokens:
194
+
195
+ - chart-* only has 6 slots and derives from the intent ramps, so a brand
196
+ that retunes `secondary` would silently reshuffle people's identities.
197
+ Its dark values are the *-400 tints (L≈0.66) — light enough that white
198
+ initials drop to ~2.6:1, below AA. Charts colour marks; avatars carry
199
+ text on top, which is a different contrast contract.
200
+ - intent tokens are semantic (a person is not "danger").
201
+
202
+ Construction: a single lightness per mode, 12 hues at an even 30° spacing.
203
+ Because OKLCH lightness is perceptual, holding L constant means every slot
204
+ shares one guaranteed contrast partner — `--color-text-on-dark`, which
205
+ flips white → neutral-900 with the mode exactly as these fills flip dark →
206
+ light. So legibility is a property of the construction, not 12 separate
207
+ checks. Even spacing also beats the 15 hardcoded hex values this replaced,
208
+ which clustered (red/orange/amber/yellow within ~20°) and blurred when
209
+ avatars sat side by side.
210
+
211
+ L=0.45 (light) / L=0.72 (dark) mirror the foundation ramps' *-700 / *-300
212
+ steps, so the palette reads as part of the same chassis. Rebrand by
213
+ overriding these 12 tokens; the text partner follows automatically. */
214
+ --color-avatar-1: light-dark(oklch(0.45 0.13 25), oklch(0.72 0.12 25));
215
+ --color-avatar-2: light-dark(oklch(0.45 0.13 55), oklch(0.72 0.12 55));
216
+ --color-avatar-3: light-dark(oklch(0.45 0.13 85), oklch(0.72 0.12 85));
217
+ --color-avatar-4: light-dark(oklch(0.45 0.13 115), oklch(0.72 0.12 115));
218
+ --color-avatar-5: light-dark(oklch(0.45 0.13 145), oklch(0.72 0.12 145));
219
+ --color-avatar-6: light-dark(oklch(0.45 0.13 175), oklch(0.72 0.12 175));
220
+ --color-avatar-7: light-dark(oklch(0.45 0.13 205), oklch(0.72 0.12 205));
221
+ --color-avatar-8: light-dark(oklch(0.45 0.13 235), oklch(0.72 0.12 235));
222
+ --color-avatar-9: light-dark(oklch(0.45 0.13 265), oklch(0.72 0.12 265));
223
+ --color-avatar-10: light-dark(oklch(0.45 0.13 295), oklch(0.72 0.12 295));
224
+ --color-avatar-11: light-dark(oklch(0.45 0.13 325), oklch(0.72 0.12 325));
225
+ --color-avatar-12: light-dark(oklch(0.45 0.13 355), oklch(0.72 0.12 355));
226
+
227
+ /* === LIVE / "NOW" ACCENT ===
228
+ Single-purpose accent for live/now markers — the Calendar current-time
229
+ line and dot, and future live/recording indicators. Red is the
230
+ convention (Google Calendar et al.), but the *purpose* is not `danger`:
231
+ a now-line states "you are here", not "something is wrong", and the two
232
+ must stay independently themeable. Defaults ride the danger foundation
233
+ ramp, so dark mode and per-theme danger re-tunes flow through
234
+ automatically; override `--color-live` alone to restyle live indicators
235
+ without touching error styling. */
236
+ --color-live: light-dark(var(--color-danger-500), var(--color-danger-400));
237
+
155
238
  /* === BORDERS ===
156
239
  border-hairline is the quietest tier — structural
157
240
  trennlinien (row dividers, Card header/footer separators, inline
@@ -86,7 +86,9 @@
86
86
  --color-warning-400: oklch(0.66 0.14 60);
87
87
  --color-warning-500: oklch(0.75 0.15 60);
88
88
  --color-warning-600: oklch(0.65 0.15 60);
89
- --color-warning-700: oklch(0.55 0.13 60);
89
+ /* 0.59 mirrors foundation.css: light-mode press fill under the dark
90
+ text-on-warning — hue 60 is the binding case (0.58 = 4.46:1, under AA). */
91
+ --color-warning-700: oklch(0.59 0.13 60);
90
92
  --color-warning-800: oklch(0.45 0.11 60);
91
93
  --color-warning-900: oklch(0.25 0.08 60);
92
94
  --color-warning-950: oklch(0.15 0.05 60);
@@ -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);
@@ -73,7 +73,9 @@
73
73
  --color-warning-400: oklch(0.66 0.14 92);
74
74
  --color-warning-500: oklch(0.75 0.15 92);
75
75
  --color-warning-600: oklch(0.65 0.15 92);
76
- --color-warning-700: oklch(0.55 0.13 92);
76
+ /* 0.59 mirrors foundation.css: light-mode press fill under the dark
77
+ text-on-warning (AA floor across all themed hues). */
78
+ --color-warning-700: oklch(0.59 0.13 92);
77
79
  --color-warning-800: oklch(0.45 0.11 92);
78
80
  --color-warning-900: oklch(0.25 0.08 92);
79
81
  --color-warning-950: oklch(0.15 0.05 92);
@@ -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
+ }
@@ -99,7 +99,7 @@ const foundationColors = {
99
99
  '400': 'oklch(0.66 0.14 80)',
100
100
  '500': 'oklch(0.75 0.15 80)',
101
101
  '600': 'oklch(0.65 0.15 80)',
102
- '700': 'oklch(0.55 0.13 80)',
102
+ '700': 'oklch(0.59 0.13 80)',
103
103
  '800': 'oklch(0.45 0.11 80)',
104
104
  '900': 'oklch(0.25 0.08 80)',
105
105
  '950': 'oklch(0.15 0.05 80)'
@@ -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)',
@@ -155,6 +155,10 @@ const semanticTokens = {
155
155
  disabled: { value: '{color.neutral.300}', description: 'Disabled text' },
156
156
  'on-dark': { value: '{color.neutral.0}', description: 'Text on dark surfaces' },
157
157
  'on-primary': { value: '{color.neutral.0}', description: 'Text on primary color bg' },
158
+ 'on-warning': {
159
+ value: '{color.warning.950}',
160
+ description: 'Text on the warning fill (warm dark in both modes)'
161
+ },
158
162
  'on-surface': { value: '{color.neutral.900}', description: 'Text on tinted surfaces' }
159
163
  },
160
164
  border: {
@@ -96,10 +96,12 @@ const ROUTE_TARGET_TIMEOUT_MS = 4000;
96
96
  /**
97
97
  * Heuristic: does `landed` look like a router-normalized form of `route` — a trailing slash
98
98
  * (`/expenses/`) or a base/locale prefix (`/de/expenses`) — rather than an unrelated redirect target
99
- * (`/login`)? Used ONLY to decide whether a *synchronous* own-navigation landing on a non-exact path
100
- * deserves a DEV diagnostic, never to change tour behavior, so a wrong guess merely over- or
101
- * under-warns in DEV. `route` is a normalized pathname (leading `/`), so the suffix test lands on a
102
- * real segment boundary `/myexpenses` does not end with `/expenses`.
99
+ * (`/login`)? On the *synchronous* (re-entrant) path it only tunes DEV diagnostics — `#selfNavigating`
100
+ * is the causal signal there. On the *asynchronous* path (the default source) there is no causal
101
+ * signal, so this heuristic decides own-vs-foreign for a pending navigation: a normalized-looking
102
+ * landing keeps the tour running (DEV-warned, since it is a guess), an unrelated one stops it.
103
+ * `route` is a normalized pathname (leading `/`), so the suffix test lands on a real segment
104
+ * boundary — `/myexpenses` does not end with `/expenses`.
103
105
  */
104
106
  function looksLikeNormalizedRoute(landed, route) {
105
107
  const stripSlash = (s) => (s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s);
@@ -191,9 +193,9 @@ export class GuideController {
191
193
  * `true` only while our own injected `#navigate()` call is on the stack. A `navigationSource` that
192
194
  * reports its location change **synchronously, re-entrant** during that call (SvelteKit's
193
195
  * `afterNavigate` fires inside `goto`) IS that very navigation — never a foreign one. This is the
194
- * exact signal; the `path === #expectedRoute` check in `#onLocationChange` is only an async-source
195
- * proxy for the same thing and breaks the moment the router reports a normalized path
196
- * (trailing slash, base/locale prefix) that doesn't equal the raw `step.route`.
196
+ * exact signal; the `#expectedRoute` matching in `#onLocationChange` (exact path, or a
197
+ * `looksLikeNormalizedRoute` landing for a router that normalizes) is only an async-source proxy
198
+ * for the same thing.
197
199
  *
198
200
  * A boolean (not a depth counter) is enough: the sole re-entrant callee of a sync source is
199
201
  * `#onLocationChange`, which never calls `#maybeNavigate`. A `navigate` hook that *synchronously*
@@ -202,6 +204,16 @@ export class GuideController {
202
204
  * never open, so it can't keep a genuinely foreign navigation alive.
203
205
  */
204
206
  #selfNavigating = false;
207
+ /**
208
+ * Own navigations superseded before their report arrived — a rapid `next()`/`prev()` while the
209
+ * previous step's navigation was still in flight moves its `#expectedRoute` here instead of
210
+ * dropping it, so the late report is still recognized as the tour's own (not a foreign stop).
211
+ * Entries live only until the *current* navigation settles (report, or target resolve) or the
212
+ * tour tears down — bounding the window in which a genuinely foreign visit to one of these paths
213
+ * would be misread as ours. A plain `Set` — pure bookkeeping, never rendered, so the `SvelteSet`
214
+ * rule doesn't apply.
215
+ */
216
+ #supersededRoutes = new Set();
205
217
  /** Last path the tour is known to be on — set at start, updated on each handled navigation. */
206
218
  #knownPath = '';
207
219
  /** Unsubscribe from the navigation source; non-null only while a route-using tour runs. */
@@ -487,8 +499,14 @@ export class GuideController {
487
499
  */
488
500
  #maybeNavigate() {
489
501
  // Each step starts with a clean slate — no navigation is pending until we trigger one below.
490
- // (A step that doesn't navigate must not inherit the previous step's `expectedRoute`.)
491
- this.#expectedRoute = null;
502
+ // (A step that doesn't navigate must not inherit the previous step's `expectedRoute`.) A
503
+ // pending navigation superseded here (rapid next/prev while it was still in flight) is
504
+ // remembered: its report may land *after* this step activates, and must still be read as the
505
+ // tour's own navigation — not a foreign one that stops the tour (async epoch race).
506
+ if (this.#expectedRoute !== null) {
507
+ this.#supersededRoutes.add(this.#expectedRoute);
508
+ this.#expectedRoute = null;
509
+ }
492
510
  const route = this.currentStep?.route;
493
511
  if (route == null)
494
512
  return;
@@ -539,27 +557,53 @@ export class GuideController {
539
557
  this.#expectedRoute = null;
540
558
  this.#clearRouteTargetWarning();
541
559
  }
560
+ /** Does `path` look like `route`'s landing — exact, or a router-normalized form of it? */
561
+ #isOwnLanding(path, route) {
562
+ return path === route || looksLikeNormalizedRoute(path, route);
563
+ }
542
564
  /**
543
565
  * React to a navigation observed while a route-using tour runs. A navigation that is the tour's own
544
- * — reported synchronously during our `#navigate()` call (`#selfNavigating`), or matching the
545
- * pending `#expectedRoute` keeps it running (the ring lands via the surface's
566
+ * — reported synchronously during our `#navigate()` call (`#selfNavigating`), matching the pending
567
+ * `#expectedRoute` (exactly, or as a router-normalized landing), or matching a navigation this tour
568
+ * triggered and then superseded — keeps it running (the ring lands via the surface's
546
569
  * `reapplyStepHighlight`). Any other navigation is foreign — the user left, or a
547
570
  * youngest-gesture-wins race — and tears the tour down, analytics-silent (`stopTour`).
548
571
  */
549
572
  #onLocationChange(path) {
550
573
  if (!this.#activeTour)
551
574
  return;
552
- if (path === this.#knownPath)
553
- return; // no pathname change (e.g. a hash/query update) ignore
575
+ if (path === this.#knownPath) {
576
+ // No pathname change (a hash/query update, or a re-report of the path we're already on).
577
+ // A same-path report can still BE the pending navigation's landing: two consecutive steps on
578
+ // the same logical route under a normalizing router re-navigate without the pathname ever
579
+ // changing. A *targetless* step settles on landing, so clear its expectation here too —
580
+ // skipping that (the pre-fix behavior) armed a misleading "navigated toward …" DEV warning
581
+ // on the next genuine foreign navigation. (A *targeted* step keeps the expectation until its
582
+ // target resolves, exactly like the main own-navigation branch below.)
583
+ if (this.#expectedRoute !== null &&
584
+ this.currentStep?.target == null &&
585
+ (this.#selfNavigating || this.#isOwnLanding(path, this.#expectedRoute))) {
586
+ this.#expectedRoute = null;
587
+ }
588
+ return;
589
+ }
554
590
  this.#knownPath = path;
555
- const matchesExpected = this.#expectedRoute !== null && path === this.#expectedRoute;
556
- // A location report is the tour's OWN navigation in two cases: it matches the pending
557
- // `#expectedRoute` (the async-source proxy, for a report landing a tick after the hook returns),
558
- // or it arrives synchronously, re-entrant, during our injected `#navigate()` call
591
+ const expected = this.#expectedRoute;
592
+ const matchesExpected = expected !== null && path === expected;
593
+ // With an *async* source (the default) `#selfNavigating` is already false by the time the report
594
+ // lands, so recognizing the tour's own navigation falls to path matching. Exact-only matching
595
+ // false-stopped the tour on a router-normalized landing (`/expenses/`, `/de/expenses`) for a raw
596
+ // `step.route` — the async form of #41 — so a normalized-looking landing for the pending route
597
+ // counts as ours too (a heuristic, hence DEV-warned below; the sync path has the causal signal
598
+ // and stays silent).
599
+ const matchesNormalized = expected !== null && !matchesExpected && looksLikeNormalizedRoute(path, expected);
600
+ // A location report is the tour's OWN navigation when it matches the pending `#expectedRoute`
601
+ // (exactly or normalized — the async-source proxy, for a report landing a tick after the hook
602
+ // returns), or when it arrives synchronously, re-entrant, during our injected `#navigate()` call
559
603
  // (`#selfNavigating`). The latter is a *causal* signal — our navigate produced this report,
560
- // whatever path it carries — so it covers a router-normalized landing (trailing slash,
561
- // base/locale prefix) that an exact match would miss. Either keeps the tour running.
562
- if (this.#selfNavigating || matchesExpected) {
604
+ // whatever path it carries — so it also covers a synchronous redirect an exact or normalized
605
+ // match would miss. Either keeps the tour running.
606
+ if (this.#selfNavigating || matchesExpected || matchesNormalized) {
563
607
  // A *synchronous* report on a path that isn't the expected route is still causally ours, but it
564
608
  // landed somewhere unexpected: a normalized variant of `step.route` (fine — the target anchors
565
609
  // once it mounts) or a genuine redirect off the route (an auth guard → `/login`). We keep
@@ -569,11 +613,20 @@ export class GuideController {
569
613
  // bubble is otherwise invisible in both DEV and PROD.)
570
614
  if (this.#dev &&
571
615
  this.#selfNavigating &&
572
- this.#expectedRoute !== null &&
616
+ expected !== null &&
573
617
  !matchesExpected &&
574
- !looksLikeNormalizedRoute(path, this.#expectedRoute)) {
575
- console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${this.#expectedRoute}" but synchronously landed on "${path}" — keeping the tour running as its own navigation. If this was a redirect off the step's route (not a normalized form of it), stop the tour explicitly from your router guard.`);
618
+ !matchesNormalized) {
619
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${expected}" but synchronously landed on "${path}" — keeping the tour running as its own navigation. If this was a redirect off the step's route (not a normalized form of it), stop the tour explicitly from your router guard.`);
576
620
  }
621
+ else if (this.#dev && !this.#selfNavigating && matchesNormalized) {
622
+ // The async normalized match is a guess (no causal signal) — keep running, but surface it so
623
+ // the consumer can make the match exact.
624
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${expected}" and landed on "${path}" — treating it as the router's normalized form of the step route and keeping the tour running. Set step.route to the router's actual landed path to make the match exact.`);
625
+ }
626
+ // The current navigation reported → any older superseded navigation can no longer report
627
+ // (navigation reports arrive in order), so drop those epochs — a later *foreign* visit to one
628
+ // of their paths must stop the tour again.
629
+ this.#supersededRoutes.clear();
577
630
  // For a *targeted* step, keep `#expectedRoute` set until the target settles
578
631
  // (`#applyStepHighlight`) so a redirecting / multi-hop source (the Navigation API emitting one
579
632
  // event per hop) firing a *second* event for the redirect target is still recognized as a
@@ -586,12 +639,20 @@ export class GuideController {
586
639
  this.#expectedRoute = null;
587
640
  return;
588
641
  }
589
- // Any other navigation stops the tour (analytics-silent). When one was pending, a landed path
590
- // that doesn't match is most often a normalized `step.route` (trailing slash, base/locale
591
- // prefix) rather than a genuine foreign nav so surface it instead of a silent teardown on a
592
- // one-character mismatch. (The strict stop is kept either way: better than running on a wrong page.)
593
- if (this.#dev && this.#expectedRoute !== null) {
594
- console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${this.#expectedRoute}" but landed on "${path}" — stopping as a foreign navigation. If "${path}" is just a normalized form of the step's route (trailing slash, base/locale prefix), set step.route to the router's actual path.`);
642
+ // A late report of a navigation this tour triggered and then superseded (rapid next/prev while
643
+ // it was still in flight) is causally ours keep running and keep waiting for the *current*
644
+ // navigation's own report. Silent: the route was recorded causally in `#maybeNavigate`; only
645
+ // its normalization is heuristic. (Trade-off, bounded by the in-flight window: a genuinely
646
+ // foreign visit to one of these paths during that window is misread as ours.)
647
+ for (const superseded of this.#supersededRoutes) {
648
+ if (this.#isOwnLanding(path, superseded))
649
+ return;
650
+ }
651
+ // Any other navigation stops the tour (analytics-silent). When one was pending, surface the
652
+ // mismatch in DEV instead of tearing down silently — with normalized landings recognized above,
653
+ // reaching this with a pending route means a genuinely unrelated landing (e.g. a redirect).
654
+ if (this.#dev && expected !== null) {
655
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${expected}" but landed on "${path}" — stopping as a foreign navigation (the landed path is not a normalized form of the step's route either).`);
595
656
  }
596
657
  this.#expectedRoute = null;
597
658
  this.stopTour();
@@ -640,6 +701,7 @@ export class GuideController {
640
701
  this.#navUnsub?.();
641
702
  this.#navUnsub = null;
642
703
  this.#expectedRoute = null;
704
+ this.#supersededRoutes.clear();
643
705
  this.clearHighlight();
644
706
  this.#tourUnregister?.();
645
707
  this.#tourUnregister = null;
@@ -691,6 +753,7 @@ export class GuideController {
691
753
  this.highlight(step.target, { scroll: true });
692
754
  this.#clearRouteTargetWarning(); // target landed → no "never appeared" warning needed
693
755
  this.#expectedRoute = null; // the navigated-to target resolved → navigation fully settled
756
+ this.#supersededRoutes.clear(); // …and with it every older, superseded navigation epoch
694
757
  }
695
758
  else {
696
759
  this.clearHighlight();