@poodle64/ui 2026.10.18 → 2026.10.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/components/ui/graph-canvas/graph-canvas.svelte +69 -2
- package/dist/components/ui/graph-canvas/graph-canvas.svelte.d.ts +2 -0
- package/dist/components/ui/graph-canvas/scene.d.ts +6 -0
- package/dist/components/ui/graph-canvas/scene.js +52 -21
- package/dist/components/ui/graph-canvas/types.d.ts +3 -0
- package/dist/styles.css +14 -0
- package/package.json +2 -1
- package/registry/component-map.json +1 -1
- package/registry/component-map.md +1 -1
package/README.md
CHANGED
|
@@ -574,6 +574,13 @@ paragraphs ~207px away from their own label. Both halves are gated in
|
|
|
574
574
|
on the page's own left edge, and it follows a retune of
|
|
575
575
|
`--ds-shell-measure-prose`.
|
|
576
576
|
|
|
577
|
+
**Large screens.** The measures, the type and the rail are all rem, and past
|
|
578
|
+
2560px the root size grows (1px per 160px of width, to 24px at 3840px), so a
|
|
579
|
+
page fills a 4K screen as it fills a laptop's. At 2560px and below nothing
|
|
580
|
+
moves. An app sets no per-page variant; one that sets its own root size does it
|
|
581
|
+
after the `styles.css` import. Gated in `harness/drive.mjs` at 1440, 2560 and
|
|
582
|
+
3840px.
|
|
583
|
+
|
|
577
584
|
### The page: container, sections and the type scale
|
|
578
585
|
|
|
579
586
|
```svelte
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
hiddenKinds?: string[];
|
|
35
35
|
/** Arrowheads at each edge's target. */
|
|
36
36
|
directed?: boolean;
|
|
37
|
+
/** A path to highlight: node ids in order. Its nodes and the edges between them stay lit, labelled with their verbs; the rest dims. */
|
|
38
|
+
path?: string[];
|
|
37
39
|
/** The accessible name. */
|
|
38
40
|
label?: string;
|
|
39
41
|
/** The find field. */
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
selected = $bindable(null),
|
|
56
58
|
hiddenKinds = $bindable([]),
|
|
57
59
|
directed = false,
|
|
60
|
+
path = [],
|
|
58
61
|
label = 'Graph',
|
|
59
62
|
search = true,
|
|
60
63
|
activateLabel = 'Open',
|
|
@@ -70,6 +73,7 @@
|
|
|
70
73
|
const NEIGHBOURS = 12;
|
|
71
74
|
|
|
72
75
|
const scene = new GraphScene<N>();
|
|
76
|
+
const nodeListId = `graph-nodes-${Math.random().toString(36).slice(2, 8)}`;
|
|
73
77
|
let root: HTMLDivElement;
|
|
74
78
|
let canvas: HTMLCanvasElement;
|
|
75
79
|
let probe: HTMLSpanElement;
|
|
@@ -80,6 +84,7 @@
|
|
|
80
84
|
|
|
81
85
|
let query = $state('');
|
|
82
86
|
let showAll = $state(false);
|
|
87
|
+
let listOpen = $state(false);
|
|
83
88
|
let settled = $state(false);
|
|
84
89
|
let cursor = $state('grab');
|
|
85
90
|
|
|
@@ -163,6 +168,24 @@
|
|
|
163
168
|
return hits.slice(0, 8).map((h) => h.node);
|
|
164
169
|
});
|
|
165
170
|
|
|
171
|
+
// The keyboard and screen-reader route to every node: a list in reading
|
|
172
|
+
// order, most connected first past the cap, the selected node always in it.
|
|
173
|
+
const NODE_LIST = 200;
|
|
174
|
+
const listed = $derived.by(() => {
|
|
175
|
+
const hidden = new Set(hiddenKinds);
|
|
176
|
+
const all = [...nodeById.values()].filter((n) => !hidden.has(n.kind));
|
|
177
|
+
const degree = new Map<string, number>();
|
|
178
|
+
for (const e of edges) {
|
|
179
|
+
degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
|
|
180
|
+
degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
const top = all.length > NODE_LIST ? [...all].sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0)).slice(0, NODE_LIST) : all;
|
|
183
|
+
if (current && !top.includes(current)) top.push(current);
|
|
184
|
+
return { rows: top.sort((a, b) => a.label.localeCompare(b.label)), total: all.length };
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const routeLabels = $derived(path.flatMap((id) => nodeById.get(id)?.label ?? []));
|
|
188
|
+
|
|
166
189
|
const summary = $derived(
|
|
167
190
|
`${formatNumber(nodes.length)} ${nodes.length === 1 ? 'node' : 'nodes'} · ${formatNumber(edges.length)} ${edges.length === 1 ? 'link' : 'links'}`
|
|
168
191
|
);
|
|
@@ -171,6 +194,7 @@
|
|
|
171
194
|
? `${current.label}, ${kindLabel(current.kind)}, ${neighbours.length} ${neighbours.length === 1 ? 'connection' : 'connections'}`
|
|
172
195
|
: ''
|
|
173
196
|
);
|
|
197
|
+
const routeText = $derived(routeLabels.length > 1 ? `Path: ${routeLabels.join(', then ')}.` : '');
|
|
174
198
|
|
|
175
199
|
function kindLabel(id: string): string {
|
|
176
200
|
return kindOf.get(id)?.label ?? id;
|
|
@@ -403,6 +427,11 @@
|
|
|
403
427
|
requestDraw();
|
|
404
428
|
});
|
|
405
429
|
|
|
430
|
+
$effect(() => {
|
|
431
|
+
scene.setRoute(path);
|
|
432
|
+
requestDraw();
|
|
433
|
+
});
|
|
434
|
+
|
|
406
435
|
$effect(() => {
|
|
407
436
|
scene.directed = directed;
|
|
408
437
|
requestDraw();
|
|
@@ -731,7 +760,7 @@
|
|
|
731
760
|
style:cursor
|
|
732
761
|
tabindex="0"
|
|
733
762
|
aria-roledescription="graph canvas"
|
|
734
|
-
aria-label={`${label}: ${summary}
|
|
763
|
+
aria-label={`${label}: ${summary}.${routeText ? ' ' + routeText : ''} Arrow keys pan, plus and minus zoom, 0 fits${search ? ', slash finds' : ''}.`}
|
|
735
764
|
onkeydown={onCanvasKey}
|
|
736
765
|
></canvas>
|
|
737
766
|
|
|
@@ -784,6 +813,44 @@
|
|
|
784
813
|
{/if}
|
|
785
814
|
{/if}
|
|
786
815
|
|
|
816
|
+
<div>
|
|
817
|
+
<button
|
|
818
|
+
type="button"
|
|
819
|
+
class="hover:bg-accent flex w-full items-center justify-between rounded-md border border-transparent px-2 py-1 text-xs @2xl:text-sm"
|
|
820
|
+
aria-expanded={listOpen}
|
|
821
|
+
aria-controls={nodeListId}
|
|
822
|
+
onclick={() => (listOpen = !listOpen)}
|
|
823
|
+
>
|
|
824
|
+
<span class="text-foreground">Nodes</span>
|
|
825
|
+
<span class="text-muted-foreground tabular-nums">{formatNumber(listed.total)}</span>
|
|
826
|
+
</button>
|
|
827
|
+
<ul
|
|
828
|
+
id={nodeListId}
|
|
829
|
+
class="mt-1 max-h-48 flex-col overflow-y-auto {listOpen ? 'flex' : 'hidden'}"
|
|
830
|
+
aria-label="Nodes"
|
|
831
|
+
>
|
|
832
|
+
{#each listed.rows as n (n.id)}
|
|
833
|
+
<li>
|
|
834
|
+
<button
|
|
835
|
+
type="button"
|
|
836
|
+
class="hover:bg-accent focus-visible:bg-accent flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm outline-none"
|
|
837
|
+
aria-pressed={selected === n.id}
|
|
838
|
+
onclick={() => focus(n.id)}
|
|
839
|
+
>
|
|
840
|
+
{@render swatch(n.kind)}
|
|
841
|
+
<span class="text-foreground min-w-0 flex-1 truncate">{n.label}</span>
|
|
842
|
+
<span class="text-muted-foreground shrink-0 text-xs">{kindLabel(n.kind)}</span>
|
|
843
|
+
</button>
|
|
844
|
+
</li>
|
|
845
|
+
{/each}
|
|
846
|
+
{#if listed.total > listed.rows.length}
|
|
847
|
+
<li class="text-muted-foreground px-2 py-1 text-xs">
|
|
848
|
+
Most connected {formatNumber(listed.rows.length)} of {formatNumber(listed.total)}; find narrows it.
|
|
849
|
+
</li>
|
|
850
|
+
{/if}
|
|
851
|
+
</ul>
|
|
852
|
+
</div>
|
|
853
|
+
|
|
787
854
|
{#if legendRows.length}
|
|
788
855
|
<ul
|
|
789
856
|
class="flex flex-wrap gap-x-1 @2xl:flex-col @2xl:flex-nowrap"
|
|
@@ -918,5 +985,5 @@
|
|
|
918
985
|
</section>
|
|
919
986
|
{/if}
|
|
920
987
|
|
|
921
|
-
<p class="sr-only" aria-live="polite">{announcement}</p>
|
|
988
|
+
<p class="sr-only" aria-live="polite">{[announcement, routeText].filter(Boolean).join(' ')}</p>
|
|
922
989
|
</div>
|
|
@@ -20,6 +20,8 @@ declare function $$render<N extends GraphNode>(): {
|
|
|
20
20
|
hiddenKinds?: string[];
|
|
21
21
|
/** Arrowheads at each edge's target. */
|
|
22
22
|
directed?: boolean;
|
|
23
|
+
/** A path to highlight: node ids in order. Its nodes and the edges between them stay lit, labelled with their verbs; the rest dims. */
|
|
24
|
+
path?: string[];
|
|
23
25
|
/** The accessible name. */
|
|
24
26
|
label?: string;
|
|
25
27
|
/** The find field. */
|
|
@@ -65,6 +65,10 @@ export declare class GraphScene<N extends GraphNode = GraphNode> {
|
|
|
65
65
|
selected: SimNode<N> | null;
|
|
66
66
|
hovered: SimNode<N> | null;
|
|
67
67
|
matches: Set<SimNode<N>>;
|
|
68
|
+
/** The highlighted route: its nodes and every edge joining consecutive ones. */
|
|
69
|
+
routeNodes: Set<SimNode<N>>;
|
|
70
|
+
routeEdges: Set<SimEdge<N>>;
|
|
71
|
+
private routeIds;
|
|
68
72
|
readonly sim: Simulation<SimNode<N>, SimEdge<N>>;
|
|
69
73
|
private link;
|
|
70
74
|
private charge;
|
|
@@ -80,6 +84,8 @@ export declare class GraphScene<N extends GraphNode = GraphNode> {
|
|
|
80
84
|
setAspect(width: number, height: number): void;
|
|
81
85
|
/** Replace the data, keeping every surviving node where it is. */
|
|
82
86
|
setData(nodes: readonly N[], edges: readonly GraphEdge[]): number;
|
|
87
|
+
/** Highlight a path given as node ids in order; ids not in the graph are skipped. */
|
|
88
|
+
setRoute(ids: readonly string[]): void;
|
|
83
89
|
hot(): boolean;
|
|
84
90
|
heat(alpha: number): void;
|
|
85
91
|
/** Advance the layout for at most `budgetMs`, and at least one tick. */
|
|
@@ -34,6 +34,10 @@ export class GraphScene {
|
|
|
34
34
|
selected = null;
|
|
35
35
|
hovered = null;
|
|
36
36
|
matches = new Set();
|
|
37
|
+
/** The highlighted route: its nodes and every edge joining consecutive ones. */
|
|
38
|
+
routeNodes = new Set();
|
|
39
|
+
routeEdges = new Set();
|
|
40
|
+
routeIds = [];
|
|
37
41
|
sim;
|
|
38
42
|
link = forceLink([]);
|
|
39
43
|
charge = forceManyBody().strength((d) => -28 - 14 * Math.sqrt(d.degree));
|
|
@@ -143,8 +147,20 @@ export class GraphScene {
|
|
|
143
147
|
if (this.hovered)
|
|
144
148
|
this.hovered = byId.get(this.hovered.id) ?? null;
|
|
145
149
|
this.matches = new Set([...this.matches].flatMap((m) => byId.get(m.id) ?? []));
|
|
150
|
+
this.setRoute(this.routeIds);
|
|
146
151
|
return fresh.length;
|
|
147
152
|
}
|
|
153
|
+
/** Highlight a path given as node ids in order; ids not in the graph are skipped. */
|
|
154
|
+
setRoute(ids) {
|
|
155
|
+
this.routeIds = ids;
|
|
156
|
+
const nodes = ids.flatMap((id) => this.byId.get(id) ?? []);
|
|
157
|
+
this.routeNodes = new Set(nodes);
|
|
158
|
+
const key = (a, b) => (a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`);
|
|
159
|
+
const pairs = new Set();
|
|
160
|
+
for (let i = 1; i < nodes.length; i++)
|
|
161
|
+
pairs.add(key(nodes[i - 1].id, nodes[i].id));
|
|
162
|
+
this.routeEdges = new Set(this.edges.filter((e) => pairs.has(key(e.source.id, e.target.id))));
|
|
163
|
+
}
|
|
148
164
|
hot() {
|
|
149
165
|
return this.sim.alpha() >= this.sim.alphaMin();
|
|
150
166
|
}
|
|
@@ -236,9 +252,12 @@ export class GraphScene {
|
|
|
236
252
|
if (!P)
|
|
237
253
|
return;
|
|
238
254
|
const chosen = this.selected && this.visible(this.selected) ? this.selected : null;
|
|
239
|
-
|
|
255
|
+
// A route holds the picture until the pointer rests on a node.
|
|
256
|
+
const route = !this.hovered && this.routeNodes.size > 0;
|
|
257
|
+
const focus = route ? null : (this.hovered ?? chosen);
|
|
240
258
|
const near = focus ? this.adj.get(focus) : undefined;
|
|
241
|
-
const
|
|
259
|
+
const dim = !!focus || route;
|
|
260
|
+
const lit = (s) => (route ? this.routeNodes.has(s) : !focus || s === focus || !!near?.has(s));
|
|
242
261
|
const margin = 40 / k;
|
|
243
262
|
const vx0 = -tx / k - margin;
|
|
244
263
|
const vy0 = -ty / k - margin;
|
|
@@ -268,24 +287,26 @@ export class GraphScene {
|
|
|
268
287
|
const curved = shown.length < 1200;
|
|
269
288
|
// Edges: one batched path for the rest, one per colour for the lit ones.
|
|
270
289
|
const rest = new Path2D();
|
|
290
|
+
const restDashed = new Path2D();
|
|
271
291
|
const restHeads = new Path2D();
|
|
272
292
|
const litPaths = new Map();
|
|
273
293
|
const edgeLabels = [];
|
|
274
|
-
const heads = this.directed && (shown.length < 500 ||
|
|
294
|
+
const heads = this.directed && (shown.length < 500 || dim);
|
|
275
295
|
// A hub's relations crowd its own spokes; past a dozen the card lists them instead.
|
|
276
296
|
const relationWords = focus ? (this.adj.get(focus)?.size ?? 0) <= 12 : shown.length < 300;
|
|
277
297
|
for (const e of shown) {
|
|
278
298
|
const a = e.source;
|
|
279
299
|
const b = e.target;
|
|
280
|
-
const hot = !!focus && (a === focus || b === focus);
|
|
281
|
-
|
|
300
|
+
const hot = route ? this.routeEdges.has(e) : !!focus && (a === focus || b === focus);
|
|
301
|
+
const dashed = e.edge.style === 'dashed';
|
|
302
|
+
let path = dashed ? restDashed : rest;
|
|
282
303
|
let head = restHeads;
|
|
283
304
|
if (hot) {
|
|
284
|
-
const colour = this.colour(a === focus ? b : a);
|
|
305
|
+
const colour = route ? P.ink : this.colour(a === focus ? b : a);
|
|
285
306
|
let p = litPaths.get(colour);
|
|
286
307
|
if (!p)
|
|
287
|
-
litPaths.set(colour, (p = { line: new Path2D(), head: new Path2D() }));
|
|
288
|
-
path = p.line;
|
|
308
|
+
litPaths.set(colour, (p = { line: new Path2D(), dashed: new Path2D(), head: new Path2D() }));
|
|
309
|
+
path = dashed ? p.dashed : p.line;
|
|
289
310
|
head = p.head;
|
|
290
311
|
}
|
|
291
312
|
const [cx, cy] = curved || hot ? this.control(e) : [(a.x + b.x) / 2, (a.y + b.y) / 2];
|
|
@@ -294,30 +315,36 @@ export class GraphScene {
|
|
|
294
315
|
path.quadraticCurveTo(cx, cy, b.x, b.y);
|
|
295
316
|
else
|
|
296
317
|
path.lineTo(b.x, b.y);
|
|
297
|
-
if (heads && (hot || !
|
|
318
|
+
if (heads && (hot || !dim))
|
|
298
319
|
this.arrow(head, cx, cy, b, rad(b), k);
|
|
299
|
-
if (e.edge.label && relationWords && (hot || !focus))
|
|
320
|
+
if (e.edge.label && (route ? hot : relationWords && (hot || !focus)))
|
|
300
321
|
edgeLabels.push(e);
|
|
301
322
|
}
|
|
302
323
|
ctx.strokeStyle = P.edge;
|
|
303
324
|
ctx.fillStyle = P.edge;
|
|
304
|
-
ctx.globalAlpha =
|
|
325
|
+
ctx.globalAlpha = dim ? 0.07 : Math.min(0.65, 0.12 + 0.35 * k);
|
|
305
326
|
ctx.lineWidth = 1 / k;
|
|
306
327
|
ctx.stroke(rest);
|
|
328
|
+
ctx.setLineDash([5 / k, 4 / k]);
|
|
329
|
+
ctx.stroke(restDashed);
|
|
330
|
+
ctx.setLineDash([]);
|
|
307
331
|
ctx.fill(restHeads);
|
|
308
332
|
ctx.globalAlpha = 0.95;
|
|
309
|
-
ctx.lineWidth = 1.75 / k;
|
|
333
|
+
ctx.lineWidth = (route ? 2.75 : 1.75) / k;
|
|
310
334
|
for (const [colour, p] of litPaths) {
|
|
311
335
|
ctx.strokeStyle = colour;
|
|
312
336
|
ctx.fillStyle = colour;
|
|
313
337
|
ctx.stroke(p.line);
|
|
338
|
+
ctx.setLineDash([6 / k, 4 / k]);
|
|
339
|
+
ctx.stroke(p.dashed);
|
|
340
|
+
ctx.setLineDash([]);
|
|
314
341
|
ctx.fill(p.head);
|
|
315
342
|
}
|
|
316
343
|
// Nodes: dimmed first, then lit, each batched by kind. A dot too small to
|
|
317
344
|
// overlap its neighbour visibly skips the ground-coloured halo.
|
|
318
345
|
const halo = k >= 0.5 ? 1.4 / k : 0;
|
|
319
346
|
for (const pass of [false, true]) {
|
|
320
|
-
if (pass === false && !
|
|
347
|
+
if (pass === false && !dim)
|
|
321
348
|
continue;
|
|
322
349
|
const groups = new Map();
|
|
323
350
|
for (const s of this.nodes) {
|
|
@@ -345,7 +372,8 @@ export class GraphScene {
|
|
|
345
372
|
if (!this.visible(s) || !onScreen(s))
|
|
346
373
|
continue;
|
|
347
374
|
const ring = s === this.selected || s === focus;
|
|
348
|
-
|
|
375
|
+
const onRoute = route && this.routeNodes.has(s);
|
|
376
|
+
if (!s.node.emphasis && !ring && !onRoute && !this.matches.has(s))
|
|
349
377
|
continue;
|
|
350
378
|
const r = rad(s);
|
|
351
379
|
const shape = this.shapes.get(s.node.kind) ?? 'circle';
|
|
@@ -361,11 +389,11 @@ export class GraphScene {
|
|
|
361
389
|
const p = new Path2D();
|
|
362
390
|
this.shape(p, shape, s.x, s.y, r + 3.5 / k);
|
|
363
391
|
ctx.lineWidth = (ring ? 2 : 1.4) / k;
|
|
364
|
-
ctx.strokeStyle = ring || this.matches.has(s) ? P.ink : this.colour(s);
|
|
392
|
+
ctx.strokeStyle = ring || onRoute || this.matches.has(s) ? P.ink : this.colour(s);
|
|
365
393
|
ctx.stroke(p);
|
|
366
394
|
}
|
|
367
395
|
ctx.globalAlpha = 1;
|
|
368
|
-
this.labels(ctx, P, focus, near, edgeLabels, onScreen, rad);
|
|
396
|
+
this.labels(ctx, P, focus, near, edgeLabels, route, onScreen, rad);
|
|
369
397
|
}
|
|
370
398
|
colour(s) {
|
|
371
399
|
return this.palette?.kinds.get(s.node.kind) ?? this.palette?.fallback ?? 'currentColor';
|
|
@@ -457,7 +485,7 @@ export class GraphScene {
|
|
|
457
485
|
}
|
|
458
486
|
return true;
|
|
459
487
|
}
|
|
460
|
-
labels(ctx, P, focus, near, edgeLabels, onScreen, rad) {
|
|
488
|
+
labels(ctx, P, focus, near, edgeLabels, route, onScreen, rad) {
|
|
461
489
|
const { k, x: tx, y: ty } = this.cam;
|
|
462
490
|
const { dpr, width: W, height: H } = this;
|
|
463
491
|
this.grid.clear();
|
|
@@ -487,8 +515,11 @@ export class GraphScene {
|
|
|
487
515
|
else {
|
|
488
516
|
if (this.selected)
|
|
489
517
|
order.push(this.selected);
|
|
518
|
+
if (route)
|
|
519
|
+
order.push(...this.routeNodes);
|
|
490
520
|
order.push(...this.matches);
|
|
491
|
-
|
|
521
|
+
if (!route)
|
|
522
|
+
order.push(...this.ranked);
|
|
492
523
|
}
|
|
493
524
|
const seen = new Set();
|
|
494
525
|
const quota = Math.ceil((W * H) / LABEL_AREA);
|
|
@@ -504,7 +535,7 @@ export class GraphScene {
|
|
|
504
535
|
seen.add(s);
|
|
505
536
|
tries++;
|
|
506
537
|
const text = clip(s.node.label);
|
|
507
|
-
const prominent = s === focus || s === this.selected || !!s.node.emphasis;
|
|
538
|
+
const prominent = s === focus || s === this.selected || !!s.node.emphasis || (route && this.routeNodes.has(s));
|
|
508
539
|
if (!focus && placed >= quota && rad(s) * k < 7 && !prominent && !this.matches.has(s))
|
|
509
540
|
continue;
|
|
510
541
|
const font = prominent ? strong : body;
|
|
@@ -519,7 +550,7 @@ export class GraphScene {
|
|
|
519
550
|
const below = ty + s.y * k + r + 4;
|
|
520
551
|
const above = ty + s.y * k - r - 4 - h;
|
|
521
552
|
const fits = (y) => this.claim(sx - w / 2 - 3, y - 2, sx + w / 2 + 3, y + h + 2);
|
|
522
|
-
const sy = fits(below) ? below : fits(above) ? above : s === focus || s === this.selected ? below : null;
|
|
553
|
+
const sy = fits(below) ? below : fits(above) ? above : s === focus || s === this.selected || (route && this.routeNodes.has(s)) ? below : null;
|
|
523
554
|
if (sy === null || sy > H || sy + h < 0)
|
|
524
555
|
continue;
|
|
525
556
|
this.text(ctx, font, text, sx, sy, 'top', P.ink, P.ground);
|
|
@@ -542,7 +573,7 @@ export class GraphScene {
|
|
|
542
573
|
const w = this.measure(ctx, small, text);
|
|
543
574
|
if (!this.claim(mx - w / 2 - 3, my - 7, mx + w / 2 + 3, my + 7))
|
|
544
575
|
continue;
|
|
545
|
-
const hot = !!focus && (e.source === focus || e.target === focus);
|
|
576
|
+
const hot = route || (!!focus && (e.source === focus || e.target === focus));
|
|
546
577
|
this.text(ctx, small, text, mx, my, 'middle', hot ? P.ink : P.inkSoft, P.ground);
|
|
547
578
|
edgesPlaced++;
|
|
548
579
|
}
|
|
@@ -14,7 +14,10 @@ export interface GraphNode {
|
|
|
14
14
|
export interface GraphEdge {
|
|
15
15
|
source: string;
|
|
16
16
|
target: string;
|
|
17
|
+
/** The relation's verb, drawn on the line on the highlighted path or around a focused node. */
|
|
17
18
|
label?: string;
|
|
19
|
+
/** `dashed` for a weaker link, such as a claim with no citation yet. Defaults to `solid`. */
|
|
20
|
+
style?: 'solid' | 'dashed';
|
|
18
21
|
}
|
|
19
22
|
export type GraphShape = 'circle' | 'square' | 'diamond' | 'triangle';
|
|
20
23
|
/** A legend row. The colour is any CSS colour; a `var(--…)` resolves against the graph's own element. */
|
package/dist/styles.css
CHANGED
|
@@ -441,6 +441,20 @@
|
|
|
441
441
|
--ds-control-pad-icon-lg: 0.625rem;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Large screens. Past 2560 CSS px the root size grows with the viewport, 1px of
|
|
446
|
+
* root per 160px of width, capped at 1.5rem (a 4K screen at 1x, 3840px, reads
|
|
447
|
+
* 24px), so everything set in rem grows with it: type, the shell's measures
|
|
448
|
+
* (`page` 80rem, `wide` 120rem) and the rail. At 2560px and below the rule
|
|
449
|
+
* resolves to 1rem and nothing moves; it is continuous at the seam. An app that
|
|
450
|
+
* sets its own root size sets it after this import.
|
|
451
|
+
*/
|
|
452
|
+
@media (min-width: 2560px) {
|
|
453
|
+
html {
|
|
454
|
+
font-size: clamp(1rem, 100vw / 160, 1.5rem);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
444
458
|
@layer base {
|
|
445
459
|
/**
|
|
446
460
|
* The milled edge. Cards read as having depth from a hairline border plus a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/ui",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.19",
|
|
4
4
|
"description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"@testing-library/svelte": "^5.4.2",
|
|
101
101
|
"@types/d3-force": "^3.0.10",
|
|
102
102
|
"@types/node": "^22.20.1",
|
|
103
|
+
"axe-core": "^4.14.0",
|
|
103
104
|
"bits-ui": "^2.18.1",
|
|
104
105
|
"formsnap": "^2.0.1",
|
|
105
106
|
"jsdom": "^29.1.1",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @poodle64/ui — situation → component map
|
|
2
2
|
|
|
3
3
|
<!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
|
|
4
|
-
Generated from `@poodle64/ui@2026.10.
|
|
4
|
+
Generated from `@poodle64/ui@2026.10.19`. 76 components, 20 situations.
|
|
5
5
|
|
|
6
6
|
**Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
|
|
7
7
|
|