@poodle64/ui 2026.10.17 → 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.
Files changed (62) hide show
  1. package/README.md +101 -11
  2. package/dist/components/feedback/report-widget.svelte +4 -1
  3. package/dist/components/feedback/report-widget.svelte.d.ts +1 -0
  4. package/dist/components/ui/app-identity/app-identity.svelte +7 -2
  5. package/dist/components/ui/app-shell/texture.d.ts +5 -5
  6. package/dist/components/ui/app-shell/texture.js +3 -3
  7. package/dist/components/ui/breadcrumbs/breadcrumbs.svelte +70 -0
  8. package/dist/components/ui/breadcrumbs/breadcrumbs.svelte.d.ts +32 -0
  9. package/dist/components/ui/breadcrumbs/index.d.ts +2 -0
  10. package/dist/components/ui/breadcrumbs/index.js +2 -0
  11. package/dist/components/ui/content-skeleton/content-skeleton.svelte +94 -0
  12. package/dist/components/ui/content-skeleton/content-skeleton.svelte.d.ts +25 -0
  13. package/dist/components/ui/content-skeleton/index.d.ts +2 -0
  14. package/dist/components/ui/content-skeleton/index.js +2 -0
  15. package/dist/components/ui/document-reader/document-reader.svelte +336 -0
  16. package/dist/components/ui/document-reader/document-reader.svelte.d.ts +65 -0
  17. package/dist/components/ui/document-reader/index.d.ts +2 -0
  18. package/dist/components/ui/document-reader/index.js +2 -0
  19. package/dist/components/ui/graph-canvas/graph-canvas.svelte +69 -2
  20. package/dist/components/ui/graph-canvas/graph-canvas.svelte.d.ts +2 -0
  21. package/dist/components/ui/graph-canvas/scene.d.ts +6 -0
  22. package/dist/components/ui/graph-canvas/scene.js +52 -21
  23. package/dist/components/ui/graph-canvas/types.d.ts +3 -0
  24. package/dist/components/ui/page-container/index.d.ts +2 -0
  25. package/dist/components/ui/page-container/index.js +2 -0
  26. package/dist/components/ui/page-container/page-container.svelte +39 -0
  27. package/dist/components/ui/page-container/page-container.svelte.d.ts +24 -0
  28. package/dist/components/ui/page-header/page-header.svelte +12 -7
  29. package/dist/components/ui/page-header/page-header.svelte.d.ts +3 -2
  30. package/dist/components/ui/page-section/index.d.ts +2 -0
  31. package/dist/components/ui/page-section/index.js +2 -0
  32. package/dist/components/ui/page-section/page-section.svelte +92 -0
  33. package/dist/components/ui/page-section/page-section.svelte.d.ts +36 -0
  34. package/dist/components/ui/sheet/index.d.ts +13 -0
  35. package/dist/components/ui/sheet/index.js +15 -0
  36. package/dist/components/ui/sheet/sheet-body.svelte +21 -0
  37. package/dist/components/ui/sheet/sheet-body.svelte.d.ts +5 -0
  38. package/dist/components/ui/sheet/sheet-close.svelte +11 -0
  39. package/dist/components/ui/sheet/sheet-close.svelte.d.ts +4 -0
  40. package/dist/components/ui/sheet/sheet-content.svelte +70 -0
  41. package/dist/components/ui/sheet/sheet-content.svelte.d.ts +37 -0
  42. package/dist/components/ui/sheet/sheet-description.svelte +20 -0
  43. package/dist/components/ui/sheet/sheet-description.svelte.d.ts +4 -0
  44. package/dist/components/ui/sheet/sheet-footer.svelte +23 -0
  45. package/dist/components/ui/sheet/sheet-footer.svelte.d.ts +5 -0
  46. package/dist/components/ui/sheet/sheet-header.svelte +21 -0
  47. package/dist/components/ui/sheet/sheet-header.svelte.d.ts +5 -0
  48. package/dist/components/ui/sheet/sheet-overlay.svelte +20 -0
  49. package/dist/components/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
  50. package/dist/components/ui/sheet/sheet-portal.svelte +7 -0
  51. package/dist/components/ui/sheet/sheet-portal.svelte.d.ts +3 -0
  52. package/dist/components/ui/sheet/sheet-title.svelte +21 -0
  53. package/dist/components/ui/sheet/sheet-title.svelte.d.ts +4 -0
  54. package/dist/components/ui/sheet/sheet-trigger.svelte +11 -0
  55. package/dist/components/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
  56. package/dist/components/ui/sheet/sheet.svelte +7 -0
  57. package/dist/components/ui/sheet/sheet.svelte.d.ts +3 -0
  58. package/dist/components/ui/skeleton/skeleton.svelte +1 -1
  59. package/dist/styles.css +22 -0
  60. package/package.json +2 -1
  61. package/registry/component-map.json +43 -4
  62. package/registry/component-map.md +9 -4
@@ -0,0 +1,336 @@
1
+ <script lang="ts" module>
2
+ /** One heading of the body, as the contents list shows it. */
3
+ export type DocumentReaderHeading = { id: string; text: string; level: 2 | 3 };
4
+
5
+ /** A heading's id from its text: lower case, words joined by hyphens. */
6
+ export function slugify(text: string): string {
7
+ return (
8
+ text
9
+ .toLowerCase()
10
+ .normalize('NFKD')
11
+ .replace(/[̀-ͯ]/g, '')
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '') || 'section'
14
+ );
15
+ }
16
+ </script>
17
+
18
+ <script lang="ts">
19
+ /**
20
+ * A document to read: its title and trail, the body at a reading measure,
21
+ * and a contents list built from the body's own headings.
22
+ *
23
+ * One app had built this for four routes and read its library documents
24
+ * through a second shell, a DocumentDetail with the body in a box under it.
25
+ * DocumentDetail stays the catalogue record of a document; when there is a
26
+ * body to read, this is where it is read, and the record's facts go in
27
+ * `aside` or `meta`.
28
+ *
29
+ * The body is HTML the app has already rendered and sanitised (`html`), or
30
+ * markup (`children`). The package styles it with `.ds-prose` and never
31
+ * renders markdown itself, for the reason `.ds-prose` gives: sanitising is
32
+ * a security boundary the package cannot see the inputs of.
33
+ *
34
+ * The contents list is the body's `h2` and `h3`. A heading with no id is
35
+ * given one from its text, so an app's markdown pipeline needs no heading-id
36
+ * plugin. A link scrolls to its heading directly rather than through the
37
+ * URL fragment, because a fragment cannot find an element inside a shadow
38
+ * root, and this reader appears inside elements too.
39
+ *
40
+ * It centres itself and carries its own measure, so its route's `measure`
41
+ * only needs to leave room: `page` or wider shows the contents beside the
42
+ * body, and the shift from beside to above is a container query on the
43
+ * reader's own width, not the window's.
44
+ */
45
+ import { untrack, type Snippet } from 'svelte';
46
+ import type { HTMLAttributes } from 'svelte/elements';
47
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
48
+ import { cn, type WithElementRef } from '../../../utils.js';
49
+ import Button from '../button/button.svelte';
50
+ import PageHeader from '../page-header/page-header.svelte';
51
+ import type { BreadcrumbItem } from '../breadcrumbs/breadcrumbs.svelte';
52
+ import ContentSkeleton from '../content-skeleton/content-skeleton.svelte';
53
+ import ErrorState from '../error-state/error-state.svelte';
54
+ import LoadingState from '../loading-state/loading-state.svelte';
55
+
56
+ let {
57
+ title,
58
+ subtitle,
59
+ breadcrumbs,
60
+ meta,
61
+ actions,
62
+ html,
63
+ children,
64
+ contents = true,
65
+ contentsLabel = 'On this page',
66
+ aside,
67
+ footer,
68
+ loading = false,
69
+ error = null,
70
+ onRetry,
71
+ ref = $bindable(null),
72
+ class: className,
73
+ ...restProps
74
+ }: Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, 'title' | 'children'> & {
75
+ /** The document's title, as the page's heading. */
76
+ title?: string;
77
+ subtitle?: string;
78
+ breadcrumbs?: BreadcrumbItem[];
79
+ /** Facts about the document under its title: a date, a reading time, a status. */
80
+ meta?: Snippet;
81
+ actions?: Snippet;
82
+ /** The body, rendered and sanitised by the app. */
83
+ html?: string;
84
+ /** The body as markup, instead of `html`. */
85
+ children?: Snippet;
86
+ /** Show the contents list once the body has two headings or more. */
87
+ contents?: boolean;
88
+ contentsLabel?: string;
89
+ /** More for the side column, under the contents: the document's catalogue facts. */
90
+ aside?: Snippet;
91
+ /** After the body: what to read next, a completion control, related documents. */
92
+ footer?: Snippet;
93
+ /** The body is loading. With a `title` the header stands and the body is a skeleton. */
94
+ loading?: boolean;
95
+ error?: string | null;
96
+ onRetry?: () => void;
97
+ } = $props();
98
+
99
+ let article = $state<HTMLElement | null>(null);
100
+ let disclosure = $state<HTMLDetailsElement | null>(null);
101
+ let headings = $state<DocumentReaderHeading[]>([]);
102
+ let read = $state(false);
103
+ let active = $state<string | null>(null);
104
+
105
+ const showContents = $derived(contents && headings.length >= 2);
106
+ // The column is held while the body loads and until its headings are read
107
+ // (before the first paint), so a structured document never moves sideways
108
+ // as it arrives; one with fewer than two headings gives the room back.
109
+ const side = $derived(!!aside || (contents && (loading || !read || headings.length >= 2)));
110
+
111
+ /** Give every h2 and h3 an id, and read the list. */
112
+ function readHeadings(root: HTMLElement): DocumentReaderHeading[] {
113
+ const scope = root.getRootNode() as Document | ShadowRoot;
114
+ const taken = new Set<string>();
115
+ const found: DocumentReaderHeading[] = [];
116
+ const free = (id: string, el: HTMLElement) =>
117
+ !taken.has(id) && [null, el].includes(scope.getElementById(id) as HTMLElement | null);
118
+ for (const el of root.querySelectorAll<HTMLElement>('h2, h3')) {
119
+ let id = el.id;
120
+ if (!id || taken.has(id)) {
121
+ const base = slugify(el.textContent ?? '');
122
+ id = base;
123
+ for (let n = 2; !free(id, el); n++) id = `${base}-${n}`;
124
+ el.id = id;
125
+ }
126
+ taken.add(id);
127
+ found.push({ id, text: (el.textContent ?? '').trim(), level: el.tagName === 'H2' ? 2 : 3 });
128
+ }
129
+ return found;
130
+ }
131
+
132
+ const same = (a: DocumentReaderHeading[], b: DocumentReaderHeading[]) =>
133
+ a.length === b.length &&
134
+ a.every((h, i) => h.id === b[i]?.id && h.text === b[i]?.text && h.level === b[i]?.level);
135
+
136
+ // A link that named a heading: the router scrolled before the body existed,
137
+ // so the reader scrolls once the heading does. A fragment that is not valid
138
+ // percent-encoding names nothing.
139
+ let hashSought = false;
140
+ function seekHash(root: HTMLElement) {
141
+ if (hashSought) return;
142
+ let id = '';
143
+ try {
144
+ id = decodeURIComponent(window.location.hash.slice(1));
145
+ } catch {
146
+ hashSought = true;
147
+ return;
148
+ }
149
+ const el = id ? root.querySelector<HTMLElement>(`#${CSS.escape(id)}`) : null;
150
+ if (el) el.scrollIntoView({ block: 'start' });
151
+ hashSought = !id || !!el;
152
+ }
153
+
154
+ // The body changes when `html` does, or when the app's own markup does; a
155
+ // MutationObserver sees both. Attributes are not observed, so giving a
156
+ // heading its id does not re-run this, and a change that leaves the
157
+ // headings as they were leaves the list alone.
158
+ $effect(() => {
159
+ const root = article;
160
+ if (!root) return;
161
+ const update = () => {
162
+ const found = readHeadings(root);
163
+ if (!same(found, untrack(() => headings))) headings = found;
164
+ read = true;
165
+ seekHash(root);
166
+ };
167
+ update();
168
+ const observer = new MutationObserver(update);
169
+ observer.observe(root, { childList: true, subtree: true, characterData: true });
170
+ return () => observer.disconnect();
171
+ });
172
+
173
+ // The heading being read: the last one whose top has passed a line a little
174
+ // below the top of whatever scrolls. Scroll does not bubble but is captured
175
+ // at the window from any scroller in the page, and at the shadow root from
176
+ // one inside an element, which the window never hears. A link's own jump
177
+ // holds its heading for the length of the scroll it starts.
178
+ const READING_LINE = 96;
179
+ let heldUntil = 0;
180
+ $effect(() => {
181
+ const list = headings;
182
+ const root = article;
183
+ if (!root || list.length < 2) return;
184
+ const scopes: EventTarget[] = [window];
185
+ const shadow = root.getRootNode();
186
+ if (shadow instanceof ShadowRoot) scopes.push(shadow);
187
+ let frame = 0;
188
+ const update = () => {
189
+ frame = 0;
190
+ if (performance.now() < heldUntil) return;
191
+ let current: string | null = list[0]?.id ?? null;
192
+ for (const { id } of list) {
193
+ const el = root.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
194
+ if (el && el.getBoundingClientRect().top <= READING_LINE) current = id;
195
+ else break;
196
+ }
197
+ active = current;
198
+ };
199
+ const onScroll = () => {
200
+ if (!frame) frame = requestAnimationFrame(update);
201
+ };
202
+ update();
203
+ for (const scope of scopes) scope.addEventListener('scroll', onScroll, { capture: true, passive: true });
204
+ return () => {
205
+ for (const scope of scopes) scope.removeEventListener('scroll', onScroll, { capture: true });
206
+ if (frame) cancelAnimationFrame(frame);
207
+ };
208
+ });
209
+
210
+ function go(event: MouseEvent, id: string) {
211
+ const el = article?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
212
+ if (!el) return;
213
+ event.preventDefault();
214
+ const still = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
215
+ heldUntil = performance.now() + (still ? 0 : 800);
216
+ if (disclosure) disclosure.open = false;
217
+ el.scrollIntoView({ block: 'start', behavior: still ? 'auto' : 'smooth' });
218
+ // Move focus with the view, so the next Tab continues from the heading.
219
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
220
+ el.focus({ preventScroll: true });
221
+ active = id;
222
+ }
223
+ </script>
224
+
225
+ {#snippet contentsList()}
226
+ <nav aria-label={contentsLabel}>
227
+ <ul class="border-border flex flex-col border-l text-sm">
228
+ {#each headings as heading (heading.id)}
229
+ <li>
230
+ <a
231
+ href={`#${heading.id}`}
232
+ onclick={(event) => go(event, heading.id)}
233
+ aria-current={active === heading.id ? 'location' : undefined}
234
+ class={cn(
235
+ 'focus-visible:ring-ring/50 -ml-px block border-l-2 border-transparent py-1 pr-2 leading-snug outline-none focus-visible:ring-3',
236
+ heading.level === 2 ? 'pl-3' : 'pl-6',
237
+ active === heading.id
238
+ ? 'border-primary text-foreground font-medium'
239
+ : 'text-muted-foreground hover:text-foreground'
240
+ )}>{heading.text}</a
241
+ >
242
+ </li>
243
+ {/each}
244
+ </ul>
245
+ </nav>
246
+ {/snippet}
247
+
248
+ <div
249
+ bind:this={ref}
250
+ data-slot="document-reader"
251
+ class={cn('@container w-full text-base', className)}
252
+ {...restProps}
253
+ >
254
+ {#if error}
255
+ <ErrorState message={error}>
256
+ {#snippet action()}
257
+ {#if onRetry}
258
+ <Button variant="outline" onclick={onRetry}>Retry</Button>
259
+ {/if}
260
+ {/snippet}
261
+ </ErrorState>
262
+ {:else if loading && !title}
263
+ <LoadingState message="Loading the document…" />
264
+ {:else}
265
+ <div
266
+ class={cn(
267
+ 'mx-auto w-full max-w-(--ds-shell-measure-prose)',
268
+ side && '@5xl:max-w-[calc(var(--ds-shell-measure-prose)+16.5rem)]'
269
+ )}
270
+ >
271
+ {#if title || breadcrumbs?.length || meta || actions}
272
+ <PageHeader {title} {subtitle} {breadcrumbs} {meta} {actions} />
273
+ {/if}
274
+ {#if loading}
275
+ <ContentSkeleton shape="prose" label="Loading the document…" />
276
+ {:else}
277
+ <!-- One column, then two from 64rem of reader. The side column is
278
+ rendered once: below 64rem it follows the body and shows only
279
+ `aside`, since the contents fold into the disclosure above. -->
280
+ <div class="flex flex-col @5xl:flex-row @5xl:items-start @5xl:gap-10">
281
+ <div class="min-w-0 flex-1">
282
+ {#if showContents}
283
+ <details
284
+ bind:this={disclosure}
285
+ class="group bg-card border-border ds-edge mb-6 rounded-lg border @5xl:hidden"
286
+ >
287
+ <summary
288
+ class="focus-visible:ring-ring/50 flex cursor-pointer list-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium outline-none focus-visible:ring-3 [&::-webkit-details-marker]:hidden"
289
+ >
290
+ <ChevronRight
291
+ class="text-muted-foreground size-4 transition-transform group-open:rotate-90"
292
+ aria-hidden="true"
293
+ />
294
+ {contentsLabel}
295
+ </summary>
296
+ <div class="px-3 pb-3">{@render contentsList()}</div>
297
+ </details>
298
+ {/if}
299
+ <article
300
+ bind:this={article}
301
+ class="ds-prose ds-measure [&_:is(h2,h3)]:scroll-mt-4 [&_:is(h2,h3)]:outline-none"
302
+ data-measure="prose"
303
+ >
304
+ {#if html !== undefined}
305
+ {@html html}
306
+ {:else}
307
+ {@render children?.()}
308
+ {/if}
309
+ </article>
310
+ </div>
311
+ {#if side}
312
+ <div
313
+ data-slot="document-reader-side"
314
+ class="flex flex-col gap-8 @5xl:sticky @5xl:top-4 @5xl:max-h-[calc(100dvh-6rem)] @5xl:w-56 @5xl:shrink-0 @5xl:overflow-y-auto"
315
+ >
316
+ {#if showContents}
317
+ <div class="hidden flex-col gap-2 @5xl:flex">
318
+ <p class="text-sm font-medium" aria-hidden="true">{contentsLabel}</p>
319
+ {@render contentsList()}
320
+ </div>
321
+ {/if}
322
+ {#if aside}
323
+ <div class="mt-10 @5xl:mt-0">{@render aside()}</div>
324
+ {/if}
325
+ </div>
326
+ {/if}
327
+ </div>
328
+ {#if footer}
329
+ <div class="ds-measure border-border mt-12 border-t pt-6" data-measure="prose">
330
+ {@render footer()}
331
+ </div>
332
+ {/if}
333
+ {/if}
334
+ </div>
335
+ {/if}
336
+ </div>
@@ -0,0 +1,65 @@
1
+ /** One heading of the body, as the contents list shows it. */
2
+ export type DocumentReaderHeading = {
3
+ id: string;
4
+ text: string;
5
+ level: 2 | 3;
6
+ };
7
+ /** A heading's id from its text: lower case, words joined by hyphens. */
8
+ export declare function slugify(text: string): string;
9
+ /**
10
+ * A document to read: its title and trail, the body at a reading measure,
11
+ * and a contents list built from the body's own headings.
12
+ *
13
+ * One app had built this for four routes and read its library documents
14
+ * through a second shell, a DocumentDetail with the body in a box under it.
15
+ * DocumentDetail stays the catalogue record of a document; when there is a
16
+ * body to read, this is where it is read, and the record's facts go in
17
+ * `aside` or `meta`.
18
+ *
19
+ * The body is HTML the app has already rendered and sanitised (`html`), or
20
+ * markup (`children`). The package styles it with `.ds-prose` and never
21
+ * renders markdown itself, for the reason `.ds-prose` gives: sanitising is
22
+ * a security boundary the package cannot see the inputs of.
23
+ *
24
+ * The contents list is the body's `h2` and `h3`. A heading with no id is
25
+ * given one from its text, so an app's markdown pipeline needs no heading-id
26
+ * plugin. A link scrolls to its heading directly rather than through the
27
+ * URL fragment, because a fragment cannot find an element inside a shadow
28
+ * root, and this reader appears inside elements too.
29
+ *
30
+ * It centres itself and carries its own measure, so its route's `measure`
31
+ * only needs to leave room: `page` or wider shows the contents beside the
32
+ * body, and the shift from beside to above is a container query on the
33
+ * reader's own width, not the window's.
34
+ */
35
+ import { type Snippet } from 'svelte';
36
+ import type { HTMLAttributes } from 'svelte/elements';
37
+ import { type WithElementRef } from '../../../utils.js';
38
+ import type { BreadcrumbItem } from '../breadcrumbs/breadcrumbs.svelte';
39
+ type $$ComponentProps = Omit<WithElementRef<HTMLAttributes<HTMLDivElement>>, 'title' | 'children'> & {
40
+ /** The document's title, as the page's heading. */
41
+ title?: string;
42
+ subtitle?: string;
43
+ breadcrumbs?: BreadcrumbItem[];
44
+ /** Facts about the document under its title: a date, a reading time, a status. */
45
+ meta?: Snippet;
46
+ actions?: Snippet;
47
+ /** The body, rendered and sanitised by the app. */
48
+ html?: string;
49
+ /** The body as markup, instead of `html`. */
50
+ children?: Snippet;
51
+ /** Show the contents list once the body has two headings or more. */
52
+ contents?: boolean;
53
+ contentsLabel?: string;
54
+ /** More for the side column, under the contents: the document's catalogue facts. */
55
+ aside?: Snippet;
56
+ /** After the body: what to read next, a completion control, related documents. */
57
+ footer?: Snippet;
58
+ /** The body is loading. With a `title` the header stands and the body is a skeleton. */
59
+ loading?: boolean;
60
+ error?: string | null;
61
+ onRetry?: () => void;
62
+ };
63
+ declare const DocumentReader: import("svelte").Component<$$ComponentProps, {}, "ref">;
64
+ type DocumentReader = ReturnType<typeof DocumentReader>;
65
+ export default DocumentReader;
@@ -0,0 +1,2 @@
1
+ export { default as DocumentReader, slugify, type DocumentReaderHeading } from './document-reader.svelte';
2
+ export { default } from './document-reader.svelte';
@@ -0,0 +1,2 @@
1
+ export { default as DocumentReader, slugify } from './document-reader.svelte';
2
+ export { default } from './document-reader.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. */