@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 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}. Arrow keys pan, plus and minus zoom, 0 fits${search ? ', slash finds' : ''}.`}
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
- const focus = this.hovered ?? chosen;
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 lit = (s) => !focus || s === focus || !!near?.has(s);
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 || !!focus);
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
- let path = rest;
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 || !focus))
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 = focus ? 0.07 : Math.min(0.65, 0.12 + 0.35 * k);
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 && !focus)
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
- if (!s.node.emphasis && !ring && !this.matches.has(s))
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
- order.push(...this.ranked);
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.18",
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
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.10.18",
4
+ "version": "2026.10.19",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
7
  "componentCount": 76,
@@ -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.18`. 76 components, 20 situations.
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