@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.
- package/README.md +14 -0
- package/dist/components/Calendar/CalendarTimeGrid.svelte +2 -1
- package/dist/components/Calendar/calendar.variants.d.ts +63 -63
- package/dist/components/Calendar/calendar.variants.js +5 -3
- package/dist/components/CommandPalette/CommandPalette.svelte +32 -16
- package/dist/components/CommandPalette/CommandPalette.svelte.d.ts +1 -1
- package/dist/components/CommandPalette/commandPalette.variants.d.ts +10 -0
- package/dist/components/CommandPalette/commandPalette.variants.js +9 -7
- package/dist/components/CommandPalette/index.d.ts +19 -0
- package/dist/components/CompositionBar/CompositionBar.svelte +2 -2
- package/dist/components/CompositionBar/composition-bar.variants.js +1 -1
- package/dist/components/Planner/planner.variants.js +1 -1
- package/dist/components/Sankey/sankey.variants.js +1 -1
- package/dist/i18n/index.d.ts +378 -2
- package/dist/internal/charts/variants.js +2 -2
- package/dist/internal/date-grid/DateGridScaffold.svelte +9 -2
- package/dist/internal/date-grid/date-grid.svelte.d.ts +4 -2
- package/dist/internal/date-grid/date-grid.svelte.js +63 -7
- package/dist/primitives/Alert/alert.variants.d.ts +8 -8
- package/dist/primitives/Alert/alert.variants.js +1 -1
- package/dist/primitives/Avatar/Avatar.svelte +38 -24
- package/dist/primitives/Avatar/index.d.ts +1 -1
- package/dist/primitives/Badge/badge.variants.js +1 -1
- package/dist/primitives/Button/Button.svelte +12 -25
- package/dist/primitives/Button/button.variants.js +2 -2
- package/dist/primitives/ButtonGroup/ButtonGroup.svelte +5 -1
- package/dist/primitives/Checkbox/checkbox.variants.js +2 -2
- package/dist/primitives/Collapsible/Collapsible.svelte +10 -0
- package/dist/primitives/Collapsible/index.d.ts +13 -2
- package/dist/primitives/ConfirmDialog/ConfirmDialog.svelte +14 -0
- package/dist/primitives/ConfirmDialog/index.d.ts +13 -2
- package/dist/primitives/Dialog/Dialog.svelte +36 -8
- package/dist/primitives/Drawer/Drawer.svelte +34 -7
- package/dist/primitives/Input/Input.svelte +7 -1
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +22 -22
- package/dist/primitives/JourneyTimeline/journey-timeline.variants.js +3 -3
- package/dist/primitives/Menu/Menu.svelte +8 -0
- package/dist/primitives/Menu/index.d.ts +11 -13
- package/dist/primitives/Popover/Popover.svelte +107 -4
- package/dist/primitives/Popover/index.d.ts +15 -2
- package/dist/primitives/Popover/index.js +1 -1
- package/dist/primitives/Popover/popover.variants.d.ts +25 -0
- package/dist/primitives/Popover/popover.variants.js +35 -0
- package/dist/primitives/Progress/progress.variants.js +1 -1
- package/dist/primitives/RadioGroup/radioGroup.variants.js +2 -2
- package/dist/primitives/Select/index.d.ts +8 -1
- package/dist/primitives/Slider/Slider.svelte +32 -3
- package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
- package/dist/primitives/Stepper/stepper.variants.d.ts +11 -11
- package/dist/primitives/Stepper/stepper.variants.js +2 -2
- package/dist/primitives/Textarea/Textarea.svelte +5 -1
- package/dist/primitives/Toast/toast.variants.d.ts +12 -12
- package/dist/primitives/Tooltip/tooltip.variants.d.ts +3 -3
- package/dist/primitives/Tooltip/tooltip.variants.js +14 -3
- package/dist/style/foundation.css +41 -7
- package/dist/style/interaction.css +19 -2
- package/dist/style/semantic.css +84 -1
- package/dist/style/themes/forest.css +3 -1
- package/dist/style/themes/neutral.css +10 -1
- package/dist/style/themes/ocean.css +5 -1
- package/dist/style/themes/sunset.css +3 -1
- package/dist/utils/compose-handlers.d.ts +72 -0
- package/dist/utils/compose-handlers.js +65 -0
- package/dist/utils/figma-token-export.js +6 -2
- package/dist/utils/guide.svelte.js +92 -29
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/overlay-tokens.d.ts +17 -0
- package/dist/utils/overlay-tokens.js +30 -0
- package/dist/utils/overlay.js +24 -2
- package/dist/utils/use-floating-panel.svelte.js +9 -3
- package/dist/utils/variants.js +5 -1
- 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
|
|
56
|
-
AA (4.5:1) on the solid intent backgrounds
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
102
|
-
|
|
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
|
package/dist/style/semantic.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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`)?
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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
|
-
|
|
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`),
|
|
545
|
-
*
|
|
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
|
-
|
|
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
|
|
556
|
-
|
|
557
|
-
//
|
|
558
|
-
//
|
|
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
|
|
561
|
-
//
|
|
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
|
-
|
|
616
|
+
expected !== null &&
|
|
573
617
|
!matchesExpected &&
|
|
574
|
-
!
|
|
575
|
-
console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${
|
|
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
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
|
|
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();
|