@picked/svelte 0.0.1-dev.1789541668638 → 0.0.1-dev.1793753513002
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/LICENSE +1 -0
- package/README.md +51 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/_virtual/_vite/preload-helper.js +1 -1
- package/dist/components/index.js +1 -1
- package/dist/experience/ExperienceHost.svelte +29 -13
- package/dist/experience/ExperienceSlot.svelte +21 -3
- package/dist/experience/ExperienceWrapper.svelte +23 -0
- package/dist/form/index.js +1 -1
- package/dist/hooks/action.d.ts +47 -18
- package/dist/hooks/action.js +1 -1
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/step.d.ts +14 -0
- package/dist/hooks/step.js +1 -1
- package/dist/plugins/devtools/DevToolsExperience.svelte +545 -203
- package/dist/plugins/devtools/components/AdaptedToolHost.svelte +2 -2
- package/dist/plugins/devtools/constants.d.ts +5 -0
- package/dist/plugins/devtools/constants.js +1 -0
- package/dist/plugins/devtools/customTools.d.ts +42 -6
- package/dist/plugins/devtools/customTools.js +1 -1
- package/dist/plugins/devtools/derive/answer-display/answer-display.d.ts +112 -0
- package/dist/plugins/devtools/derive/answer-display/answer-display.js +1 -0
- package/dist/plugins/devtools/derive/answer-display/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/answer-display/index.js +1 -0
- package/dist/plugins/devtools/derive/answer-history/answer-history.d.ts +76 -0
- package/dist/plugins/devtools/derive/answer-history/answer-history.js +1 -0
- package/dist/plugins/devtools/derive/answer-history/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/answer-history/index.js +1 -0
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.d.ts +67 -0
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -0
- package/dist/plugins/devtools/derive/guide-sections/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/guide-sections/index.js +1 -0
- package/dist/plugins/devtools/derive/index.d.ts +28 -0
- package/dist/plugins/devtools/derive/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-outline/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-outline/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-outline/journey-outline.d.ts +86 -0
- package/dist/plugins/devtools/derive/journey-outline/journey-outline.js +1 -0
- package/dist/plugins/devtools/derive/journey-pick/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-pick/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-pick/journey-pick.d.ts +73 -0
- package/dist/plugins/devtools/derive/journey-pick/journey-pick.js +1 -0
- package/dist/plugins/devtools/derive/journey-timeline/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-timeline/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-timeline/journey-timeline.d.ts +70 -0
- package/dist/plugins/devtools/derive/journey-timeline/journey-timeline.js +1 -0
- package/dist/plugins/devtools/derive/journey-tree/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/journey-tree/index.js +1 -0
- package/dist/plugins/devtools/derive/journey-tree/journey-tree.d.ts +191 -0
- package/dist/plugins/devtools/derive/journey-tree/journey-tree.js +1 -0
- package/dist/plugins/devtools/derive/plain-language/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/plain-language/index.js +1 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +73 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -0
- package/dist/plugins/devtools/derive/plain-logic/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/plain-logic/index.js +1 -0
- package/dist/plugins/devtools/derive/plain-logic/plain-logic.d.ts +48 -0
- package/dist/plugins/devtools/derive/plain-logic/plain-logic.js +1 -0
- package/dist/plugins/devtools/derive/safety-posture/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/safety-posture/index.js +1 -0
- package/dist/plugins/devtools/derive/safety-posture/safety-posture.d.ts +43 -0
- package/dist/plugins/devtools/derive/safety-posture/safety-posture.js +1 -0
- package/dist/plugins/devtools/derive/visited-steps/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/visited-steps/index.js +1 -0
- package/dist/plugins/devtools/derive/visited-steps/visited-steps.d.ts +35 -0
- package/dist/plugins/devtools/derive/visited-steps/visited-steps.js +1 -0
- package/dist/plugins/devtools/derive/walk-route/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/walk-route/index.js +1 -0
- package/dist/plugins/devtools/derive/walk-route/walk-route.d.ts +22 -0
- package/dist/plugins/devtools/derive/walk-route/walk-route.js +1 -0
- package/dist/plugins/devtools/index.d.ts +5 -2
- package/dist/plugins/devtools/index.js +1 -1
- package/dist/plugins/devtools/launcher/Launcher.svelte +150 -0
- package/dist/plugins/devtools/launcher/launcher.d.ts +36 -0
- package/dist/plugins/devtools/launcher/launcher.js +1 -0
- package/dist/plugins/devtools/options.d.ts +106 -0
- package/dist/plugins/devtools/options.js +1 -0
- package/dist/plugins/devtools/plugin.d.ts +8 -8
- package/dist/plugins/devtools/plugin.js +1 -1
- package/dist/plugins/devtools/posture-context.d.ts +15 -0
- package/dist/plugins/devtools/posture-context.js +1 -0
- package/dist/plugins/devtools/review/answers/AnswersPanel.svelte +526 -0
- package/dist/plugins/devtools/review/guide/GuideLine.svelte +20 -0
- package/dist/plugins/devtools/review/guide/GuidePanel.svelte +133 -0
- package/dist/plugins/devtools/review/index.d.ts +6 -0
- package/dist/plugins/devtools/review/index.js +1 -0
- package/dist/plugins/devtools/review/journey/JourneyMap.svelte +1076 -0
- package/dist/plugins/devtools/review/journey/JourneyPanel.svelte +584 -0
- package/dist/plugins/devtools/review/journey/JourneyTimeline.svelte +750 -0
- package/dist/plugins/devtools/review/journey/TimelineMarker.svelte +59 -0
- package/dist/plugins/devtools/review/session/SessionPanel.svelte +214 -0
- package/dist/plugins/devtools/review/variations/VariationsPanel.svelte +279 -0
- package/dist/plugins/devtools/screens/config/ConfigScreen.svelte +6 -0
- package/dist/plugins/devtools/screens/custom-tools/CustomToolsScreen.svelte +3 -3
- package/dist/plugins/devtools/screens/responses/ResponseTableRow.svelte +6 -0
- package/dist/plugins/devtools/session/index.d.ts +3 -0
- package/dist/plugins/devtools/session/index.js +1 -0
- package/dist/plugins/devtools/session/session.d.ts +90 -0
- package/dist/plugins/devtools/session/session.js +1 -0
- package/dist/plugins/devtools/session-access.d.ts +32 -0
- package/dist/plugins/devtools/session-access.js +1 -0
- package/dist/plugins/devtools/sidebar/Sidebar.svelte +105 -0
- package/dist/plugins/devtools/sidebar/SidebarRow.svelte +117 -0
- package/dist/plugins/devtools/sidebar/nav.d.ts +105 -0
- package/dist/plugins/devtools/sidebar/nav.js +1 -0
- package/dist/plugins/devtools/ui/actions/Button.svelte +10 -1
- package/dist/plugins/devtools/ui/actions/CopyButton.svelte +13 -1
- package/dist/plugins/devtools/ui/data/Badge.svelte +3 -2
- package/dist/plugins/devtools/ui/data/EmptyState.svelte +16 -1
- package/dist/plugins/devtools/ui/feedback/Banner.svelte +3 -2
- package/dist/plugins/framework/index.js +1 -1
- package/dist/render/browser.js +1 -1
- package/package.json +23 -12
- package/dist/form/__tests__/CreateFormHarness.svelte +0 -30
- package/dist/hooks/events.d.ts +0 -2
- package/dist/hooks/events.js +0 -1
- package/dist/vite-env.d.js +0 -0
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* @file The journey drawn as a tree — the Developer group's experience tree, in plain English.
|
|
4
|
+
*
|
|
5
|
+
* `derive/journey-tree` does the thinking: the graph-to-tree walk and the tidy layout. This file
|
|
6
|
+
* owns nothing but pan, zoom and selection, which is why the layout is testable and this is not.
|
|
7
|
+
* The look follows the experience tree: left to right, a coloured dot per node (grey start, blue
|
|
8
|
+
* screen, amber decision, sky-blue for a branch that rejoins), curved links, a decision's arms
|
|
9
|
+
* green and red, and each label above its dot — below it at the end of a branch.
|
|
10
|
+
*
|
|
11
|
+
* NODES ARE HTML, LINKS ARE SVG. A node is a real `<button>` holding its dot and label,
|
|
12
|
+
* positioned so the dot's centre sits on the layout point. That buys focus rings, line clamping
|
|
13
|
+
* and the design tokens, and the shared CSS transform keeps the two layers locked together.
|
|
14
|
+
*
|
|
15
|
+
* ZOOM KEEPS THE POINT UNDER THE CURSOR STILL, and the wheel handler must `preventDefault` — the
|
|
16
|
+
* map lives inside a scrolling panel body, and without it a zoom gesture scrolls the panel away.
|
|
17
|
+
*
|
|
18
|
+
* A REJOIN keeps the tree a tree: the later branch ends on a `↩` leaf, and a DASHED line runs
|
|
19
|
+
* from that leaf to the node drawn in full, so the route still reads as one. Clicking any node
|
|
20
|
+
* opens a POP-OUT beside it — what it is, where its arms go, its rule, and the one action it
|
|
21
|
+
* offers — so the reviewer never scrolls away from the map to read about it.
|
|
22
|
+
*
|
|
23
|
+
* ACTIONS ARE NOT DRAWN. A route runs straight across them, and where a branch hands off to one
|
|
24
|
+
* and stops, the pop-out names it on the node before.
|
|
25
|
+
*/
|
|
26
|
+
import type { TreeLayout, TreeNode, TreeNodeKind } from '../../derive'
|
|
27
|
+
|
|
28
|
+
interface JourneyMapProps {
|
|
29
|
+
layout: TreeLayout
|
|
30
|
+
/** Whether a screen may be jumped to at all — the posture, and whether this journey is open. */
|
|
31
|
+
canNavigate: boolean
|
|
32
|
+
onJump: (stepKey: string) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { layout, canNavigate, onJump }: JourneyMapProps = $props()
|
|
36
|
+
|
|
37
|
+
const MIN_SCALE = 0.1
|
|
38
|
+
const MAX_SCALE = 3
|
|
39
|
+
const ZOOM_STEP = 1.2
|
|
40
|
+
/** Below this, a whole tree is too small to read, so the map opens on its start instead. */
|
|
41
|
+
const LEGIBLE_SCALE = 0.55
|
|
42
|
+
/** How far a press may travel and still count as a click rather than a drag. */
|
|
43
|
+
const DRAG_THRESHOLD = 4
|
|
44
|
+
/** How close to the viewport's edge a focused node may sit before the map re-centres on it. */
|
|
45
|
+
const FOCUS_MARGIN = 96
|
|
46
|
+
|
|
47
|
+
/** The pop-out's width, and its gap from the dot and from the viewport's edges. */
|
|
48
|
+
const POP_WIDTH = 320
|
|
49
|
+
const POP_GAP = 24
|
|
50
|
+
const POP_EDGE = 8
|
|
51
|
+
|
|
52
|
+
const KIND_LABELS: Record<TreeNodeKind, string> = {
|
|
53
|
+
start: 'Start',
|
|
54
|
+
screen: 'Screen',
|
|
55
|
+
decision: 'Decision',
|
|
56
|
+
system: 'Behind the scenes',
|
|
57
|
+
journey: 'Opens another journey',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let viewport: HTMLDivElement | undefined = $state()
|
|
61
|
+
let scale = $state(1)
|
|
62
|
+
let offsetX = $state(0)
|
|
63
|
+
let offsetY = $state(0)
|
|
64
|
+
let selected = $state<string | null>(null)
|
|
65
|
+
let panning = $state(false)
|
|
66
|
+
let viewWidth = $state(0)
|
|
67
|
+
let viewHeight = $state(0)
|
|
68
|
+
let popHeight = $state(0)
|
|
69
|
+
|
|
70
|
+
let origin = { x: 0, y: 0, offsetX: 0, offsetY: 0 }
|
|
71
|
+
/** A press on the background that never became a drag is a click, and closes the pop-out. */
|
|
72
|
+
let dragged = false
|
|
73
|
+
|
|
74
|
+
const selectedNode = $derived(layout.nodes.find((node) => node.id === selected) ?? null)
|
|
75
|
+
const nodeById = $derived(new Map(layout.nodes.map((node) => [node.id, node])))
|
|
76
|
+
|
|
77
|
+
/** Where a decision's arms lead — a sentence the reviewer would otherwise trace by eye. */
|
|
78
|
+
const arms = $derived(
|
|
79
|
+
selectedNode === null || selectedNode.kind !== 'decision'
|
|
80
|
+
? []
|
|
81
|
+
: layout.links
|
|
82
|
+
.filter((link) => link.from === selectedNode.id && link.label !== null)
|
|
83
|
+
.map((link) => ({
|
|
84
|
+
id: link.id,
|
|
85
|
+
label: link.label,
|
|
86
|
+
arm: link.arm,
|
|
87
|
+
to: nodeById.get(link.to)?.label ?? '',
|
|
88
|
+
})),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Holds a value between two bounds.
|
|
93
|
+
*
|
|
94
|
+
* @param {number} value - The value to hold.
|
|
95
|
+
* @param {number} low - The lowest it may be.
|
|
96
|
+
* @param {number} high - The highest it may be.
|
|
97
|
+
* @returns {number} The value, moved inside the bounds.
|
|
98
|
+
* @example
|
|
99
|
+
* clamp(5, 0, 3) // 3
|
|
100
|
+
*/
|
|
101
|
+
function clamp(value: number, low: number, high: number): number {
|
|
102
|
+
return Math.min(Math.max(value, low), high)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The pop-out sits beside its dot — to the right, or to the left when the right would run off the
|
|
107
|
+
* map — and is held inside the viewport top to bottom. It keeps clear of the node's own label: a
|
|
108
|
+
* label above its dot leaves the pop-out starting level with the dot, a label below it (the end of
|
|
109
|
+
* a branch) leaves the pop-out ending there. Recomputed from the live pan and zoom, so it travels
|
|
110
|
+
* with its node.
|
|
111
|
+
*/
|
|
112
|
+
const popover = $derived.by(() => {
|
|
113
|
+
if (selectedNode === null) return null
|
|
114
|
+
|
|
115
|
+
const dotX = selectedNode.x * scale + offsetX
|
|
116
|
+
const dotY = selectedNode.y * scale + offsetY
|
|
117
|
+
const right = dotX + POP_GAP
|
|
118
|
+
const fitsRight = right + POP_WIDTH <= viewWidth - POP_EDGE
|
|
119
|
+
const left = fitsRight ? right : dotX - POP_GAP - POP_WIDTH
|
|
120
|
+
const top = selectedNode.leaf ? dotY + POP_EDGE - popHeight : dotY - POP_EDGE
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
left: clamp(left, POP_EDGE, Math.max(POP_EDGE, viewWidth - POP_WIDTH - POP_EDGE)),
|
|
124
|
+
top: clamp(top, POP_EDGE, Math.max(POP_EDGE, viewHeight - popHeight - POP_EDGE)),
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a dashed jump lights up: either of its ends is the node the reviewer is looking at.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} from - The jump's reference leaf.
|
|
132
|
+
* @param {string} to - The node drawn in full.
|
|
133
|
+
* @returns {boolean} True when either end is selected.
|
|
134
|
+
* @example
|
|
135
|
+
* jumpActive(jump.from, jump.to)
|
|
136
|
+
*/
|
|
137
|
+
function jumpActive(from: string, to: string): boolean {
|
|
138
|
+
return selected !== null && (selected === from || selected === to)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether a node can take the visitor back to its screen.
|
|
143
|
+
*
|
|
144
|
+
* @param {TreeNode} node - The node to test.
|
|
145
|
+
* @returns {boolean} True for a visited screen in the open journey, when the posture allows it.
|
|
146
|
+
* @example
|
|
147
|
+
* canJump(node)
|
|
148
|
+
*/
|
|
149
|
+
function canJump(node: TreeNode): boolean {
|
|
150
|
+
return (
|
|
151
|
+
node.kind === 'screen' && node.state === 'visited' && node.stepKey !== null && canNavigate
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Brings a canvas point to the middle of the viewport at the current scale.
|
|
157
|
+
*
|
|
158
|
+
* @param {number} x - The point's canvas x.
|
|
159
|
+
* @param {number} y - The point's canvas y.
|
|
160
|
+
* @example
|
|
161
|
+
* centreOn(node.x, node.y)
|
|
162
|
+
*/
|
|
163
|
+
function centreOn(x: number, y: number): void {
|
|
164
|
+
if (viewport === undefined) return
|
|
165
|
+
const box = viewport.getBoundingClientRect()
|
|
166
|
+
offsetX = box.width / 2 - x * scale
|
|
167
|
+
offsetY = box.height / 2 - y * scale
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Frames the map. The visitor's own screen when this is the open journey — "where am I" is what
|
|
172
|
+
* the tab is for. Otherwise the whole tree when it fits legibly, and when it does not, the start,
|
|
173
|
+
* held at the left edge and centred top to bottom, so the tree reads left to right from its first
|
|
174
|
+
* node.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* fit()
|
|
178
|
+
*/
|
|
179
|
+
function fit(): void {
|
|
180
|
+
const start = layout.nodes.at(0)
|
|
181
|
+
if (viewport === undefined || start === undefined) return
|
|
182
|
+
|
|
183
|
+
const box = viewport.getBoundingClientRect()
|
|
184
|
+
const here = layout.nodes.find((node) => node.state === 'current')
|
|
185
|
+
if (here !== undefined) {
|
|
186
|
+
scale = 1
|
|
187
|
+
centreOn(here.x, here.y)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const whole = Math.min(box.width / layout.width, box.height / layout.height)
|
|
192
|
+
if (whole >= LEGIBLE_SCALE) {
|
|
193
|
+
scale = Math.min(whole, 1)
|
|
194
|
+
offsetX = (box.width - layout.width * scale) / 2
|
|
195
|
+
offsetY = (box.height - layout.height * scale) / 2
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The layout already leaves half a label's width left of the start, so the canvas edge goes on
|
|
200
|
+
// the viewport edge.
|
|
201
|
+
scale = 0.85
|
|
202
|
+
offsetX = 0
|
|
203
|
+
offsetY = box.height / 2 - start.y * scale
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Zooms about a point in VIEWPORT coordinates, holding whatever sits under it still.
|
|
208
|
+
*
|
|
209
|
+
* @param {number} pointX - The point's x within the viewport.
|
|
210
|
+
* @param {number} pointY - The point's y within the viewport.
|
|
211
|
+
* @param {number} factor - How much to scale by; above 1 zooms in.
|
|
212
|
+
* @example
|
|
213
|
+
* zoomAbout(120, 80, 1.2)
|
|
214
|
+
*/
|
|
215
|
+
function zoomAbout(pointX: number, pointY: number, factor: number): void {
|
|
216
|
+
const next = clamp(scale * factor, MIN_SCALE, MAX_SCALE)
|
|
217
|
+
if (next === scale) return
|
|
218
|
+
|
|
219
|
+
const ratio = next / scale
|
|
220
|
+
offsetX = pointX - (pointX - offsetX) * ratio
|
|
221
|
+
offsetY = pointY - (pointY - offsetY) * ratio
|
|
222
|
+
scale = next
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Zooms about the middle of the viewport, which is where the eye already is.
|
|
227
|
+
*
|
|
228
|
+
* @param {number} factor - How much to scale by; above 1 zooms in.
|
|
229
|
+
* @example
|
|
230
|
+
* zoomByButton(ZOOM_STEP)
|
|
231
|
+
*/
|
|
232
|
+
function zoomByButton(factor: number): void {
|
|
233
|
+
if (viewport === undefined) return
|
|
234
|
+
const box = viewport.getBoundingClientRect()
|
|
235
|
+
zoomAbout(box.width / 2, box.height / 2, factor)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether an event happened inside the pop-out.
|
|
240
|
+
*
|
|
241
|
+
* @param {EventTarget | null} target - The event's target.
|
|
242
|
+
* @returns {boolean} True inside the pop-out.
|
|
243
|
+
* @example
|
|
244
|
+
* inPopover(event.target)
|
|
245
|
+
*/
|
|
246
|
+
function inPopover(target: EventTarget | null): boolean {
|
|
247
|
+
return target instanceof Element && target.closest('.popover') !== null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Zooms on the wheel. Over the pop-out, the wheel scrolls its rule instead.
|
|
252
|
+
*
|
|
253
|
+
* @param {WheelEvent} event - The wheel event.
|
|
254
|
+
* @example
|
|
255
|
+
* onWheel(event)
|
|
256
|
+
*/
|
|
257
|
+
function onWheel(event: WheelEvent): void {
|
|
258
|
+
if (viewport === undefined || inPopover(event.target)) return
|
|
259
|
+
event.preventDefault()
|
|
260
|
+
|
|
261
|
+
const box = viewport.getBoundingClientRect()
|
|
262
|
+
zoomAbout(
|
|
263
|
+
event.clientX - box.left,
|
|
264
|
+
event.clientY - box.top,
|
|
265
|
+
event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Starts a pan from a press on the background.
|
|
271
|
+
*
|
|
272
|
+
* @param {PointerEvent} event - The pointer event.
|
|
273
|
+
* @example
|
|
274
|
+
* onPointerDown(event)
|
|
275
|
+
*/
|
|
276
|
+
function onPointerDown(event: PointerEvent): void {
|
|
277
|
+
// A press that lands on a node is that node's to handle — panning from it would swallow the click.
|
|
278
|
+
if (event.target instanceof Element && event.target.closest('button') !== null) return
|
|
279
|
+
if (inPopover(event.target)) return
|
|
280
|
+
|
|
281
|
+
panning = true
|
|
282
|
+
dragged = false
|
|
283
|
+
origin = { x: event.clientX, y: event.clientY, offsetX, offsetY }
|
|
284
|
+
|
|
285
|
+
// Captured so a drag that leaves the panel still tracks, and still ends, on this element.
|
|
286
|
+
if (event.currentTarget instanceof Element) {
|
|
287
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Moves the map with the pointer while panning.
|
|
293
|
+
*
|
|
294
|
+
* @param {PointerEvent} event - The pointer event.
|
|
295
|
+
* @example
|
|
296
|
+
* onPointerMove(event)
|
|
297
|
+
*/
|
|
298
|
+
function onPointerMove(event: PointerEvent): void {
|
|
299
|
+
if (!panning) return
|
|
300
|
+
const travel = Math.abs(event.clientX - origin.x) + Math.abs(event.clientY - origin.y)
|
|
301
|
+
if (travel > DRAG_THRESHOLD) dragged = true
|
|
302
|
+
offsetX = origin.offsetX + (event.clientX - origin.x)
|
|
303
|
+
offsetY = origin.offsetY + (event.clientY - origin.y)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Ends a pan. A press that never moved was a click on the background, and closes the pop-out.
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* endPan()
|
|
311
|
+
*/
|
|
312
|
+
function endPan(): void {
|
|
313
|
+
if (panning && !dragged) selected = null
|
|
314
|
+
panning = false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Opens a node's pop-out; a second click on the same node closes it. A click never jumps.
|
|
319
|
+
*
|
|
320
|
+
* @param {TreeNode} node - The node clicked.
|
|
321
|
+
* @example
|
|
322
|
+
* activate(node)
|
|
323
|
+
*/
|
|
324
|
+
function activate(node: TreeNode): void {
|
|
325
|
+
selected = selected === node.id ? null : node.id
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Moves the open journey back to a node's screen.
|
|
330
|
+
*
|
|
331
|
+
* @param {TreeNode} node - A visited screen.
|
|
332
|
+
* @example
|
|
333
|
+
* goBack(node)
|
|
334
|
+
*/
|
|
335
|
+
function goBack(node: TreeNode): void {
|
|
336
|
+
if (canJump(node) && node.stepKey !== null) onJump(node.stepKey)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Closes the pop-out on Escape, and only that — the panel itself stays open.
|
|
341
|
+
*
|
|
342
|
+
* @param {KeyboardEvent} event - The key event.
|
|
343
|
+
* @example
|
|
344
|
+
* onMapKeydown(event)
|
|
345
|
+
*/
|
|
346
|
+
function onMapKeydown(event: KeyboardEvent): void {
|
|
347
|
+
if (event.key !== 'Escape' || selected === null) return
|
|
348
|
+
event.stopPropagation()
|
|
349
|
+
selected = null
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Selects and centres the node a rejoin refers to. A rejoin is drawn in full on its first branch
|
|
354
|
+
* only.
|
|
355
|
+
*
|
|
356
|
+
* @param {TreeNode} node - The `↩` reference leaf.
|
|
357
|
+
* @example
|
|
358
|
+
* showDrawn(node)
|
|
359
|
+
*/
|
|
360
|
+
function showDrawn(node: TreeNode): void {
|
|
361
|
+
const drawn = layout.nodes.find((entry) => entry.nodeId === node.nodeId && !entry.reference)
|
|
362
|
+
if (drawn === undefined) return
|
|
363
|
+
selected = drawn.id
|
|
364
|
+
centreOn(drawn.x, drawn.y)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Keeps a node reachable by keyboard. Tabbing moves focus in tree order, which walks straight off
|
|
369
|
+
* the visible area, so whatever gains focus is brought back into view.
|
|
370
|
+
*
|
|
371
|
+
* @param {TreeNode} node - The node that gained focus.
|
|
372
|
+
* @example
|
|
373
|
+
* onNodeFocus(node)
|
|
374
|
+
*/
|
|
375
|
+
function onNodeFocus(node: TreeNode): void {
|
|
376
|
+
if (viewport === undefined) return
|
|
377
|
+
|
|
378
|
+
const box = viewport.getBoundingClientRect()
|
|
379
|
+
const left = node.x * scale + offsetX
|
|
380
|
+
const top = node.y * scale + offsetY
|
|
381
|
+
const outside =
|
|
382
|
+
left < FOCUS_MARGIN ||
|
|
383
|
+
left > box.width - FOCUS_MARGIN ||
|
|
384
|
+
top < FOCUS_MARGIN ||
|
|
385
|
+
top > box.height - FOCUS_MARGIN
|
|
386
|
+
|
|
387
|
+
if (outside) centreOn(node.x, node.y)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Fit whenever a DIFFERENT tree arrives. Keyed on the layout's own shape rather than on object
|
|
391
|
+
// identity: the panel rebuilds the layout every time a batch of fields lands, and re-fitting on
|
|
392
|
+
// each one would yank the map out from under a reviewer who had just panned somewhere. The current
|
|
393
|
+
// step is part of the key on purpose: when the visitor moves on, the map follows them.
|
|
394
|
+
//
|
|
395
|
+
// The last-fitted shape is a plain variable, not `$state` — writing it here must not re-run the
|
|
396
|
+
// effect that writes it.
|
|
397
|
+
const shape = $derived(
|
|
398
|
+
[
|
|
399
|
+
layout.nodes.length,
|
|
400
|
+
layout.width,
|
|
401
|
+
layout.height,
|
|
402
|
+
layout.nodes.find((node) => node.state === 'current')?.id ?? '',
|
|
403
|
+
].join(':'),
|
|
404
|
+
)
|
|
405
|
+
let fitted = ''
|
|
406
|
+
|
|
407
|
+
$effect(() => {
|
|
408
|
+
if (shape === fitted) return
|
|
409
|
+
fitted = shape
|
|
410
|
+
fit()
|
|
411
|
+
})
|
|
412
|
+
</script>
|
|
413
|
+
|
|
414
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
415
|
+
<div class="map" onkeydown={onMapKeydown}>
|
|
416
|
+
<div class="toolbar">
|
|
417
|
+
<button type="button" onclick={() => zoomByButton(ZOOM_STEP)}>Zoom in</button>
|
|
418
|
+
<button type="button" onclick={() => zoomByButton(1 / ZOOM_STEP)}>Zoom out</button>
|
|
419
|
+
<button type="button" onclick={fit}>Reset</button>
|
|
420
|
+
<span class="zoom" aria-live="polite">{Math.round(scale * 100)}%</span>
|
|
421
|
+
</div>
|
|
422
|
+
|
|
423
|
+
<!--
|
|
424
|
+
A pan surface, not a control: it has no role and no tab stop because everything it does is also
|
|
425
|
+
reachable another way — the zoom buttons, and focus-follows for the nodes. Adding a tab stop here
|
|
426
|
+
would put an announced-but-empty element in front of every node.
|
|
427
|
+
-->
|
|
428
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
429
|
+
<div
|
|
430
|
+
class="viewport"
|
|
431
|
+
class:panning
|
|
432
|
+
bind:this={viewport}
|
|
433
|
+
bind:clientWidth={viewWidth}
|
|
434
|
+
bind:clientHeight={viewHeight}
|
|
435
|
+
data-testid="devtools-journey-map"
|
|
436
|
+
onwheel={onWheel}
|
|
437
|
+
onpointerdown={onPointerDown}
|
|
438
|
+
onpointermove={onPointerMove}
|
|
439
|
+
onpointerup={endPan}
|
|
440
|
+
onpointercancel={endPan}
|
|
441
|
+
>
|
|
442
|
+
<div
|
|
443
|
+
class="canvas"
|
|
444
|
+
style:--x="{offsetX}px"
|
|
445
|
+
style:--y="{offsetY}px"
|
|
446
|
+
style:--k={scale}
|
|
447
|
+
style:--w="{layout.width}px"
|
|
448
|
+
style:--h="{layout.height}px"
|
|
449
|
+
>
|
|
450
|
+
<svg class="links" width={layout.width} height={layout.height} aria-hidden="true">
|
|
451
|
+
<!-- Under the links, so a jump never paints over the tree it is threading through. -->
|
|
452
|
+
{#each layout.jumps as jump (jump.id)}
|
|
453
|
+
<path
|
|
454
|
+
class="jump"
|
|
455
|
+
class:back={jump.back}
|
|
456
|
+
class:active={jumpActive(jump.from, jump.to)}
|
|
457
|
+
d={jump.path}
|
|
458
|
+
/>
|
|
459
|
+
{/each}
|
|
460
|
+
{#each layout.links as link (link.id)}
|
|
461
|
+
<path
|
|
462
|
+
class="link arm-{link.arm ?? 'none'}"
|
|
463
|
+
class:travelled={link.travelled}
|
|
464
|
+
d={link.path}
|
|
465
|
+
/>
|
|
466
|
+
{/each}
|
|
467
|
+
{#each layout.links as link (link.id)}
|
|
468
|
+
{#if link.label !== null}
|
|
469
|
+
<text class="link-label arm-{link.arm ?? 'none'}" x={link.labelX} y={link.labelY}
|
|
470
|
+
>{link.label}</text
|
|
471
|
+
>
|
|
472
|
+
{/if}
|
|
473
|
+
{/each}
|
|
474
|
+
</svg>
|
|
475
|
+
|
|
476
|
+
{#each layout.nodes as node (node.id)}
|
|
477
|
+
<button
|
|
478
|
+
type="button"
|
|
479
|
+
class="node kind-{node.kind} state-{node.state}"
|
|
480
|
+
class:leaf={node.leaf}
|
|
481
|
+
class:reference={node.reference}
|
|
482
|
+
class:jumpable={canJump(node)}
|
|
483
|
+
class:selected={selected === node.id}
|
|
484
|
+
style:--nx="{node.x}px"
|
|
485
|
+
style:--ny="{node.y}px"
|
|
486
|
+
title={node.reference ? `Rejoins ${node.label}` : node.label}
|
|
487
|
+
data-testid={node.kind === 'screen' && !node.reference
|
|
488
|
+
? 'devtools-journey-screen'
|
|
489
|
+
: undefined}
|
|
490
|
+
onclick={() => activate(node)}
|
|
491
|
+
onfocus={() => onNodeFocus(node)}
|
|
492
|
+
>
|
|
493
|
+
<span class="label">
|
|
494
|
+
{node.reference ? `↩ ${node.label}` : node.label}
|
|
495
|
+
{#if node.state === 'current' && !node.reference}
|
|
496
|
+
<span class="here">You are here</span>
|
|
497
|
+
{:else if canJump(node)}
|
|
498
|
+
<span class="go-back">Go back</span>
|
|
499
|
+
{/if}
|
|
500
|
+
</span>
|
|
501
|
+
<span class="dot" aria-hidden="true"></span>
|
|
502
|
+
</button>
|
|
503
|
+
{/each}
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
{#if selectedNode !== null && popover !== null}
|
|
507
|
+
<div
|
|
508
|
+
class="popover"
|
|
509
|
+
role="dialog"
|
|
510
|
+
aria-label={selectedNode.label}
|
|
511
|
+
style:--left="{popover.left}px"
|
|
512
|
+
style:--top="{popover.top}px"
|
|
513
|
+
style:--width="{POP_WIDTH}px"
|
|
514
|
+
bind:offsetHeight={popHeight}
|
|
515
|
+
data-testid="devtools-journey-popover"
|
|
516
|
+
>
|
|
517
|
+
<div class="pop-head">
|
|
518
|
+
<span class="key kind-{selectedNode.kind}" class:reference={selectedNode.reference}
|
|
519
|
+
></span>
|
|
520
|
+
<span class="pop-kind">
|
|
521
|
+
{selectedNode.reference ? 'Rejoins an earlier branch' : KIND_LABELS[selectedNode.kind]}
|
|
522
|
+
</span>
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
class="pop-close"
|
|
526
|
+
aria-label="Close"
|
|
527
|
+
onclick={() => (selected = null)}>×</button
|
|
528
|
+
>
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
<p class="pop-title">{selectedNode.label}</p>
|
|
532
|
+
|
|
533
|
+
{#if selectedNode.state === 'current' && !selectedNode.reference}
|
|
534
|
+
<p class="pop-state">You are here</p>
|
|
535
|
+
{:else if selectedNode.state === 'visited'}
|
|
536
|
+
<p class="pop-state">On the route taken this session</p>
|
|
537
|
+
{/if}
|
|
538
|
+
|
|
539
|
+
{#if selectedNode.detail !== null}
|
|
540
|
+
<code class="pop-key">{selectedNode.detail}</code>
|
|
541
|
+
{/if}
|
|
542
|
+
|
|
543
|
+
{#if selectedNode.reference}
|
|
544
|
+
<p class="pop-text">
|
|
545
|
+
This route joins {selectedNode.label}, which is drawn in full on an earlier branch. The
|
|
546
|
+
dashed line shows the way.
|
|
547
|
+
</p>
|
|
548
|
+
{/if}
|
|
549
|
+
|
|
550
|
+
{#if selectedNode.ends.length > 0}
|
|
551
|
+
<ul class="pop-arms">
|
|
552
|
+
{#each selectedNode.ends as end (end.key)}
|
|
553
|
+
<li>
|
|
554
|
+
{#if end.armLabel !== null}
|
|
555
|
+
<span class="arm-name arm-{end.arm ?? 'none'}">{end.armLabel}</span>
|
|
556
|
+
{/if}
|
|
557
|
+
<span>Ends: {end.label} (behind the scenes)</span>
|
|
558
|
+
</li>
|
|
559
|
+
{/each}
|
|
560
|
+
</ul>
|
|
561
|
+
{/if}
|
|
562
|
+
|
|
563
|
+
{#if arms.length > 0}
|
|
564
|
+
<ul class="pop-arms">
|
|
565
|
+
{#each arms as arm (arm.id)}
|
|
566
|
+
<li>
|
|
567
|
+
<span class="arm-name arm-{arm.arm ?? 'none'}">{arm.label}</span>
|
|
568
|
+
<span>→ {arm.to}</span>
|
|
569
|
+
</li>
|
|
570
|
+
{/each}
|
|
571
|
+
</ul>
|
|
572
|
+
{/if}
|
|
573
|
+
|
|
574
|
+
{#if selectedNode.rule !== null}
|
|
575
|
+
<!-- The sentence is lossy by design; the verbatim rule is what makes that safe. -->
|
|
576
|
+
<pre class="pop-rule">{selectedNode.rule}</pre>
|
|
577
|
+
{/if}
|
|
578
|
+
|
|
579
|
+
{#if selectedNode.reference}
|
|
580
|
+
<button type="button" class="pop-action" onclick={() => showDrawn(selectedNode)}>
|
|
581
|
+
Go to node
|
|
582
|
+
</button>
|
|
583
|
+
{:else if canJump(selectedNode)}
|
|
584
|
+
<button type="button" class="pop-action" onclick={() => goBack(selectedNode)}>
|
|
585
|
+
Go back to this screen
|
|
586
|
+
</button>
|
|
587
|
+
{/if}
|
|
588
|
+
</div>
|
|
589
|
+
{/if}
|
|
590
|
+
</div>
|
|
591
|
+
|
|
592
|
+
<p class="legend">
|
|
593
|
+
<span class="key kind-start"></span> Start
|
|
594
|
+
<span class="key kind-screen"></span> Screen
|
|
595
|
+
<span class="key kind-decision"></span> Decision
|
|
596
|
+
<span class="key kind-journey"></span> Opens another journey
|
|
597
|
+
<span class="key reference"></span> Rejoins an earlier branch (dashed)
|
|
598
|
+
<span class="key seen"></span> Seen
|
|
599
|
+
<span class="key current"></span> You are here
|
|
600
|
+
{#if layout.truncated}
|
|
601
|
+
<span class="truncated">· the deepest branches are cut short</span>
|
|
602
|
+
{/if}
|
|
603
|
+
</p>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
<style>
|
|
607
|
+
.map {
|
|
608
|
+
display: flex;
|
|
609
|
+
flex-direction: column;
|
|
610
|
+
gap: var(--space-2);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.toolbar {
|
|
614
|
+
display: flex;
|
|
615
|
+
align-items: center;
|
|
616
|
+
gap: var(--space-1);
|
|
617
|
+
align-self: flex-end;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.toolbar button {
|
|
621
|
+
padding: var(--space-0-5) var(--space-2);
|
|
622
|
+
font-family: inherit;
|
|
623
|
+
font-size: var(--text-xs);
|
|
624
|
+
color: var(--color-gray-700);
|
|
625
|
+
background-color: var(--color-white);
|
|
626
|
+
border: 1px solid var(--color-gray-200);
|
|
627
|
+
border-radius: var(--radius-md);
|
|
628
|
+
cursor: pointer;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.toolbar button:hover {
|
|
632
|
+
background-color: var(--color-gray-50);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
.toolbar button:focus-visible {
|
|
636
|
+
outline: var(--ring-width) solid var(--ring-color);
|
|
637
|
+
outline-offset: var(--ring-offset);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.zoom {
|
|
641
|
+
min-width: 3.5em;
|
|
642
|
+
padding: var(--space-0-5) var(--space-2);
|
|
643
|
+
font-size: var(--text-xs);
|
|
644
|
+
font-variant-numeric: tabular-nums;
|
|
645
|
+
text-align: center;
|
|
646
|
+
color: var(--color-gray-600);
|
|
647
|
+
background-color: var(--color-gray-100);
|
|
648
|
+
border-radius: var(--radius-full);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.viewport {
|
|
652
|
+
position: relative;
|
|
653
|
+
box-sizing: border-box;
|
|
654
|
+
overflow: hidden;
|
|
655
|
+
/* Grows with a panel that is 90% of the viewport: what is left once the panel's header, the
|
|
656
|
+
view and journey row, the map's toolbar and its legend (about 223px together) have theirs. */
|
|
657
|
+
height: max(420px, calc(90vh - 223px));
|
|
658
|
+
border: 1px solid var(--color-gray-200);
|
|
659
|
+
border-radius: var(--radius-lg);
|
|
660
|
+
background-color: var(--color-white);
|
|
661
|
+
cursor: grab;
|
|
662
|
+
touch-action: none;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.viewport.panning {
|
|
666
|
+
cursor: grabbing;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.canvas {
|
|
670
|
+
position: absolute;
|
|
671
|
+
top: 0;
|
|
672
|
+
left: 0;
|
|
673
|
+
width: var(--w);
|
|
674
|
+
height: var(--h);
|
|
675
|
+
transform: translate(var(--x), var(--y)) scale(var(--k));
|
|
676
|
+
transform-origin: 0 0;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.links {
|
|
680
|
+
position: absolute;
|
|
681
|
+
top: 0;
|
|
682
|
+
left: 0;
|
|
683
|
+
overflow: visible;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/* The experience tree's link colours: slate, and a decision's arms green for Yes and red for
|
|
687
|
+
Otherwise. */
|
|
688
|
+
.link {
|
|
689
|
+
fill: none;
|
|
690
|
+
stroke: #94a3b8;
|
|
691
|
+
stroke-width: 2;
|
|
692
|
+
/* Strokes stay readable at every zoom instead of thinning to nothing with the transform. */
|
|
693
|
+
vector-effect: non-scaling-stroke;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.link.arm-yes {
|
|
697
|
+
stroke: #10b981;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.link.arm-no {
|
|
701
|
+
stroke: var(--color-red-500);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/* The visitor's route, over the arm colour — "you came this way" outranks "this was a Yes". */
|
|
705
|
+
.link.travelled {
|
|
706
|
+
stroke: var(--color-blue-600);
|
|
707
|
+
stroke-width: 3.5;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.link-label {
|
|
711
|
+
font-size: 10px;
|
|
712
|
+
font-weight: 700;
|
|
713
|
+
text-anchor: middle;
|
|
714
|
+
fill: #64748b;
|
|
715
|
+
paint-order: stroke;
|
|
716
|
+
stroke: var(--color-white);
|
|
717
|
+
stroke-width: 3px;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.link-label.arm-yes {
|
|
721
|
+
fill: #10b981;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.link-label.arm-no {
|
|
725
|
+
fill: var(--color-red-500);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/*
|
|
729
|
+
* A node is a column of label then dot, placed so the DOT's centre sits on the layout point: the
|
|
730
|
+
* box's bottom edge is the dot's bottom, so it is lifted by its full height less one dot radius.
|
|
731
|
+
* At the end of a branch the column flips — dot then label — and drops by one radius instead.
|
|
732
|
+
*/
|
|
733
|
+
.node {
|
|
734
|
+
--r: 10px;
|
|
735
|
+
position: absolute;
|
|
736
|
+
left: var(--nx);
|
|
737
|
+
top: var(--ny);
|
|
738
|
+
width: 184px;
|
|
739
|
+
display: flex;
|
|
740
|
+
flex-direction: column;
|
|
741
|
+
align-items: center;
|
|
742
|
+
gap: 4px;
|
|
743
|
+
padding: 0;
|
|
744
|
+
font-family: inherit;
|
|
745
|
+
background: none;
|
|
746
|
+
border: none;
|
|
747
|
+
cursor: default;
|
|
748
|
+
transform: translate(-50%, calc(-100% + var(--r)));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/* The "you are here" halo reaches 9px past the dot; keep the label clear of it. */
|
|
752
|
+
.node.state-current {
|
|
753
|
+
gap: 12px;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.node.leaf {
|
|
757
|
+
flex-direction: column-reverse;
|
|
758
|
+
transform: translate(-50%, calc(-1 * var(--r)));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.node.kind-start {
|
|
762
|
+
--r: 15px;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.node:focus-visible {
|
|
766
|
+
outline: none;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.node:focus-visible .dot {
|
|
770
|
+
outline: var(--ring-width) solid var(--ring-color);
|
|
771
|
+
outline-offset: 2px;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.node.jumpable {
|
|
775
|
+
cursor: pointer;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.label {
|
|
779
|
+
max-width: 100%;
|
|
780
|
+
padding: 0 var(--space-1);
|
|
781
|
+
font-size: 11px;
|
|
782
|
+
line-height: 1.25;
|
|
783
|
+
color: #1e293b;
|
|
784
|
+
text-align: center;
|
|
785
|
+
border-radius: var(--radius-sm);
|
|
786
|
+
/* A white halo, so a link passing behind a label never strikes through the words. */
|
|
787
|
+
background-color: rgb(255 255 255 / 85%);
|
|
788
|
+
/* Two lines, then an ellipsis; the full name is in the title and in the pop-out. */
|
|
789
|
+
display: -webkit-box;
|
|
790
|
+
-webkit-box-orient: vertical;
|
|
791
|
+
-webkit-line-clamp: 2;
|
|
792
|
+
line-clamp: 2;
|
|
793
|
+
overflow: hidden;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
.node.selected .label {
|
|
797
|
+
color: var(--color-blue-700);
|
|
798
|
+
font-weight: var(--font-semibold);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
.node.jumpable:hover .label {
|
|
802
|
+
text-decoration: underline;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.reference .label {
|
|
806
|
+
color: #0369a1;
|
|
807
|
+
font-style: italic;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/* The experience tree's dot colours, with their white rim. */
|
|
811
|
+
.dot,
|
|
812
|
+
.key {
|
|
813
|
+
flex-shrink: 0;
|
|
814
|
+
border-radius: var(--radius-full);
|
|
815
|
+
background-color: var(--color-blue-500);
|
|
816
|
+
box-shadow: 0 0 0 2px var(--color-white);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
.dot {
|
|
820
|
+
width: calc(var(--r) * 2);
|
|
821
|
+
height: calc(var(--r) * 2);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
.kind-start .dot,
|
|
825
|
+
.key.kind-start {
|
|
826
|
+
background-color: var(--color-gray-500);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
.kind-decision .dot,
|
|
830
|
+
.key.kind-decision {
|
|
831
|
+
background-color: #f59e0b;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
.kind-journey .dot,
|
|
835
|
+
.key.kind-journey {
|
|
836
|
+
background-color: #8b5cf6;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.reference .dot,
|
|
840
|
+
.key.reference {
|
|
841
|
+
background-color: #38bdf8;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/* The trail, laid over the type colour: a dark rim for seen, a halo for where the visitor is. */
|
|
845
|
+
.state-visited .dot,
|
|
846
|
+
.key.seen {
|
|
847
|
+
box-shadow:
|
|
848
|
+
0 0 0 2px var(--color-white),
|
|
849
|
+
0 0 0 4px var(--color-blue-700);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.state-current .dot,
|
|
853
|
+
.key.current {
|
|
854
|
+
box-shadow:
|
|
855
|
+
0 0 0 2px var(--color-white),
|
|
856
|
+
0 0 0 4px var(--color-blue-700),
|
|
857
|
+
0 0 0 9px #bfdbfe;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
.here,
|
|
861
|
+
.go-back {
|
|
862
|
+
display: block;
|
|
863
|
+
font-size: 9px;
|
|
864
|
+
font-weight: var(--font-medium);
|
|
865
|
+
color: var(--color-blue-600);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
.go-back {
|
|
869
|
+
opacity: 0;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
.node.jumpable:hover .go-back,
|
|
873
|
+
.node.jumpable:focus-visible .go-back {
|
|
874
|
+
opacity: 1;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/* The dashed way back from a rejoin, in the rejoin dot's own sky blue. */
|
|
878
|
+
.jump {
|
|
879
|
+
fill: none;
|
|
880
|
+
stroke: #38bdf8;
|
|
881
|
+
stroke-width: 1.5;
|
|
882
|
+
stroke-dasharray: 6 5;
|
|
883
|
+
opacity: 0.75;
|
|
884
|
+
vector-effect: non-scaling-stroke;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
.jump.active {
|
|
888
|
+
stroke: #0284c7;
|
|
889
|
+
stroke-width: 2.5;
|
|
890
|
+
opacity: 1;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/* `border-box`, so `--width` is the whole pop-out and the placement above holds. */
|
|
894
|
+
.popover {
|
|
895
|
+
position: absolute;
|
|
896
|
+
left: var(--left);
|
|
897
|
+
top: var(--top);
|
|
898
|
+
z-index: 2;
|
|
899
|
+
box-sizing: border-box;
|
|
900
|
+
width: var(--width);
|
|
901
|
+
max-height: calc(100% - 16px);
|
|
902
|
+
overflow-y: auto;
|
|
903
|
+
display: flex;
|
|
904
|
+
flex-direction: column;
|
|
905
|
+
gap: var(--space-2);
|
|
906
|
+
padding: var(--space-3);
|
|
907
|
+
background-color: var(--color-white);
|
|
908
|
+
border: 1px solid var(--color-gray-200);
|
|
909
|
+
border-radius: var(--radius-lg);
|
|
910
|
+
box-shadow: var(--shadow-xl);
|
|
911
|
+
cursor: default;
|
|
912
|
+
scrollbar-width: thin;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
.pop-head {
|
|
916
|
+
display: flex;
|
|
917
|
+
align-items: center;
|
|
918
|
+
gap: var(--space-1-5);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
.pop-head .key {
|
|
922
|
+
margin-left: 0;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
.pop-kind {
|
|
926
|
+
flex: 1;
|
|
927
|
+
font-size: var(--text-xs);
|
|
928
|
+
font-weight: var(--font-medium);
|
|
929
|
+
color: var(--color-gray-500);
|
|
930
|
+
text-transform: uppercase;
|
|
931
|
+
letter-spacing: 0.04em;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
.pop-close {
|
|
935
|
+
width: 24px;
|
|
936
|
+
height: 24px;
|
|
937
|
+
padding: 0;
|
|
938
|
+
font-family: inherit;
|
|
939
|
+
font-size: var(--text-base);
|
|
940
|
+
line-height: 1;
|
|
941
|
+
color: var(--color-gray-500);
|
|
942
|
+
background: none;
|
|
943
|
+
border: none;
|
|
944
|
+
border-radius: var(--radius-md);
|
|
945
|
+
cursor: pointer;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
.pop-close:hover {
|
|
949
|
+
background-color: var(--color-gray-100);
|
|
950
|
+
color: var(--color-gray-900);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
.pop-close:focus-visible,
|
|
954
|
+
.pop-action:focus-visible {
|
|
955
|
+
outline: var(--ring-width) solid var(--ring-color);
|
|
956
|
+
outline-offset: var(--ring-offset);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.pop-title {
|
|
960
|
+
margin: 0;
|
|
961
|
+
font-size: var(--text-sm);
|
|
962
|
+
font-weight: var(--font-semibold);
|
|
963
|
+
color: var(--color-gray-900);
|
|
964
|
+
overflow-wrap: anywhere;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
.pop-state {
|
|
968
|
+
margin: 0;
|
|
969
|
+
font-size: var(--text-xs);
|
|
970
|
+
font-weight: var(--font-medium);
|
|
971
|
+
color: var(--color-blue-600);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
.pop-text {
|
|
975
|
+
margin: 0;
|
|
976
|
+
font-size: var(--text-xs);
|
|
977
|
+
color: var(--color-gray-600);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
.pop-key,
|
|
981
|
+
.pop-rule {
|
|
982
|
+
font-family: 'Monaco', 'Menlo', 'Courier New', monospace;
|
|
983
|
+
font-size: 10px;
|
|
984
|
+
color: var(--color-gray-500);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
.pop-key {
|
|
988
|
+
align-self: flex-start;
|
|
989
|
+
padding: 1px var(--space-1);
|
|
990
|
+
background-color: var(--color-gray-100);
|
|
991
|
+
border-radius: var(--radius-sm);
|
|
992
|
+
overflow-wrap: anywhere;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.pop-arms {
|
|
996
|
+
display: flex;
|
|
997
|
+
flex-direction: column;
|
|
998
|
+
gap: var(--space-1);
|
|
999
|
+
margin: 0;
|
|
1000
|
+
padding: 0;
|
|
1001
|
+
list-style: none;
|
|
1002
|
+
font-size: var(--text-xs);
|
|
1003
|
+
color: var(--color-gray-700);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.pop-arms li {
|
|
1007
|
+
display: flex;
|
|
1008
|
+
gap: var(--space-1-5);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.arm-name {
|
|
1012
|
+
flex-shrink: 0;
|
|
1013
|
+
font-weight: 700;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
.arm-name.arm-yes {
|
|
1017
|
+
color: #10b981;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
.arm-name.arm-no {
|
|
1021
|
+
color: var(--color-red-500);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.pop-rule {
|
|
1025
|
+
max-height: 180px;
|
|
1026
|
+
margin: 0;
|
|
1027
|
+
padding: var(--space-2);
|
|
1028
|
+
overflow: auto;
|
|
1029
|
+
background-color: var(--color-gray-50);
|
|
1030
|
+
border: 1px solid var(--color-gray-200);
|
|
1031
|
+
border-radius: var(--radius-md);
|
|
1032
|
+
white-space: pre-wrap;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
.pop-action {
|
|
1036
|
+
align-self: flex-start;
|
|
1037
|
+
padding: var(--space-1) var(--space-3);
|
|
1038
|
+
font-family: inherit;
|
|
1039
|
+
font-size: var(--text-xs);
|
|
1040
|
+
font-weight: var(--font-medium);
|
|
1041
|
+
color: var(--color-white);
|
|
1042
|
+
background-color: var(--color-blue-600);
|
|
1043
|
+
border: none;
|
|
1044
|
+
border-radius: var(--radius-md);
|
|
1045
|
+
cursor: pointer;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
.pop-action:hover {
|
|
1049
|
+
background-color: var(--color-blue-700);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.legend {
|
|
1053
|
+
display: flex;
|
|
1054
|
+
align-items: center;
|
|
1055
|
+
flex-wrap: wrap;
|
|
1056
|
+
gap: var(--space-1-5);
|
|
1057
|
+
margin: 0;
|
|
1058
|
+
font-size: var(--text-xs);
|
|
1059
|
+
color: var(--color-gray-500);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
.key {
|
|
1063
|
+
width: 10px;
|
|
1064
|
+
height: 10px;
|
|
1065
|
+
margin-left: var(--space-1);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.key.seen,
|
|
1069
|
+
.key.current {
|
|
1070
|
+
background-color: var(--color-white);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.truncated {
|
|
1074
|
+
margin-left: auto;
|
|
1075
|
+
}
|
|
1076
|
+
</style>
|