@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.
- package/.claude/skills/live-tokens-build-page/SKILL.md +36 -1
- package/.claude/skills/live-tokens-build-page/references/layout-sources.md +48 -0
- package/.claude/skills/live-tokens-pick-component/SKILL.md +1 -1
- package/CHANGELOG.md +50 -0
- package/package.json +9 -2
- package/src/editor/docs/content/creating-components.md +13 -0
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/skill-atlas/SkillAtlas.svelte +836 -0
- package/src/editor/skill-atlas/SkillAtlas.svelte.d.ts +4 -0
- package/src/editor/skill-atlas/SourcePane.svelte +364 -0
- package/src/editor/skill-atlas/TreeNodeCard.svelte +206 -0
- package/src/editor/skill-atlas/skillSources.generated.ts +41 -0
- package/src/editor/skill-atlas/skillSources.ts +1 -0
- package/src/editor/skill-atlas/skillTrees.ts +3691 -0
- package/src/editor/skill-atlas/types.ts +65 -0
|
@@ -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>
|