@motion-proto/live-tokens 0.72.1 → 0.73.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.
@@ -0,0 +1,836 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { navigate } from '../core/routing/router';
4
+ import Button from '../../system/components/Button.svelte';
5
+ import Callout from '../../system/components/Callout.svelte';
6
+ import TabBar from '../../system/components/TabBar.svelte';
7
+ import SourcePane from './SourcePane.svelte';
8
+ import TreeNodeCard from './TreeNodeCard.svelte';
9
+ import { SKILL_DOC, skillDocs } from './skillSources';
10
+ import { skillTrees } from './skillTrees';
11
+ import type { Edge, LineRange, Selection, TreeNode } from './types';
12
+
13
+ // `/skills#pair-fonts` opens that skill, so a link can hand someone one tree
14
+ // rather than the atlas front door.
15
+ const linked = window.location.hash.slice(1);
16
+ let active = $state(linked in skillTrees ? linked : Object.keys(skillTrees)[0]);
17
+ let selection: Selection | null = $state(null);
18
+ /** Which of the skill's documents the source pane shows. */
19
+ let doc: string = $state(SKILL_DOC);
20
+
21
+ let tree = $derived(skillTrees[active]);
22
+ let docs = $derived(skillDocs[active]);
23
+ let lines = $derived(docs[doc] ?? docs[SKILL_DOC]);
24
+ let siblings = $derived(Object.keys(docs).filter((name) => name !== SKILL_DOC));
25
+
26
+ let tabs = $derived(
27
+ Object.entries(skillTrees).map(([id, t]) => ({
28
+ id,
29
+ label: `${t.title}\n${skillDocs[id][SKILL_DOC].length} lines`,
30
+ })),
31
+ );
32
+
33
+ let docTabs = $derived(
34
+ Object.keys(docs).map((name) => ({
35
+ id: name,
36
+ label: name.replace('references/', '').replace(/\.md$/, ''),
37
+ })),
38
+ );
39
+
40
+ function openDoc(name: string) {
41
+ doc = name;
42
+ sourcePane?.scrollTo({ top: 0 });
43
+ }
44
+
45
+ let rows = $derived.by(() => {
46
+ const byRow = new Map<number, TreeNode[]>();
47
+ for (const node of tree.nodes) {
48
+ const group = byRow.get(node.row);
49
+ if (group) group.push(node);
50
+ else byRow.set(node.row, [node]);
51
+ }
52
+ return [...byRow.entries()].sort((a, b) => a[0] - b[0]).map(([, group]) => group);
53
+ });
54
+
55
+ function selectTarget(key: string, label: string, range: LineRange) {
56
+ selection = { key, label, lines: range };
57
+ // Every range cites SKILL.md, so a step selected while a reference is open
58
+ // would otherwise highlight nothing.
59
+ doc = SKILL_DOC;
60
+ scrollSourceTo(range[0]);
61
+ }
62
+
63
+ /** Every target a line could belong to, node and chip alike. */
64
+ function targets(): Selection[] {
65
+ return tree.nodes.flatMap((node) => [
66
+ ...(node.lines ? [{ key: node.id, label: node.title, lines: node.lines }] : []),
67
+ ...(node.chips ?? []).map((chip, i) => ({
68
+ key: `${node.id}:${i}`,
69
+ label: chip.label,
70
+ lines: chip.lines,
71
+ })),
72
+ ]);
73
+ }
74
+
75
+ /** Reverse lookup: the narrowest target whose range covers this line wins. */
76
+ function selectFromLine(lineNumber: number) {
77
+ const covering = targets()
78
+ .filter(({ lines: [from, to] }) => lineNumber >= from && lineNumber <= to)
79
+ .sort((a, b) => a.lines[1] - a.lines[0] - (b.lines[1] - b.lines[0]));
80
+
81
+ const best = covering[0];
82
+ if (!best) return;
83
+
84
+ selection = best;
85
+ treePane
86
+ ?.querySelector(`[data-node="${best.key.split(':')[0]}"]`)
87
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
88
+ }
89
+
90
+ async function scrollSourceTo(lineNumber: number) {
91
+ // The row only exists once a switch back to SKILL.md has rendered.
92
+ await tick();
93
+ sourcePane
94
+ ?.querySelector(`[data-line="${lineNumber}"]`)
95
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
96
+ }
97
+
98
+ function changeTab(id: string) {
99
+ active = id;
100
+ selection = null;
101
+ doc = SKILL_DOC;
102
+ history.replaceState(null, '', `${window.location.pathname}#${id}`);
103
+ treePane?.scrollTo({ top: 0 });
104
+ sourcePane?.scrollTo({ top: 0 });
105
+ }
106
+
107
+ // ---- wires -------------------------------------------------------------
108
+ // Connectors are measured from the laid-out cards, so they survive wrapping,
109
+ // theme changes, and the geometry skill moving every radius and padding.
110
+ let canvas: HTMLElement | undefined = $state();
111
+ let treePane: HTMLElement | undefined = $state();
112
+ let sourcePane: HTMLElement | undefined = $state();
113
+
114
+ interface Wire {
115
+ d: string;
116
+ back: boolean;
117
+ lit: boolean;
118
+ /** Trunks and buses carry no arrowhead; only the segment that arrives does. */
119
+ arrow: boolean;
120
+ }
121
+
122
+ interface Label {
123
+ x: number;
124
+ y: number;
125
+ text: string;
126
+ lit: boolean;
127
+ /** Lane labels run along the lane; branch labels sit across the drop. */
128
+ rotate: boolean;
129
+ }
130
+
131
+ interface Box {
132
+ left: number;
133
+ right: number;
134
+ top: number;
135
+ bottom: number;
136
+ midX: number;
137
+ midY: number;
138
+ }
139
+
140
+ interface Drawing {
141
+ wires: Wire[];
142
+ labels: Label[];
143
+ }
144
+
145
+ let wires: Wire[] = $state([]);
146
+ let labels: Label[] = $state([]);
147
+ let canvasSize = $state({ w: 0, h: 0 });
148
+
149
+ /** Clearance between a card border and the wire, so the arrowhead reads as
150
+ * an arrival rather than as part of the card outline. */
151
+ const STANDOFF = 6;
152
+ const CORNER = 12;
153
+ /** How far outside the cards a lane sits, and how far one lane nests
154
+ * inside the next when two share a stretch of gutter. */
155
+ const LANE_INSET = 40;
156
+ const LANE_STEP = 16;
157
+
158
+ /** Cards laid out in one row share a top to within a pixel; cards pushed onto
159
+ * a second wrapped line do not, and a single bus cannot serve both. */
160
+ function aligned(values: number[]): boolean {
161
+ return Math.max(...values) - Math.min(...values) < 4;
162
+ }
163
+
164
+ function push<T>(map: Map<string, T[]>, key: string, value: T) {
165
+ const list = map.get(key);
166
+ if (list) list.push(value);
167
+ else map.set(key, [value]);
168
+ }
169
+
170
+ function label(out: Drawing, text: string | undefined, x: number, y: number, lit: boolean, rotate = false) {
171
+ if (text) out.labels.push({ x, y, text, lit, rotate });
172
+ }
173
+
174
+ /** One trunk down from the parent, one bus across, one drop into each child —
175
+ * so a split reads as a single decision rather than as N crossing curves.
176
+ * Each answer sits on its own drop. */
177
+ function fanOut(out: Drawing, a: Box, kids: Box[], edges: Edge[], lit: (i: number) => boolean) {
178
+ const top = kids[0].top;
179
+ const busY = (a.bottom + top) / 2;
180
+ const xs = kids.map((k) => k.midX);
181
+ const r = Math.max(
182
+ 0,
183
+ Math.min(CORNER, (Math.max(...xs) - Math.min(...xs)) / 2, (busY - a.bottom) / 2, (top - busY) / 2),
184
+ );
185
+ const turns = xs.map((x) => (x < a.midX ? x + r : x > a.midX ? x - r : x));
186
+ const busLeft = Math.min(...turns, a.midX);
187
+ const busRight = Math.max(...turns, a.midX);
188
+ const anyLit = kids.some((_, i) => lit(i));
189
+
190
+ out.wires.push({
191
+ back: false,
192
+ arrow: false,
193
+ lit: anyLit,
194
+ d: `M ${a.midX} ${a.bottom + STANDOFF} L ${a.midX} ${busY} M ${busLeft} ${busY} L ${busRight} ${busY}`,
195
+ });
196
+ kids.forEach((k, i) => {
197
+ const x = k.midX;
198
+ const turn = turns[i];
199
+ out.wires.push({
200
+ back: false,
201
+ arrow: true,
202
+ lit: lit(i),
203
+ d:
204
+ turn === x
205
+ ? `M ${x} ${busY} L ${x} ${top - STANDOFF}`
206
+ : `M ${turn} ${busY} Q ${x} ${busY}, ${x} ${busY + r} L ${x} ${top - STANDOFF}`,
207
+ });
208
+ label(out, edges[i].label, x, (busY + r + top - STANDOFF) / 2, lit(i));
209
+ });
210
+ }
211
+
212
+ /** The mirror of `fanOut`: risers up to a shared bus, one trunk into the child,
213
+ * so several outcomes converging land as one arrow instead of a pile. */
214
+ function fanIn(out: Drawing, parents: Box[], b: Box, edges: Edge[], lit: (i: number) => boolean) {
215
+ const bottom = parents[0].bottom;
216
+ const busY = (bottom + b.top) / 2;
217
+ const xs = parents.map((p) => p.midX);
218
+ const r = Math.max(
219
+ 0,
220
+ Math.min(CORNER, (Math.max(...xs) - Math.min(...xs)) / 2, (busY - bottom) / 2, (b.top - busY) / 2),
221
+ );
222
+ const turns = xs.map((x) => (x < b.midX ? x + r : x > b.midX ? x - r : x));
223
+ const anyLit = parents.some((_, i) => lit(i));
224
+
225
+ parents.forEach((p, i) => {
226
+ const x = p.midX;
227
+ const turn = turns[i];
228
+ out.wires.push({
229
+ back: false,
230
+ arrow: false,
231
+ lit: lit(i),
232
+ d:
233
+ turn === x
234
+ ? `M ${x} ${bottom + STANDOFF} L ${x} ${busY}`
235
+ : `M ${x} ${bottom + STANDOFF} L ${x} ${busY - r} Q ${x} ${busY}, ${turn} ${busY}`,
236
+ });
237
+ label(out, edges[i].label, x, (bottom + STANDOFF + busY - r) / 2, lit(i));
238
+ });
239
+ out.wires.push({
240
+ back: false,
241
+ arrow: true,
242
+ lit: anyLit,
243
+ d:
244
+ `M ${Math.min(...turns, b.midX)} ${busY} L ${Math.max(...turns, b.midX)} ${busY}` +
245
+ ` M ${b.midX} ${busY} L ${b.midX} ${b.top - STANDOFF}`,
246
+ });
247
+ }
248
+
249
+ /** A plain parent-to-child curve, and the fallback wherever a bus would have
250
+ * to cross a card: children wrapped onto a second line, or parents staggered
251
+ * across rows. */
252
+ function curve(out: Drawing, a: Box, b: Box, edge: Edge, lit: boolean) {
253
+ const y1 = a.bottom + STANDOFF;
254
+ const y2 = b.top - STANDOFF;
255
+ const bend = Math.max(16, (y2 - y1) * 0.55);
256
+ out.wires.push({
257
+ back: false,
258
+ arrow: true,
259
+ lit,
260
+ d: `M ${a.midX} ${y1} C ${a.midX} ${y1 + bend}, ${b.midX} ${y2 - bend}, ${b.midX} ${y2}`,
261
+ });
262
+ label(out, edge.label, (a.midX + b.midX) / 2, (y1 + y2) / 2, lit);
263
+ }
264
+
265
+ /** Every card the span passes between, not just the two it joins. */
266
+ function crossed(a: Box, b: Box, all: Box[]): Box[] {
267
+ const top = Math.min(a.midY, b.midY);
268
+ const bottom = Math.max(a.midY, b.midY);
269
+ return all.filter((x) => x !== a && x !== b && x.bottom > top && x.top < bottom);
270
+ }
271
+
272
+ /** On the spine: no other card shares its row. A wrapped branch row also
273
+ * "crosses" cards on its way down, and those stay curves. */
274
+ function alone(box: Box, all: Box[]): boolean {
275
+ return all.every((x) => x === box || !aligned([x.top, box.top]));
276
+ }
277
+
278
+ /** Re-run loops ride a lane in the left gutter the centred spine opens up,
279
+ * rather than bulging around the card they leave. */
280
+ function backEdge(out: Drawing, a: Box, b: Box, lane: number, edge: Edge, lit: boolean) {
281
+ const dir = Math.sign(b.midY - a.midY) || -1;
282
+ const r = Math.max(
283
+ 0,
284
+ Math.min(CORNER, (a.left - STANDOFF - lane) / 2, Math.abs(a.midY - b.midY) / 2),
285
+ );
286
+ out.wires.push({
287
+ back: true,
288
+ arrow: true,
289
+ lit,
290
+ d:
291
+ `M ${a.left - STANDOFF} ${a.midY} L ${lane + r} ${a.midY}` +
292
+ ` Q ${lane} ${a.midY}, ${lane} ${a.midY + dir * r}` +
293
+ ` L ${lane} ${b.midY - dir * r} Q ${lane} ${b.midY}, ${lane + r} ${b.midY}` +
294
+ ` L ${b.left - STANDOFF} ${b.midY}`,
295
+ });
296
+ label(out, edge.label ?? 're-run', lane, (a.midY + b.midY) / 2, lit, true);
297
+ }
298
+
299
+ /** A branch that skips a row rides the right gutter, the mirror of a loop,
300
+ * so a "no" never has to be drawn through the card the "yes" leads to. */
301
+ function skipEdge(out: Drawing, a: Box, b: Box, lane: number, edge: Edge, lit: boolean) {
302
+ const r = Math.max(
303
+ 0,
304
+ Math.min(CORNER, (lane - a.right - STANDOFF) / 2, Math.abs(a.midY - b.midY) / 2),
305
+ );
306
+ out.wires.push({
307
+ back: false,
308
+ arrow: true,
309
+ lit,
310
+ d:
311
+ `M ${a.right + STANDOFF} ${a.midY} L ${lane - r} ${a.midY}` +
312
+ ` Q ${lane} ${a.midY}, ${lane} ${a.midY + r}` +
313
+ ` L ${lane} ${b.midY - r} Q ${lane} ${b.midY}, ${lane - r} ${b.midY}` +
314
+ ` L ${b.right + STANDOFF} ${b.midY}`,
315
+ });
316
+ label(out, edge.label, lane, (a.midY + b.midY) / 2, lit, true);
317
+ }
318
+
319
+ function drawWires() {
320
+ if (!canvas) return;
321
+ const base = canvas.getBoundingClientRect();
322
+ canvasSize = { w: base.width, h: base.height };
323
+ const selectedNode = selection?.key.split(':')[0] ?? null;
324
+ const lit = (...ids: string[]) => ids.includes(selectedNode ?? '');
325
+
326
+ const box = (id: string): Box | null => {
327
+ const el = canvas?.querySelector(`[data-node="${id}"]`);
328
+ if (!el) return null;
329
+ const r = el.getBoundingClientRect();
330
+ return {
331
+ left: r.left - base.left,
332
+ right: r.right - base.left,
333
+ top: r.top - base.top,
334
+ bottom: r.bottom - base.top,
335
+ midX: r.left + r.width / 2 - base.left,
336
+ midY: r.top + r.height / 2 - base.top,
337
+ };
338
+ };
339
+
340
+ const boxes = new Map<string, Box>();
341
+ for (const node of tree.nodes) {
342
+ const b = box(node.id);
343
+ if (b) boxes.set(node.id, b);
344
+ }
345
+ const all = [...boxes.values()];
346
+
347
+ const edges = tree.edges as Edge[];
348
+ const children = new Map<string, Edge[]>();
349
+ const parents = new Map<string, Edge[]>();
350
+ for (const edge of edges) {
351
+ if (edge.back) continue;
352
+ push(children, edge.from, edge);
353
+ push(parents, edge.to, edge);
354
+ }
355
+
356
+ const out: Drawing = { wires: [], labels: [] };
357
+ const claimed = new Set<Edge>();
358
+
359
+ for (const [from, kidEdges] of children) {
360
+ const a = boxes.get(from);
361
+ const kids = kidEdges.map((e) => boxes.get(e.to));
362
+ if (!a || kidEdges.length < 2 || kids.some((k) => !k)) continue;
363
+ const kept = kids as Box[];
364
+ if (!aligned(kept.map((k) => k.top))) continue;
365
+ kidEdges.forEach((e) => claimed.add(e));
366
+ fanOut(out, a, kept, kidEdges, (i) => lit(from, kidEdges[i].to));
367
+ }
368
+
369
+ for (const [to, parentEdges] of parents) {
370
+ const open = parentEdges.filter((e) => !claimed.has(e));
371
+ const b = boxes.get(to);
372
+ const ups = open.map((e) => boxes.get(e.from));
373
+ if (!b || open.length < 2 || ups.some((u) => !u)) continue;
374
+ const kept = ups as Box[];
375
+ if (!aligned(kept.map((u) => u.bottom))) continue;
376
+ open.forEach((e) => claimed.add(e));
377
+ fanIn(out, kept, b, open, (i) => lit(open[i].from, to));
378
+ }
379
+
380
+ // Longest skip first, so a long one always rides outside the skips it spans.
381
+ const skips: { edge: Edge; a: Box; b: Box; over: Box[] }[] = [];
382
+ for (const edge of edges) {
383
+ if (edge.back || claimed.has(edge)) continue;
384
+ const a = boxes.get(edge.from);
385
+ const b = boxes.get(edge.to);
386
+ if (!a || !b) continue;
387
+ const over = alone(a, all) && alone(b, all) ? crossed(a, b, all) : [];
388
+ if (over.length > 0) skips.push({ edge, a, b, over });
389
+ else curve(out, a, b, edge, lit(edge.from, edge.to));
390
+ }
391
+ skips.sort((x, y) => Math.abs(y.a.midY - y.b.midY) - Math.abs(x.a.midY - x.b.midY));
392
+ let outer = -Infinity;
393
+ for (const { edge, a, b, over } of skips) {
394
+ const lane = Math.max(
395
+ ...over.map((x) => x.right),
396
+ a.right,
397
+ b.right,
398
+ ) + LANE_INSET;
399
+ const placed = Math.max(lane, outer + LANE_STEP);
400
+ outer = placed;
401
+ skipEdge(out, a, b, placed, edge, lit(edge.from, edge.to));
402
+ }
403
+
404
+ // Shortest loop first, so a long one always nests outside the loops it spans.
405
+ const loops = edges
406
+ .filter((e) => e.back)
407
+ .map((edge) => ({ edge, a: boxes.get(edge.from), b: boxes.get(edge.to) }))
408
+ .filter((e): e is { edge: Edge; a: Box; b: Box } => !!e.a && !!e.b)
409
+ .sort((x, y) => Math.abs(x.a.midY - x.b.midY) - Math.abs(y.a.midY - y.b.midY));
410
+
411
+ let inner = Infinity;
412
+ for (const { edge, a, b } of loops) {
413
+ const clear = Math.min(...crossed(a, b, all).map((x) => x.left), a.left, b.left) - LANE_INSET;
414
+ const lane = Math.max(4, Math.min(clear, inner - LANE_STEP));
415
+ inner = lane;
416
+ backEdge(out, a, b, lane, edge, lit(edge.from, edge.to));
417
+ }
418
+
419
+ wires = out.wires;
420
+ labels = out.labels;
421
+ }
422
+
423
+ $effect(() => {
424
+ // Re-read on tab change and on selection (which only relights existing wires).
425
+ void tree;
426
+ void selection;
427
+ const frame = requestAnimationFrame(drawWires);
428
+ return () => cancelAnimationFrame(frame);
429
+ });
430
+
431
+ $effect(() => {
432
+ if (!canvas) return;
433
+ const observer = new ResizeObserver(() => drawWires());
434
+ observer.observe(canvas);
435
+ return () => observer.disconnect();
436
+ });
437
+ </script>
438
+
439
+ <div class="atlas">
440
+ <header class="masthead">
441
+ <div class="masthead-top">
442
+ <h1>Skill Atlas</h1>
443
+ <Button variant="primary" onclick={() => navigate('/')} icon="fas fa-arrow-left" iconPosition="left">
444
+ Back to Demo
445
+ </Button>
446
+ </div>
447
+ <p class="standfirst">
448
+ The eight live-tokens authoring skills as decision trees. Select a step and its lines light up in
449
+ the skill on the right; select a line number and the step that owns it lights up on the left.
450
+ </p>
451
+ <div class="tabs">
452
+ <TabBar {tabs} selectedTab={active} ontabChange={changeTab} />
453
+ </div>
454
+ </header>
455
+
456
+ <div class="split">
457
+ <section class="pane" aria-label="{tree.id} decision tree">
458
+ <div class="pane-head">
459
+ <span class="pane-title">{tree.id}</span>
460
+ <span class="pane-note">{tree.nodes.length} steps</span>
461
+ </div>
462
+ <div class="pane-body" bind:this={treePane}>
463
+ <div class="intro">
464
+ <Callout variant="info" label="The skill in brief">{tree.tagline}</Callout>
465
+ </div>
466
+
467
+ <div class="canvas" bind:this={canvas}>
468
+ <svg
469
+ class="wires"
470
+ viewBox="0 0 {canvasSize.w} {canvasSize.h}"
471
+ width={canvasSize.w}
472
+ height={canvasSize.h}
473
+ aria-hidden="true"
474
+ >
475
+ <defs>
476
+ <marker
477
+ id="atlas-arrow"
478
+ viewBox="0 0 10 10"
479
+ refX="9"
480
+ refY="5"
481
+ markerWidth="9"
482
+ markerHeight="9"
483
+ markerUnits="userSpaceOnUse"
484
+ orient="auto"
485
+ >
486
+ <path d="M 0 1 L 9 5 L 0 9 z" />
487
+ </marker>
488
+ </defs>
489
+
490
+ {#each wires as wire, i (i)}
491
+ <path
492
+ d={wire.d}
493
+ class:back={wire.back}
494
+ class:lit={wire.lit}
495
+ marker-end={wire.arrow ? 'url(#atlas-arrow)' : undefined}
496
+ />
497
+ {/each}
498
+
499
+ {#each labels as label, i (i)}
500
+ <text
501
+ class="wire-label"
502
+ class:lit={label.lit}
503
+ x={label.x}
504
+ y={label.y}
505
+ transform={label.rotate ? `rotate(-90 ${label.x} ${label.y})` : undefined}
506
+ text-anchor="middle"
507
+ dominant-baseline="central"
508
+ >
509
+ {label.text}
510
+ </text>
511
+ {/each}
512
+ </svg>
513
+
514
+ <div class="flow">
515
+ {#each rows as group, i (i)}
516
+ <div class="row" data-count={group.length} style="--n: {group.length}">
517
+ {#each group as node (node.id)}
518
+ <TreeNodeCard
519
+ {node}
520
+ selected={selection?.key ?? null}
521
+ onselect={selectTarget}
522
+ onopen={openDoc}
523
+ />
524
+ {/each}
525
+ </div>
526
+ {/each}
527
+ </div>
528
+ </div>
529
+ </div>
530
+ </section>
531
+
532
+ <section class="pane pane-source" aria-label="{tree.id} source">
533
+ <div class="pane-head">
534
+ <span class="pane-title">{tree.id}/{doc}</span>
535
+ <span class="pane-note">{lines.length} lines</span>
536
+ </div>
537
+ <div class="doc-tabs">
538
+ <TabBar tabs={docTabs} selectedTab={doc} ontabChange={openDoc} />
539
+ </div>
540
+ <div class="pane-body" bind:this={sourcePane}>
541
+ <SourcePane
542
+ {lines}
543
+ {siblings}
544
+ highlight={doc === SKILL_DOC ? (selection?.lines ?? null) : null}
545
+ onpick={selectFromLine}
546
+ onopen={openDoc}
547
+ />
548
+ </div>
549
+ </section>
550
+ </div>
551
+
552
+ <footer class="statusbar">
553
+ {#if selection}
554
+ <span class="status-label">{selection.label}</span>
555
+ <span class="status-range">
556
+ SKILL.md {selection.lines[0] === selection.lines[1]
557
+ ? `line ${selection.lines[0]}`
558
+ : `lines ${selection.lines[0]}–${selection.lines[1]}`}
559
+ </span>
560
+ {:else}
561
+ <span class="status-range">Select a step to map it onto the skill.</span>
562
+ {/if}
563
+ </footer>
564
+ </div>
565
+
566
+ <style>
567
+ /* A two-pane tool rather than a reading column. Nothing here sits at a page
568
+ column position, so the grid would only be capping the working surface;
569
+ the standfirst keeps its own measure instead. */
570
+ .atlas {
571
+ display: flex;
572
+ flex-direction: column;
573
+ padding: var(--space-48) var(--space-32) var(--space-32);
574
+ }
575
+
576
+ .masthead {
577
+ margin-bottom: var(--space-40);
578
+ }
579
+
580
+ .masthead-top {
581
+ display: flex;
582
+ align-items: center;
583
+ /* Beside the title, not at the far right: the editor overlay pins top-right
584
+ and would cover a button parked there. */
585
+ gap: var(--space-24);
586
+ flex-wrap: wrap;
587
+ /* The row carries the heading's own bottom margin; a margin left on the h1
588
+ would centre the button against the margin box, not the title. */
589
+ margin-bottom: var(--space-12);
590
+ }
591
+
592
+ .masthead-top h1 {
593
+ font-family: var(--heading-xl-font-family);
594
+ font-size: var(--heading-xl-font-size);
595
+ font-weight: var(--heading-xl-font-weight);
596
+ line-height: var(--heading-xl-line-height);
597
+ letter-spacing: var(--heading-xl-letter-spacing);
598
+ color: var(--text-primary);
599
+ margin-bottom: 0;
600
+ }
601
+
602
+ .standfirst {
603
+ max-width: 68ch;
604
+ margin: 0 0 var(--space-32);
605
+ font-family: var(--body-md-font-family);
606
+ font-size: var(--body-md-font-size);
607
+ font-weight: var(--body-md-font-weight);
608
+ line-height: var(--body-md-line-height);
609
+ letter-spacing: var(--body-md-letter-spacing);
610
+ color: var(--text-secondary);
611
+ }
612
+
613
+ .tabs {
614
+ overflow-x: auto;
615
+ }
616
+
617
+ /* The label carries its own newline, so the line count sits under the skill
618
+ name instead of wrapping wherever the strip happens to run out. */
619
+ .tabs :global(.tab span) {
620
+ white-space: pre-line;
621
+ text-align: center;
622
+ }
623
+
624
+ .split {
625
+ display: grid;
626
+ grid-template-columns: 1fr;
627
+ gap: var(--space-32);
628
+ }
629
+
630
+ @media (min-width: 64rem) {
631
+ .split {
632
+ grid-template-columns: 3fr 2fr;
633
+ align-items: start;
634
+ }
635
+ }
636
+
637
+ /* The reference tabs only exist on the source pane, so a body of its own
638
+ fixed height would leave the two panes ending at different places. */
639
+ .pane {
640
+ display: flex;
641
+ flex-direction: column;
642
+ height: 82vh;
643
+ min-width: 0;
644
+ border: var(--border-width-1) solid var(--border-neutral-subtle);
645
+ border-radius: var(--radius-3xl);
646
+ background: var(--surface-neutral-lowest);
647
+ overflow: hidden;
648
+ }
649
+
650
+ .pane-head {
651
+ flex: 0 0 auto;
652
+ display: flex;
653
+ align-items: baseline;
654
+ gap: var(--space-16);
655
+ padding: var(--space-16) var(--space-24);
656
+ border-bottom: var(--border-width-1) solid var(--border-neutral-faint);
657
+ }
658
+
659
+ /* The tab row carries the rule under the source head, so the head drops its
660
+ own; two lines a tab apart read as a second window. */
661
+ .pane-source .pane-head {
662
+ border-bottom: none;
663
+ padding-bottom: var(--space-8);
664
+ }
665
+
666
+ .pane-title {
667
+ min-width: 0;
668
+ overflow: hidden;
669
+ text-overflow: ellipsis;
670
+ white-space: nowrap;
671
+ font-family: var(--code-font-family);
672
+ font-size: var(--font-size-md);
673
+ color: var(--text-primary);
674
+ }
675
+
676
+ .pane-note {
677
+ flex: 0 0 auto;
678
+ margin-left: auto;
679
+ font-family: var(--code-font-family);
680
+ font-size: var(--font-size-md);
681
+ color: var(--text-tertiary);
682
+ }
683
+
684
+ /* Fixed: the body's own basis is the whole document, so a shrinkable tab row
685
+ loses height to it and clips. */
686
+ .doc-tabs {
687
+ flex: 0 0 auto;
688
+ /* The rule spans the pane, not the scrolled tab strip, so the component's
689
+ own divider steps aside. */
690
+ --tabbar-bar-divider: var(--color-transparent);
691
+ padding: 0 var(--space-16);
692
+ border-bottom: var(--border-width-1) solid var(--border-neutral-faint);
693
+ overflow-x: auto;
694
+ }
695
+
696
+ .pane-body {
697
+ flex: 1 1 auto;
698
+ min-height: 0;
699
+ overflow: auto;
700
+ scroll-behavior: smooth;
701
+ }
702
+
703
+ .intro {
704
+ padding: var(--space-24) var(--space-24) 0;
705
+ }
706
+
707
+ /* The side padding is the lanes: re-run loops on the left, row-skipping
708
+ branches on the right. A branch row runs the full width of the flow, so
709
+ without reserved room a lane would have nowhere to go but over the cards
710
+ it is routing around. */
711
+ .canvas {
712
+ position: relative;
713
+ padding: var(--space-32) var(--space-64) var(--space-64);
714
+ }
715
+
716
+ .wires {
717
+ position: absolute;
718
+ inset: 0;
719
+ pointer-events: none;
720
+ }
721
+
722
+ .wires > path {
723
+ fill: none;
724
+ stroke: var(--border-neutral-medium);
725
+ stroke-width: var(--border-width-2);
726
+ stroke-linecap: round;
727
+ }
728
+
729
+ .wires > path.lit {
730
+ stroke: var(--text-accent);
731
+ stroke-width: var(--border-width-3);
732
+ }
733
+
734
+ /* A re-run loop is a route, not a failure — danger red overstated it. */
735
+ .wires > path.back {
736
+ stroke: var(--border-accent-medium);
737
+ stroke-dasharray: 6 8;
738
+ }
739
+
740
+ .wires > path.back.lit {
741
+ stroke: var(--text-accent);
742
+ }
743
+
744
+ .wires marker path {
745
+ fill: context-stroke;
746
+ }
747
+
748
+ /* The halo breaks the wire so the answer reads on its own ground. */
749
+ .wire-label {
750
+ font-family: var(--code-font-family);
751
+ font-size: var(--font-size-sm);
752
+ fill: var(--text-tertiary);
753
+ stroke: var(--surface-neutral-lowest);
754
+ stroke-width: calc(var(--border-width-2) * 3);
755
+ stroke-linejoin: round;
756
+ paint-order: stroke;
757
+ }
758
+
759
+ .wire-label.lit {
760
+ fill: var(--text-accent);
761
+ }
762
+
763
+ .flow {
764
+ /* Nearly every row holds one node, so the pane's full width would only
765
+ stretch a card that has nothing to say at its right edge. Capping the
766
+ track turns the column into a spine and gives the wires somewhere to
767
+ travel. */
768
+ --node-w: 30rem;
769
+
770
+ position: relative;
771
+ display: flex;
772
+ flex-direction: column;
773
+ gap: var(--space-40);
774
+ }
775
+
776
+ /* Sibling branch rows share a column count so a family and its follow-up
777
+ question stay in the same column when they wrap. */
778
+ .row {
779
+ /* A fan only earns a trunk-and-bus while it fits on one line, so branches
780
+ narrow enough to stay unwrapped are worth more than uniform columns. */
781
+ --track: 9rem;
782
+
783
+ display: grid;
784
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--track)), 1fr));
785
+ gap: var(--space-24);
786
+ align-items: start;
787
+ /* The auto margins that centre the row also switch off the flex stretch,
788
+ so the width has to be asked for rather than inherited. */
789
+ width: 100%;
790
+ max-width: calc(var(--n) * var(--node-w) + (var(--n) - 1) * var(--space-24));
791
+ margin-inline: auto;
792
+ }
793
+
794
+ /* A flat step would spend the same gap on "next" as on "the tree splits
795
+ here". The extra margin lets a branch announce itself before it is read. */
796
+ .row:not([data-count='1']) {
797
+ margin-block: var(--space-48);
798
+ }
799
+
800
+ /* Past four, one line would shave the cards past reading; wrap them in pairs
801
+ and let the connectors fall back to curves. */
802
+ .row[data-count='5'],
803
+ .row[data-count='6'],
804
+ .row[data-count='7'] {
805
+ --track: 17rem;
806
+ }
807
+
808
+ .statusbar {
809
+ display: flex;
810
+ align-items: baseline;
811
+ gap: var(--space-16);
812
+ flex-wrap: wrap;
813
+ margin-top: var(--space-24);
814
+ padding-top: var(--space-16);
815
+ border-top: var(--border-width-1) solid var(--border-neutral-faint);
816
+ }
817
+
818
+ .status-label {
819
+ font-family: var(--body-md-font-family);
820
+ font-size: var(--font-size-md);
821
+ font-weight: var(--font-weight-bold);
822
+ color: var(--text-primary);
823
+ }
824
+
825
+ .status-range {
826
+ font-family: var(--code-font-family);
827
+ font-size: var(--font-size-md);
828
+ color: var(--text-tertiary);
829
+ }
830
+
831
+ @media (prefers-reduced-motion: reduce) {
832
+ .pane-body {
833
+ scroll-behavior: auto;
834
+ }
835
+ }
836
+ </style>