@urbicon-ui/blocks 6.28.0 → 6.29.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 +5 -3
- package/docs/GUIDE.md +480 -0
- package/docs/MIGRATION-v5.md +118 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -121,6 +121,8 @@ bun run test # Run tests (from root)
|
|
|
121
121
|
|
|
122
122
|
## Related
|
|
123
123
|
|
|
124
|
-
- [docs/
|
|
125
|
-
- [docs/
|
|
126
|
-
- [docs/
|
|
124
|
+
- [docs/GUIDE.md](./docs/GUIDE.md) — Guide system (help panel, hints, UI↔guide linking, guided tour): architecture + as-built contract. Ships in this package.
|
|
125
|
+
- [docs/MIGRATION-v5.md](./docs/MIGRATION-v5.md) — v4 → v5 consumer migration guide. Ships in this package.
|
|
126
|
+
- [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) — Token system, Mint, Preset-System, i18n (monorepo only)
|
|
127
|
+
- [docs/COMPONENT-API-CONVENTIONS.md](../../docs/COMPONENT-API-CONVENTIONS.md) — Props, callbacks, styling patterns (monorepo only)
|
|
128
|
+
- [docs/ComponentStructureStandard.md](../../docs/ComponentStructureStandard.md) — File structure, index.ts, variants.ts (monorepo only)
|
package/docs/GUIDE.md
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# Guide System
|
|
2
|
+
|
|
3
|
+
The **`Guide`** component family is a bidirectional in-app help system: a non-modal help
|
|
4
|
+
panel, contextual hints, UI↔guide links, and an opt-in guided tour — all over *one* headless
|
|
5
|
+
engine. Shipped in **v5.8.0**, stability **beta**. Lives on `@urbicon-ui/blocks` (no separate
|
|
6
|
+
package).
|
|
7
|
+
|
|
8
|
+
This is the living architecture/usage reference, shipped in the npm tarball
|
|
9
|
+
(`docs/GUIDE.md`). The monorepo's broader conventions (token system, component
|
|
10
|
+
families, decision matrices, Svelte 5 patterns) live in the repo's `docs/`
|
|
11
|
+
directory; where a decision below depends on one of them, the relevant rule is
|
|
12
|
+
restated in place.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. What it is
|
|
17
|
+
|
|
18
|
+
`Guide` is the library's first **sequential, system-driven** overlay pattern. Every other
|
|
19
|
+
overlay (Tooltip, Popover, Dialog, Menu) is *spatial and event-driven* — the user clicks,
|
|
20
|
+
something appears *there*. `Guide` inverts that: the system decides *when*, *in what order*,
|
|
21
|
+
and *in which mode* help appears.
|
|
22
|
+
|
|
23
|
+
The value is **not** the tour mechanic (product tours are rightly distrusted) but three design
|
|
24
|
+
principles:
|
|
25
|
+
|
|
26
|
+
1. **Helpful, not intrusive.** The defaults are the *callable* help panel and the *waiting*
|
|
27
|
+
hint — not a modal forced tour. The scrim-modal guided tour is the most aggressive mode and
|
|
28
|
+
stays **opt-in**.
|
|
29
|
+
2. **One shared namespace.** Every UI spot with a help identity carries `data-guide="<topic-id>"`.
|
|
30
|
+
Tours, markers, hints, and article references all resolve through that one namespace — it
|
|
31
|
+
connects the modes instead of duplicating them.
|
|
32
|
+
3. **Bidirectional.** UI ↔ Guide is linkable in both directions (§4) — the actual
|
|
33
|
+
differentiator versus Joyride/driver.js/Shepherd.
|
|
34
|
+
|
|
35
|
+
### Modes (surfaces over one engine)
|
|
36
|
+
|
|
37
|
+
| Mode | Intrusiveness | Purpose |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| **Sidebar help panel** (`GuidePanel`) | callable, self-directed | Structured in-app reference (the main focus) |
|
|
40
|
+
| **Contextual hint** (`GuideHint`) | waiting, non-blocking | Micro-help at the right element at the right time |
|
|
41
|
+
| **Bidirectional link** (`GuideMarker` ↔ `GuideMention`) | passive | UI explains itself; the article points back at the UI |
|
|
42
|
+
| **Guided tour + spotlight** (`Guide`, `GuideBeacon`) | modal/blocking | Critical first-run flows, **opt-in** |
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 2. Architecture
|
|
47
|
+
|
|
48
|
+
### Headless engine + swappable surfaces
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
┌──────────────────────────────────────────────┐
|
|
52
|
+
│ GuideController (utils/guide.svelte.ts) │
|
|
53
|
+
│ Class · $state · untrack · SvelteMap/Set │
|
|
54
|
+
│ │
|
|
55
|
+
│ #targets: SvelteMap<id, RegisteredTarget> │
|
|
56
|
+
│ activeTour · stepIndex · highlightedId │
|
|
57
|
+
│ panelOpen · activeArticle · panelId · #seen │
|
|
58
|
+
│ │
|
|
59
|
+
│ registerTarget()/target() · resolveTarget() │
|
|
60
|
+
│ startTour() next() prev() skip() finish() │
|
|
61
|
+
│ stopTour() · reapplyStepHighlight() │
|
|
62
|
+
│ highlight(id) / clearHighlight() │
|
|
63
|
+
│ openPanel(article?) / closePanel() │
|
|
64
|
+
│ hasSeen()/markSeen()/resetSeen() ← Storage │
|
|
65
|
+
└───────────────────────┬──────────────────────┘
|
|
66
|
+
createOptionalContext<GuideController>()
|
|
67
|
+
▼
|
|
68
|
+
Surfaces (consume the controller via context):
|
|
69
|
+
• GuidePanel + GuideArticle — non-modal help panel (main focus)
|
|
70
|
+
• GuideMarker — direction A: UI → panel (ⓘ trigger)
|
|
71
|
+
• GuideMention — direction B: article → UI highlight (additive ring)
|
|
72
|
+
• GuideHint — contextual, waiting hint
|
|
73
|
+
• Guide + GuideBeacon — guided tour + spotlight (opt-in)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Engine** (`packages/blocks/src/lib/utils/guide.svelte.ts`) is a `class` modeled on
|
|
77
|
+
`overlay-stack.svelte.ts` (`$state` + `untrack`, no string context keys). **Completely
|
|
78
|
+
UI-free** and unit-tested in isolation (`guide.svelte.test.ts`, node env). This is the
|
|
79
|
+
valuable part.
|
|
80
|
+
- **Surfaces** are thin views in `packages/blocks/src/lib/components/Guide/*` that consume the
|
|
81
|
+
engine via `createOptionalContext` — a surface used **without** a `GuideProvider` renders
|
|
82
|
+
inert (or as plain text for `GuideMention`) instead of throwing `missing_context`.
|
|
83
|
+
- **No separate package.** The UI depends fundamentally on `floating`/`Portal`/`overlay-stack`
|
|
84
|
+
(all in `blocks`); a separate package would only create peer-dependency friction (same reason
|
|
85
|
+
`CommandPalette` stayed in `blocks`).
|
|
86
|
+
|
|
87
|
+
### Boundary: engine/UI in the library, content in the consumer app
|
|
88
|
+
|
|
89
|
+
The library ships **engine + presentation**. The guide *definition* (which steps, which texts,
|
|
90
|
+
which selectors, which articles) belongs in the **consumer app**. This keeps consumer i18n
|
|
91
|
+
content out of the library and prevents over-engineering. `GuideTour` / `GuideStep` /
|
|
92
|
+
`GuideArticle` content is authored by the consumer.
|
|
93
|
+
|
|
94
|
+
### Reused building blocks (zero-dependency contract)
|
|
95
|
+
|
|
96
|
+
`packages/blocks` has no runtime dependencies, so an external tour library (driver.js, Shepherd)
|
|
97
|
+
is out — and unnecessary, because the expensive parts already exist:
|
|
98
|
+
|
|
99
|
+
| Guide needs… | Existing building block |
|
|
100
|
+
|---|---|
|
|
101
|
+
| Anchor a bubble/hint to a target (flip/shift/arrow) | `utils/floating.ts` (`computePosition` + `autoUpdate`) |
|
|
102
|
+
| Coexist with / pause around foreign overlays | `utils/overlay-stack.svelte.ts` (`depth`, `isTop`, `register`) |
|
|
103
|
+
| Position the panel as a sidebar | `Sidebar` mechanics + `Drawer` styling (D1) |
|
|
104
|
+
| Localize next/prev/skip/done | `@urbicon-ui/i18n` (runes-based, keys under `guide.*`) |
|
|
105
|
+
| Engine state (class + `$state` + `untrack`) | model: `overlay-stack.svelte.ts` |
|
|
106
|
+
|
|
107
|
+
**Genuinely new:** only the **engine/registry** (state machine + topic resolution + persistence)
|
|
108
|
+
and the **spotlight mask** for the tour. Everything else is composition.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 3. The `data-guide` namespace
|
|
113
|
+
|
|
114
|
+
Two ways to mark an element as a guide target, both feeding the **same** registry — so a tour
|
|
115
|
+
step, a hint, a marker, and a mention can all point at one id:
|
|
116
|
+
|
|
117
|
+
1. **`data-guide="topic-id"`** — declarative, framework-agnostic, robust, placeable even on
|
|
118
|
+
elements the consumer doesn't render itself. Resolved via `querySelector` fallback.
|
|
119
|
+
2. **`{@attach controller.target('topic-id', { label, article, direction })}`** — programmatic,
|
|
120
|
+
carries metadata, reactive binding, auto-cleanup (`{@attach}`, **not** `use:`).
|
|
121
|
+
|
|
122
|
+
A single `highlight(topicId)` in the engine serves **both** tour steps **and** the Mention→UI
|
|
123
|
+
hover — one mechanism, multiple surfaces.
|
|
124
|
+
|
|
125
|
+
**Two layers (D3).** The `data-guide` anchors are the **anchor layer** (*where* an element is —
|
|
126
|
+
necessarily declarative & distributed). On top sits an optional, typed **structure layer**: a
|
|
127
|
+
`defineGuide([...])` manifest describing *which* tours/articles exist in *what* order, referencing
|
|
128
|
+
`data-guide` ids as a string union. The manifest does **not** resolve targets itself — that
|
|
129
|
+
separation is what makes the DEV-mode warning valuable (manifest names id X, engine can't find X
|
|
130
|
+
in the DOM → warning). Repo precedent: `TypedColumnBuilder` (table columns), `createPackageI18n`
|
|
131
|
+
(i18n).
|
|
132
|
+
|
|
133
|
+
**Resilience.** In DEV, any tour step / mention / highlight pointing at an id that can't be
|
|
134
|
+
resolved logs a warning instead of failing silently. A lazily-rendered target is observed and
|
|
135
|
+
re-anchored once it appears; a vanished one falls back gracefully (§6).
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 4. Bidirectional link (the differentiator)
|
|
140
|
+
|
|
141
|
+
Both directions share the `data-guide` namespace and the same `highlight()` primitive.
|
|
142
|
+
Per topic/surface configurable: `direction?: 'to-guide' | 'to-ui' | 'both'` (default `both`) —
|
|
143
|
+
so uni- or bidirectional is a deliberate option, resolved by `resolveDirection(id, override?)`
|
|
144
|
+
(a surface-level override wins over the topic's registered direction).
|
|
145
|
+
|
|
146
|
+
**Direction A — UI → Guide** (`GuideMarker`): a discreet "ⓘ" trigger on a UI element. Activation
|
|
147
|
+
(click/Enter/Space) opens the `GuidePanel` at the matching article. A real `<button>` with
|
|
148
|
+
`aria-controls` (the panel's published id) + `aria-expanded`. Inert when the direction is
|
|
149
|
+
`'to-ui'`. → *"I see this element and want to know what it does."*
|
|
150
|
+
|
|
151
|
+
**Direction B — Guide → UI** (`GuideMention`): an inline reference inside a `GuideArticle`. Hover
|
|
152
|
+
**or focus** (keyboard parity) highlights the real element (`[data-guide="…"]`) with a light
|
|
153
|
+
ring/glow + optional scroll-into-view; clicking scrolls it into view. Degrades to plain text when
|
|
154
|
+
the direction is `'to-guide'`. → *"I'm reading the article and want to see where the element is."*
|
|
155
|
+
|
|
156
|
+
**Article → article** (`GuideRef`): an inline link inside a `GuideArticle` body that navigates the
|
|
157
|
+
panel to another article (`setArticle`) — the help-internal analogue of `GuideMention`. It is *not*
|
|
158
|
+
a `data-guide`/highlight link: it resolves against the panel's article registry (`hasArticle`), so
|
|
159
|
+
it degrades to plain text for an unknown article id (or without a provider/panel). → *"I'm reading
|
|
160
|
+
this article and want to jump to a related one."*
|
|
161
|
+
|
|
162
|
+
**Highlight principle (D5):** **Tour = subtractive** (full scrim, the surroundings disappear)
|
|
163
|
+
· **Highlight = additive** (an `outline` ring + soft glow, the target is emphasized, nothing is
|
|
164
|
+
dimmed). Direction B uses only the additive ring via `outline` (not `border` → no layout shift at
|
|
165
|
+
the target), `prefers-reduced-motion`-aware. The full scrim is reserved for the spotlight tour.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 5. Component inventory
|
|
170
|
+
|
|
171
|
+
| Name | `@tag`s | Role |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `GuideProvider` | overlay, feedback | Context root; instantiates `GuideController`, injects the StorageAdapter (analogous to `BlocksProvider`) |
|
|
174
|
+
| `GuidePanel` | overlay, navigation | Callable, **non-modal** sidebar help panel |
|
|
175
|
+
| `GuideArticle` | display | Structured article in the panel (contains `GuideMention`s); optional `group` buckets it under a section header in the index |
|
|
176
|
+
| `GuideMention` | navigation | Inline article→UI reference (direction B) |
|
|
177
|
+
| `GuideRef` | navigation | Inline article→article link inside an article body (panel-internal navigation) |
|
|
178
|
+
| `GuideMarker` | action, feedback | "ⓘ" UI→panel trigger (direction A) — *not* a status `Badge` |
|
|
179
|
+
| `GuideHint` | overlay, feedback | Contextual, non-blocking hint at an element |
|
|
180
|
+
| `Guide` | overlay, feedback | Tour renderer: bubble (`floating.ts`) + spotlight mask |
|
|
181
|
+
| `GuideBeacon` | feedback, action | Waiting hotspot — unobtrusive tour entry point |
|
|
182
|
+
|
|
183
|
+
Plus `controller.target(id, meta?)` — the attachment that registers/binds an element
|
|
184
|
+
programmatically.
|
|
185
|
+
|
|
186
|
+
Every `*Props` interface carries the mandatory JSDoc (`@description`, `@tag`, `@related`,
|
|
187
|
+
`@stability beta`) — the single source for the MCP server, `llms.txt`, and the docs site.
|
|
188
|
+
The eight surfaces additionally carry `@standalone`, which gives each its own MCP-catalog
|
|
189
|
+
entry and `llm.txt` (`find_components("guide")` lists all nine; `get_component("guide-panel")`
|
|
190
|
+
etc. work) despite the family sharing one `index.ts` and one docs page — unlike compound
|
|
191
|
+
subcomponents (TabItem, MenuItem), which stay folded into their directory component's entry.
|
|
192
|
+
|
|
193
|
+
**Family classification (D6):** Guide is a standalone **component family** (like `CommandPalette`,
|
|
194
|
+
`DatePicker`) — deliberately **not** a row in the six-family primitives taxonomy (the monorepo's
|
|
195
|
+
COMPONENT-FAMILIES.md, which assigns border-token source + tier behaviour per family). The
|
|
196
|
+
surfaces carry `@tag`s; the architecture lives here.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 6. Tokens, z-index & top-layer discipline
|
|
201
|
+
|
|
202
|
+
`Dialog` uses a native `<dialog>` → a real top layer. A portaled tour bubble with an ordinary
|
|
203
|
+
z-index would vanish *behind* an open dialog. Therefore:
|
|
204
|
+
|
|
205
|
+
- Active tours/spotlight register in the `overlayStack`.
|
|
206
|
+
- The engine **pauses/dims** (`paused` getter) when `overlayStack.depth` grows from a *foreign*
|
|
207
|
+
overlay; non-modal surfaces (`GuideHint`) hide while `overlayDepth > 0`.
|
|
208
|
+
- z-index is token-only. **`--z-guide: 1550`** (D4), between `--z-popover` (1500) and
|
|
209
|
+
`--z-skiplink` (1600), drives the spotlight tour. `GuidePanel` inherits `--z-sidebar` (1350),
|
|
210
|
+
`GuideHint` `--z-popover` (1500). The stacking guarantee versus a native `<dialog>` (browser
|
|
211
|
+
top layer) comes from the `overlayStack.depth` pause, **not** from z-index.
|
|
212
|
+
|
|
213
|
+
Tunable tokens (defined in `blocks/src/lib/style/interaction.css`, the `[data-guide-highlight]`
|
|
214
|
+
rule in `index.css`):
|
|
215
|
+
|
|
216
|
+
- `--blocks-guide-scrim` (default `oklch(0 0 0 / 0.5)`) — the tour's dimming backdrop.
|
|
217
|
+
- `--blocks-guide-highlight-ring` (default `--color-primary`) — the additive Mention→UI ring.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 7. Key decisions (D1–D6)
|
|
222
|
+
|
|
223
|
+
- **D1 — Panel placement: a standalone, *non-modal* `GuidePanel`.** It takes the `Sidebar`
|
|
224
|
+
mechanics (coexists with the app) and only the `Drawer` styling/`slotClasses` pattern. `Drawer`
|
|
225
|
+
is modal (`<dialog>.showModal()`, backdrop, focus trap) — that would break direction B (§4),
|
|
226
|
+
which must hover a mention *and simultaneously* highlight the element behind the panel.
|
|
227
|
+
- **D2 — Marker naming: `GuideMarker`.** The interactive "ⓘ" is **not** a "Badge" (collision with
|
|
228
|
+
the non-interactive status `Badge` primitive). It may still *look* like "ⓘ".
|
|
229
|
+
- **D3 — Topic definition: declarative anchors + a typed manifest (both, layered).** Anchors stay
|
|
230
|
+
declarative & distributed; structure (tours/articles/order) comes from an optional typed
|
|
231
|
+
`defineGuide([...])` manifest referencing `data-guide` ids (§3).
|
|
232
|
+
- **D4 — z-index: token `--z-guide: 1550`** for the spotlight bubble; stacking vs. native
|
|
233
|
+
`<dialog>` comes from the `overlayStack.depth` pause, not z-index (§6).
|
|
234
|
+
- **D5 — Direction-B highlight: additive `outline` ring, no scrim.** Tour = subtractive, highlight
|
|
235
|
+
= additive; `outline` (not `border`) → no layout shift; reduced-motion-aware (§4).
|
|
236
|
+
- **D6 — Family: standalone component family, not a row in the primitives table** (§5).
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 8. As-built contract (deviations now part of the API)
|
|
241
|
+
|
|
242
|
+
The build deviated from the original plan in ways that are now the intended contract:
|
|
243
|
+
|
|
244
|
+
- **`createOptionalContext`** (not `createContext`): surfaces render inert without a provider.
|
|
245
|
+
- **Controller-driven panel visibility** (`panelOpen`), not `bind:open` — single source of truth.
|
|
246
|
+
The panel uses `inert` (not `aria-hidden`) when closed, and focus-sensitive Escape (non-modal
|
|
247
|
+
courtesy). Closing from *within* returns focus to the opener (`aria-labelledby` on the header).
|
|
248
|
+
- **Native Popover API top-layer** (not `Portal`) for `GuideHint` and `Guide` — mirrors
|
|
249
|
+
`Tooltip`/`Popover`. The tour is one `popover="manual"` shell (`pointer-events:none`) with an
|
|
250
|
+
SVG even-odd scrim **and** the bubble inside.
|
|
251
|
+
- **`GuideMarker` opens on activation** (click/Enter/Space), not on bare focus (tab-friendly).
|
|
252
|
+
`GuideMention` has **no** `aria-describedby` (the visible text is the accessible name).
|
|
253
|
+
- **Tour progress = dot row + "Step X of Y"** (not the `Stepper` primitive — too heavy for a
|
|
254
|
+
~22rem bubble). Step announcement via an always-present polite `aria-live` region (a sibling of
|
|
255
|
+
the popover, so the first step is announced as a content *change*). Interactive steps use a
|
|
256
|
+
two-zone Tab cycle (bubble ⇄ target) so the spotlit target stays keyboard-reachable; `aria-modal`
|
|
257
|
+
is dropped on those steps.
|
|
258
|
+
- **Spotlight = SVG even-odd hole** following the target via `autoUpdate`; a non-interactive step
|
|
259
|
+
lays a transparent blocker `<rect>` over the hole, an interactive step leaves it click-through.
|
|
260
|
+
- **Analytics hooks** on `GuideTour`: `onStep` / `onComplete` / `onSkip` (the real business value —
|
|
261
|
+
the onboarding funnel + drop-off). Fired from the engine, defensively (a throwing callback can't
|
|
262
|
+
corrupt tour state). `stopTour()` is analytics-silent (programmatic teardown, e.g. route change).
|
|
263
|
+
- **Action-gated steps** (`GuideStep.advance: 'action'`, default `'user'`): learning-by-doing —
|
|
264
|
+
the footer's Next is rendered `aria-disabled` (still focusable, with an `aria-describedby`
|
|
265
|
+
screen-reader hint, i18n `guide.actionRequired`) and ArrowRight is inert; the app advances
|
|
266
|
+
imperatively via `controller.next()` once the user performed the real action (pair with
|
|
267
|
+
`interactive: true`). The gate lives purely in the `Guide` renderer — the engine's `next()`
|
|
268
|
+
is never blocked. Back/Skip stay available; the live region announces the gate per step.
|
|
269
|
+
- **Phase-8 hardening:** `observeTargetResolution` (rAF-coalesced MutationObserver) +
|
|
270
|
+
`reapplyStepHighlight()` re-anchor and re-spotlight a target that renders *after* a step begins,
|
|
271
|
+
and fall back to the centered scrim when one vanishes. The tour exit fades via a
|
|
272
|
+
`view = liveView ?? heldView` snapshot that holds the last step's content through the popover
|
|
273
|
+
transition. `GuidePanel` returns focus to its opener on close.
|
|
274
|
+
- **Declarative cross-route touring** (`GuideStep.route` + `GuideControllerOptions.navigate`,
|
|
275
|
+
also forwarded by `GuideProvider.navigate`): a step can name the route it lives on, and the
|
|
276
|
+
controller navigates there (via the injected, framework-agnostic hook) **before** the spotlight,
|
|
277
|
+
re-anchoring through the same `reapplyStepHighlight` once the target appears. All the logic lives
|
|
278
|
+
in the engine — the renderer is unchanged. A tour-internal navigation (tracked via an internal
|
|
279
|
+
`expectedRoute`) keeps the tour running; a foreign one stops it analytics-silent (`stopTour`), so
|
|
280
|
+
the manual-`goto` recipe is preserved by subscribing to navigation **only** for tours that declare
|
|
281
|
+
a `route`. The current path + navigation events come from an injectable `navigationSource`
|
|
282
|
+
(default: Navigation API with a `popstate` fallback; the escape hatch for a base path / custom
|
|
283
|
+
router). `prev()` navigates back symmetrically; a missing hook or a never-appearing target DEV-warns
|
|
284
|
+
without hanging or crashing (§9).
|
|
285
|
+
- **Panel index scales with the catalog:** `GuideArticle.group` buckets the index under section
|
|
286
|
+
headers (a `<ul aria-labelledby>` per section in first-occurrence order; ungrouped articles in
|
|
287
|
+
one headerless block; a flat list when no article sets a group — unchanged for existing
|
|
288
|
+
consumers). `GuidePanel.searchable` adds an opt-in case-insensitive title filter that runs
|
|
289
|
+
*before* grouping (non-empty sections keep their headers, empty ones disappear; an empty result
|
|
290
|
+
shows the i18n `guide.noResults` status). `GuideRef` (§4) is the inline article→article link,
|
|
291
|
+
resolving against the panel's article registry (`hasArticle`) so it degrades to plain text for an
|
|
292
|
+
unknown id — hydration-safe because the registry is empty during SSR and the first client render
|
|
293
|
+
alike, then reactively upgrades the `<span>` to a `<button>`.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## 9. Cross-route tours
|
|
298
|
+
|
|
299
|
+
Tours survive client-side navigation by construction: the controller lives in the layout's
|
|
300
|
+
`GuideProvider` and outlives route components; an unresolved step target falls back gracefully to
|
|
301
|
+
the centered, full-scrim bubble; and the `MutationObserver` in `Guide.svelte` re-anchors as soon
|
|
302
|
+
as the new route's `data-guide` element appears (the lazy-target hardening, §8). On top of that,
|
|
303
|
+
a step can declare the route it lives on and let the library drive the navigation.
|
|
304
|
+
|
|
305
|
+
### Declarative (`GuideStep.route` + `navigate` hook) — the supported pattern
|
|
306
|
+
|
|
307
|
+
Give the step its `route` and wire a `navigate` hook once (UIB stays framework-agnostic, so it
|
|
308
|
+
never imports your router). When a step becomes active and its `route` differs from the current
|
|
309
|
+
location, the controller navigates **before** the spotlight, then re-anchors once the target
|
|
310
|
+
appears on the new page:
|
|
311
|
+
|
|
312
|
+
```svelte
|
|
313
|
+
<script lang="ts">
|
|
314
|
+
import { goto } from '$app/navigation';
|
|
315
|
+
import { GuideProvider, Guide, GuideController, type GuideTour } from '@urbicon-ui/blocks';
|
|
316
|
+
|
|
317
|
+
// Wire the router once. (Equivalently: <GuideProvider navigate={(route) => goto(route)}>.)
|
|
318
|
+
const guide = new GuideController({ navigate: (route) => goto(route) });
|
|
319
|
+
|
|
320
|
+
const tour: GuideTour = {
|
|
321
|
+
id: 'cross-route-onboarding',
|
|
322
|
+
steps: [
|
|
323
|
+
{ target: 'dash-overview', route: '/dashboard', title: 'Your dashboard', body: '…' },
|
|
324
|
+
{ target: 'dash-filter', route: '/dashboard', title: 'Filter', body: '…' },
|
|
325
|
+
{ target: 'billing-plan', route: '/settings/billing', title: 'Your plan', body: '…' }
|
|
326
|
+
]
|
|
327
|
+
};
|
|
328
|
+
</script>
|
|
329
|
+
|
|
330
|
+
<GuideProvider controller={guide}>
|
|
331
|
+
<Guide />
|
|
332
|
+
<!-- … app … -->
|
|
333
|
+
</GuideProvider>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
How it behaves:
|
|
337
|
+
|
|
338
|
+
- **Tour-internal vs. foreign navigation.** A navigation the tour itself triggers (a `step.route`)
|
|
339
|
+
keeps it running. A *foreign* navigation — the user leaving the flow on their own, or a
|
|
340
|
+
youngest-gesture-wins race during a pending step navigation — stops it via `stopTour()`
|
|
341
|
+
(analytics-silent, so it can surface again; no `onSkip`). The engine subscribes to navigation
|
|
342
|
+
only for tours that declare at least one `route`, so a tour that does its own routing (below) is
|
|
343
|
+
never second-guessed.
|
|
344
|
+
- **`prev()` is symmetric.** Stepping back across a route boundary navigates back too — the target
|
|
345
|
+
step carries its own `route`.
|
|
346
|
+
- **The navigation gap is covered.** Between the `navigate(...)` call and the new route's
|
|
347
|
+
`data-guide` element mounting, the step renders centered over the full scrim; once the target
|
|
348
|
+
appears, the bubble re-anchors and the spotlight ring lands (via the existing
|
|
349
|
+
`reapplyStepHighlight`). If the target never appears, the step still shows centered (it never
|
|
350
|
+
hangs) and DEV warns.
|
|
351
|
+
- **Missing hook is safe.** A `route` step with no `navigate` wired DEV-warns and stays on the
|
|
352
|
+
current route — no crash.
|
|
353
|
+
- **Path comparison.** `route` is compared against `window.location.pathname` by default, so use a
|
|
354
|
+
normalized path (no query/hash, preferably the router's *actual* landed path). An asynchronously
|
|
355
|
+
observed landing that is merely a normalized form of the step's route (trailing slash, base/locale
|
|
356
|
+
prefix) is recognized as the tour's own — the tour keeps running, DEV-warned so you can make the
|
|
357
|
+
route exact; an unrelated landing (e.g. an auth redirect) stops the tour as foreign. A
|
|
358
|
+
synchronous, re-entrant `navigationSource` (the `afterNavigate` one below) doesn't need the
|
|
359
|
+
heuristic — a same-tick report is recognized as the tour's own by *timing*, not by path.
|
|
360
|
+
|
|
361
|
+
> **Reliable foreign-navigation detection.** The default `navigationSource` observes navigations via
|
|
362
|
+
> the Navigation API, which catches link clicks, `goto`, and back/forward in Chromium and recent
|
|
363
|
+
> Safari. In a browser without it the source falls back to `popstate` — back/forward only, **not**
|
|
364
|
+
> `pushState` — so a foreign *forward* navigation (an in-app link click) may go unobserved and the
|
|
365
|
+
> tour won't stop (it DEV-warns once when it takes the fallback). For detection that works in every
|
|
366
|
+
> browser, inject a `navigationSource` backed by your router. SvelteKit (`afterNavigate` fires for
|
|
367
|
+
> every client navigation):
|
|
368
|
+
>
|
|
369
|
+
> ```svelte
|
|
370
|
+
> <script lang="ts">
|
|
371
|
+
> import { afterNavigate, goto } from '$app/navigation';
|
|
372
|
+
> import { page } from '$app/state';
|
|
373
|
+
> import { GuideController, type GuideNavigationSource } from '@urbicon-ui/blocks';
|
|
374
|
+
>
|
|
375
|
+
> let notify: ((path: string) => void) | null = null;
|
|
376
|
+
> const navigationSource: GuideNavigationSource = {
|
|
377
|
+
> current: () => page.url.pathname,
|
|
378
|
+
> subscribe: (cb) => {
|
|
379
|
+
> notify = cb;
|
|
380
|
+
> return () => {
|
|
381
|
+
> notify = null;
|
|
382
|
+
> };
|
|
383
|
+
> }
|
|
384
|
+
> };
|
|
385
|
+
> afterNavigate(() => notify?.(page.url.pathname));
|
|
386
|
+
>
|
|
387
|
+
> const guide = new GuideController({ navigate: goto, navigationSource });
|
|
388
|
+
> </script>
|
|
389
|
+
> ```
|
|
390
|
+
>
|
|
391
|
+
> This source fires **synchronously inside `goto`** — re-entrant, while the controller's own
|
|
392
|
+
> `navigate` hook is still on the stack. The engine treats such a same-tick report as the tour's own
|
|
393
|
+
> navigation (recognized by *timing*, not by path), so a plain synchronous `notify` is correct — no
|
|
394
|
+
> `queueMicrotask` deferral is needed. Because the match is by timing, a router-normalized landing
|
|
395
|
+
> (trailing slash, base/locale prefix) keeps the tour running; a synchronous *redirect* off the
|
|
396
|
+
> step's route also keeps it running (DEV-warned, not stopped), so stop the tour explicitly from a
|
|
397
|
+
> router guard if a redirect should end it.
|
|
398
|
+
|
|
399
|
+
### Manual (`onStep` + `goto`) — the escape hatch
|
|
400
|
+
|
|
401
|
+
For routing that the declarative `route` can't express — chosen at runtime from tour state or user
|
|
402
|
+
answers — navigate imperatively in `onStep` instead. A tour with no `step.route` is never
|
|
403
|
+
subscribed to navigation, so its own `goto` is not mistaken for a foreign navigation:
|
|
404
|
+
|
|
405
|
+
```svelte
|
|
406
|
+
<script lang="ts">
|
|
407
|
+
import { goto } from '$app/navigation';
|
|
408
|
+
import { page } from '$app/state';
|
|
409
|
+
import type { GuideTour } from '@urbicon-ui/blocks';
|
|
410
|
+
|
|
411
|
+
const stepRoutes: Record<number, string> = { 0: '/dashboard', 2: '/settings/billing' };
|
|
412
|
+
|
|
413
|
+
const tour: GuideTour = {
|
|
414
|
+
id: 'cross-route-onboarding',
|
|
415
|
+
steps: [
|
|
416
|
+
{ target: 'dash-overview', title: 'Your dashboard', body: '…' },
|
|
417
|
+
{ target: 'dash-filter', title: 'Filter', body: '…' },
|
|
418
|
+
{ target: 'billing-plan', title: 'Your plan', body: '…' }
|
|
419
|
+
],
|
|
420
|
+
onStep: ({ index }) => {
|
|
421
|
+
const route = stepRoutes[index];
|
|
422
|
+
if (route && route !== page.url.pathname) goto(route);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
</script>
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Rules of thumb (both patterns):
|
|
429
|
+
|
|
430
|
+
- **Mount `<Guide />` (and the provider) in the layout, not in a route.** A route-local renderer
|
|
431
|
+
unmounts on navigation, and its unmount cleanup calls `stopTour()` — the tour would silently end
|
|
432
|
+
mid-flight before it could re-anchor on the destination.
|
|
433
|
+
- **`stopTour()` when navigation invalidates the tour** (logout, the user abandons the flow) — it
|
|
434
|
+
tears down without marking the tour seen and stays analytics-silent, so it can surface again. The
|
|
435
|
+
declarative pattern does this for foreign navigation automatically.
|
|
436
|
+
- Steps that land right after a navigation pair well with `advance: 'action'` (§8) when the
|
|
437
|
+
navigation itself *is* the action being taught — together they yield learning-by-doing across
|
|
438
|
+
routes.
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## 10. Non-goals / v2
|
|
443
|
+
|
|
444
|
+
Deliberately **out** of the first cut (avoiding over-engineering):
|
|
445
|
+
|
|
446
|
+
- Branching / conditional steps (branched tours by user answer)
|
|
447
|
+
- Cross-route **resume state** (surviving a full page reload / deep-link into the middle of a
|
|
448
|
+
tour) — client-side cross-route touring *is* supported now, both declaratively (`GuideStep.route`
|
|
449
|
+
+ the `navigate` hook) and imperatively (the `onStep` recipe), §9; persisting progress across a
|
|
450
|
+
hard reload is what stays out
|
|
451
|
+
- WYSIWYG tour editor / no-code authoring
|
|
452
|
+
- Built-in analytics persistence (we ship only the hooks, no backend)
|
|
453
|
+
- Server-driven remote guides (content from a CMS) — an adapter is conceivable, not v1
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## 11. Where things live & how it's tested
|
|
458
|
+
|
|
459
|
+
| Concern | Location |
|
|
460
|
+
|---|---|
|
|
461
|
+
| Engine (state machine, registry, persistence, analytics) | `packages/blocks/src/lib/utils/guide.svelte.ts` |
|
|
462
|
+
| Lazy/vanishing-target watcher | `packages/blocks/src/lib/utils/observe-target.ts` |
|
|
463
|
+
| Surfaces | `packages/blocks/src/lib/components/Guide/*.svelte` |
|
|
464
|
+
| Context + panel context | `components/Guide/guide.context.ts`, `guide-panel.context.ts` |
|
|
465
|
+
| Variants | `components/Guide/guide.variants.ts` |
|
|
466
|
+
| Tokens / highlight CSS | `blocks/src/lib/style/{interaction,index,foundation}.css` |
|
|
467
|
+
| i18n keys (`guide.*`) | `blocks/src/lib/translations/{en,de}.ts` |
|
|
468
|
+
| Doc page (live examples) | `apps/docs/src/routes/blocks/components/guide/` |
|
|
469
|
+
| Recipe (Onboarding Flow + live analytics log) | `apps/docs/src/routes/recipes/onboarding-flow/` |
|
|
470
|
+
| MCP pattern | `design-system/patterns/onboarding-guide.md` |
|
|
471
|
+
|
|
472
|
+
**Tests.** Engine logic is node-unit-tested (`utils/guide.svelte.test.ts`,
|
|
473
|
+
`utils/observe-target.test.ts`). Component behaviour + a11y (axe) + light/dark visual snapshots
|
|
474
|
+
run via Playwright against `apps/docs/src/routes/test-fixtures/guide/`
|
|
475
|
+
(`e2e/guide.spec.ts`); the visual baselines are CI-optional (`e2e/snapshots/guide.spec.ts-snapshots/`).
|
|
476
|
+
|
|
477
|
+
**docs-gen caveat.** docs-gen extracts only local `*Props`/`*Variants`, not types imported from
|
|
478
|
+
`utils`. So `GuideController` / `GuideTour` / `GuideStep` / the analytics-event payloads are **not**
|
|
479
|
+
auto-expanded into the generated `api.ts` / `llms.txt`; the hand-authored API tables on the doc
|
|
480
|
+
page are the source of truth for them until docs-gen does cross-file type resolution.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Migration Guide: v4 → v5
|
|
2
|
+
|
|
3
|
+
The v5.0.0 release ("Lighter design refactor") introduces a quieter visual
|
|
4
|
+
language across the library: hairline borders, surface-tinted in-page zones,
|
|
5
|
+
fewer chrome layers. Most consumer code is unaffected — the renames listed
|
|
6
|
+
below are the only typed-API breakages.
|
|
7
|
+
|
|
8
|
+
> Released `2026-05-21` as commit `8ac07ca` (`feat!`). All four breaking
|
|
9
|
+
> blocks below were declared in the commit's `BREAKING CHANGE:` footers but
|
|
10
|
+
> are surfaced here because git-cliff's default template renders only the
|
|
11
|
+
> commit subject in the CHANGELOG.
|
|
12
|
+
|
|
13
|
+
## 1. Card variants
|
|
14
|
+
|
|
15
|
+
| Before | After | Notes |
|
|
16
|
+
| ------------------- | -------------------- | --------------------------------------------- |
|
|
17
|
+
| `variant="elevated"` (default) | `variant="quiet"` (default) | New default — surface-quiet tint, no shadow |
|
|
18
|
+
| `variant="filled"` | _removed_ | Replaced by `quiet` |
|
|
19
|
+
| `variant="glass"` | _removed_ | No drop-in replacement; build with `unstyled` |
|
|
20
|
+
| `variant="outlined"` | _unchanged_ | — |
|
|
21
|
+
| `variant="elevated"` | _unchanged_ | Still available, no longer the default |
|
|
22
|
+
| — | `variant="floating"` | New — popover-family weight (shadow-lg) |
|
|
23
|
+
|
|
24
|
+
The `dividers` prop now defaults to **false** (was implicit `true` via
|
|
25
|
+
header/footer borders). Set `dividers={true}` to restore traditional
|
|
26
|
+
card-with-header look.
|
|
27
|
+
|
|
28
|
+
```svelte
|
|
29
|
+
<!-- v4 -->
|
|
30
|
+
<Card variant="elevated">…</Card>
|
|
31
|
+
<!-- v5 — keeps the elevated look explicitly -->
|
|
32
|
+
<Card variant="elevated">…</Card>
|
|
33
|
+
<!-- v5 — pick up the new default, no shadow -->
|
|
34
|
+
<Card>…</Card>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 2. Alert + Accordion variant renames
|
|
38
|
+
|
|
39
|
+
| Component | Before | After |
|
|
40
|
+
| ----------- | --------------------- | ---------------------- |
|
|
41
|
+
| `Alert` | `variant="outlined"` | `variant="inline"` |
|
|
42
|
+
| `Accordion` | `variant="bordered"` | `variant="separated"` |
|
|
43
|
+
|
|
44
|
+
`Alert.inline` is a memo-style with a left accent border (`border-l-2`),
|
|
45
|
+
no surrounding box. `Accordion.separated` renders items as a stacked
|
|
46
|
+
card list with `surface-quiet` tint and `space-y-2`.
|
|
47
|
+
|
|
48
|
+
```svelte
|
|
49
|
+
<!-- v4 -->
|
|
50
|
+
<Alert variant="outlined" intent="warning">…</Alert>
|
|
51
|
+
<Accordion variant="bordered">…</Accordion>
|
|
52
|
+
<!-- v5 -->
|
|
53
|
+
<Alert variant="inline" intent="warning">…</Alert>
|
|
54
|
+
<Accordion variant="separated">…</Accordion>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 3. Table chrome
|
|
58
|
+
|
|
59
|
+
The Table no longer renders a frame by default. The new `appearance` prop
|
|
60
|
+
controls the container chrome:
|
|
61
|
+
|
|
62
|
+
| `appearance` | Effect |
|
|
63
|
+
| ------------ | ------------------------------------------------------------ |
|
|
64
|
+
| `flush` (default) | No frame; the table sits inline in the reading flow. |
|
|
65
|
+
| `surface` | Quiet tinted zone — `bg-surface-quiet`. |
|
|
66
|
+
| `framed` | Border + `rounded-contain` + shadow — closest to v4 default. |
|
|
67
|
+
|
|
68
|
+
The dead `tableVariants` export is removed (was unused in any consumer).
|
|
69
|
+
|
|
70
|
+
```svelte
|
|
71
|
+
<!-- v4 — implicit frame -->
|
|
72
|
+
<Table data={rows} columns={cols} />
|
|
73
|
+
<!-- v5 — explicit equivalent -->
|
|
74
|
+
<Table data={rows} columns={cols} appearance="framed" />
|
|
75
|
+
<!-- v5 — new editorial default -->
|
|
76
|
+
<Table data={rows} columns={cols} />
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 4. Dialog + Drawer intent
|
|
80
|
+
|
|
81
|
+
Dialog and Drawer no longer draw a coloured `border-t-[3px]` accent for
|
|
82
|
+
non-neutral intents. Intent is exposed on the panel as a data attribute:
|
|
83
|
+
|
|
84
|
+
```html
|
|
85
|
+
<div data-intent="warning" …>…</div>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Consumers can re-introduce a coloured accent via CSS, a preset, or a
|
|
89
|
+
custom snippet on `header`/`title`:
|
|
90
|
+
|
|
91
|
+
```css
|
|
92
|
+
[data-intent='warning'] {
|
|
93
|
+
border-top: 3px solid var(--color-warning);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
No prop renames — `intent="warning"` still works; only the default visual
|
|
98
|
+
changed.
|
|
99
|
+
|
|
100
|
+
## Container-component border tokens
|
|
101
|
+
|
|
102
|
+
Many container surfaces (Card, Alert, Dialog, Drawer, Popover, Menu, Toast,
|
|
103
|
+
Sidebar, Collapsible, FileUpload, Sankey, CompositionBar, CommandPalette,
|
|
104
|
+
SidebarLayout, Calendar) migrated from `border-subtle` to
|
|
105
|
+
`border-hairline` (~8 % alpha at light, 6 % at dark). Form primitives
|
|
106
|
+
(Input, Select, Combobox, Textarea, Checkbox, RadioGroup) kept
|
|
107
|
+
`border-subtle` as an input affordance — consumers see no API change but
|
|
108
|
+
the visual difference is real. Override per-component via `class` /
|
|
109
|
+
`slotClasses` if a heavier border is desired.
|
|
110
|
+
|
|
111
|
+
## New tokens consumers can use directly
|
|
112
|
+
|
|
113
|
+
- `bg-surface-quiet` — softly tinted in-page zone (was `bg-surface-subtle`-equivalent)
|
|
114
|
+
- `border-hairline` — container divider (auto-upgrades under
|
|
115
|
+
`prefers-contrast: more`)
|
|
116
|
+
|
|
117
|
+
Both are exposed as semantic Tailwind utilities and via CSS custom
|
|
118
|
+
properties (`--color-surface-quiet`, `--color-border-hairline`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbicon-ui/blocks",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.29.0",
|
|
4
4
|
"description": "Svelte 5 UI component library with Tailwind CSS 4, OKLCH design tokens and zero runtime dependencies",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"LICENSE",
|
|
38
|
+
"docs/GUIDE.md",
|
|
39
|
+
"docs/MIGRATION-v5.md",
|
|
38
40
|
"dist",
|
|
39
41
|
"!dist/**/*.test.*",
|
|
40
42
|
"!dist/**/*.spec.*",
|
|
@@ -92,8 +94,8 @@
|
|
|
92
94
|
"@sveltejs/package": "^2.5.8",
|
|
93
95
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
94
96
|
"@tailwindcss/vite": "^4.3.1",
|
|
95
|
-
"@urbicon-ui/i18n": "6.
|
|
96
|
-
"@urbicon-ui/shared-types": "6.
|
|
97
|
+
"@urbicon-ui/i18n": "6.29.0",
|
|
98
|
+
"@urbicon-ui/shared-types": "6.29.0",
|
|
97
99
|
"prettier": "^3.8.4",
|
|
98
100
|
"prettier-plugin-svelte": "^4.1.1",
|
|
99
101
|
"prettier-plugin-tailwindcss": "^0.8.0",
|