@immediately-run/grove 0.1.1 → 0.1.3

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 (85) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +6 -2
  3. package/package.json +20 -8
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +485 -85
  6. package/src/GroveWiki.tsx +176 -54
  7. package/src/components/AssetImage.tsx +1 -12
  8. package/src/components/Backlinks.tsx +2 -2
  9. package/src/components/Catalogue.test.tsx +157 -0
  10. package/src/components/ContentTheme.test.tsx +146 -0
  11. package/src/components/ContentTheme.tsx +111 -0
  12. package/src/components/DocList.infinite.test.tsx +177 -0
  13. package/src/components/DocList.tsx +82 -9
  14. package/src/components/Drawer.tsx +14 -2
  15. package/src/components/EntryHeader.tsx +24 -15
  16. package/src/components/EntryImage.test.tsx +210 -0
  17. package/src/components/EntryImage.tsx +47 -0
  18. package/src/components/Galleries.test.tsx +98 -0
  19. package/src/components/GroveAgent.test.tsx +330 -0
  20. package/src/components/GroveAgent.tsx +269 -149
  21. package/src/components/GroveNav.test.tsx +229 -0
  22. package/src/components/GroveNav.tsx +69 -20
  23. package/src/components/Icon.tsx +0 -1
  24. package/src/components/InlineProse.tsx +37 -0
  25. package/src/components/LayoutGallery.tsx +65 -0
  26. package/src/components/PageView.tsx +19 -4
  27. package/src/components/Search.test.tsx +113 -0
  28. package/src/components/Search.tsx +53 -18
  29. package/src/components/Sidebar.test.tsx +135 -0
  30. package/src/components/Sidebar.tsx +114 -12
  31. package/src/components/TableOfContents.test.tsx +27 -16
  32. package/src/components/ThemeAssets.test.tsx +109 -0
  33. package/src/components/ThemeAssets.tsx +63 -0
  34. package/src/components/ThemeGallery.tsx +31 -0
  35. package/src/components/Timeline.tsx +5 -2
  36. package/src/components/WikiLink.tsx +2 -2
  37. package/src/data/catalogue.ts +54 -0
  38. package/src/data/themeFonts.ts +58 -0
  39. package/src/data/themes.ts +44 -4
  40. package/src/devfs.d.ts +5 -4
  41. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  42. package/src/hooks/useContentComponents.ts +1 -1
  43. package/src/hooks/useEditAffordance.ts +99 -0
  44. package/src/hooks/useOpenWikiBoot.ts +1 -1
  45. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  46. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  47. package/src/hooks/useScrollReset.test.tsx +132 -0
  48. package/src/hooks/useScrollReset.ts +52 -0
  49. package/src/index.css +9 -2
  50. package/src/lib/agentPrompt.test.ts +96 -0
  51. package/src/lib/agentPrompt.ts +86 -0
  52. package/src/lib/agentTools.test.ts +115 -0
  53. package/src/lib/agentTools.ts +132 -0
  54. package/src/lib/agentTranscript.ts +50 -0
  55. package/src/lib/assetPath.test.ts +36 -0
  56. package/src/lib/assetPath.ts +42 -0
  57. package/src/lib/collectionCalls.test.ts +69 -0
  58. package/src/lib/content.test.ts +10 -2
  59. package/src/lib/content.ts +7 -0
  60. package/src/lib/contentRoot.ts +16 -1
  61. package/src/lib/contentStylesheet.test.ts +80 -0
  62. package/src/lib/contentStylesheet.ts +103 -0
  63. package/src/lib/corpusScan.test.ts +37 -3
  64. package/src/lib/corpusScan.ts +17 -2
  65. package/src/lib/editTarget.test.ts +108 -0
  66. package/src/lib/editTarget.ts +93 -0
  67. package/src/lib/inlineProse.parity.test.ts +52 -0
  68. package/src/lib/layout.ts +29 -0
  69. package/src/lib/openWiki.test.ts +46 -5
  70. package/src/lib/openWiki.ts +18 -3
  71. package/src/lib/pageVariants.test.tsx +87 -0
  72. package/src/lib/queries.test.ts +33 -1
  73. package/src/lib/queries.ts +33 -1
  74. package/src/lib/reachCard.test.ts +94 -0
  75. package/src/lib/reachCard.ts +112 -0
  76. package/src/lib/shell.ts +17 -0
  77. package/src/lib/starterSweep.test.tsx +160 -0
  78. package/src/lib/starterSweep.ts +97 -0
  79. package/src/lib/themeAssets.test.ts +135 -0
  80. package/src/lib/themeAssets.ts +143 -0
  81. package/src/lib/themeSelection.test.ts +52 -0
  82. package/src/lib/themeSelection.ts +59 -0
  83. package/src/mdxComponents.ts +4 -0
  84. package/viewer-manifest.schema.json +37 -0
  85. package/viewer.manifest.json +133 -6
@@ -3,7 +3,9 @@ import { Link, useMetadataQuery } from '@immediately-run/sdk';
3
3
  import type { Metadata } from '@immediately-run/sdk';
4
4
  import { keyToHref } from '../lib/content';
5
5
  import { crumb } from '../lib/wiki';
6
- import { searchQuery } from '../lib/queries';
6
+ import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
7
+ import InlineProse from './InlineProse';
8
+ import { matchesQuery, searchQuery, toSearchEntries } from '../lib/queries';
7
9
  import type { SearchRecord } from '../lib/queries';
8
10
  import Icon from './Icon';
9
11
 
@@ -13,13 +15,19 @@ interface Hit {
13
15
  ns: string;
14
16
  }
15
17
 
18
+ let searchListSeq = 0;
19
+
16
20
  // `.grove-search` — the ⌘K command palette: client-side fuzzy filter over the
17
- // in-memory index, grouped into entries + tags, keyboard-navigable.
21
+ // in-memory index, grouped into entries + tags, keyboard-navigable. The input
22
+ // carries combobox semantics (aria-activedescendant tracks the highlight the
23
+ // filter maintains) and the box the dialog contract via the shared overlay
24
+ // hook (focus in, trap, Escape, focus return — R3-608).
18
25
  export default function Search({ onClose }: { onClose: () => void }) {
19
26
  const [query, setQuery] = useState('');
20
27
  const [sel, setSel] = useState(0);
21
- const boxRef = useRef<HTMLDivElement>(null);
22
28
  const inputRef = useRef<HTMLInputElement>(null);
29
+ const dialogRef = useOverlayFocusDismiss(true, onClose);
30
+ const listId = useMemo(() => `grove-search-list-${++searchListSeq}`, []);
23
31
 
24
32
  // Records, not tab-encoded paths (R3-276a): `tags` is the array itself, so the
25
33
  // join/split round-trip is gone.
@@ -28,12 +36,10 @@ export default function Search({ onClose }: { onClose: () => void }) {
28
36
 
29
37
  const rowsKey = rows.map((r) => r.path).join('|');
30
38
  const { entries, tags } = useMemo(() => {
31
- const es = rows.map(({ path, title, desc, tags: entryTags }) => ({
32
- key: path,
33
- title: (title || path).replace(/\.$/, ''),
34
- desc: desc || '',
35
- tags: entryTags.join(','),
36
- }));
39
+ const es = rows.map(({ path, title, desc, tags: entryTags }) => {
40
+ const entry = toSearchEntries([{ path, title, desc }])[0]!;
41
+ return { ...entry, tags: entryTags.join(',') };
42
+ });
37
43
  const tagSet = new Set<string>();
38
44
  es.forEach((e) => e.tags.split(',').filter(Boolean).forEach((t) => tagSet.add(t)));
39
45
  return { entries: es, tags: Array.from(tagSet).sort() };
@@ -43,7 +49,7 @@ export default function Search({ onClose }: { onClose: () => void }) {
43
49
  const ql = query.trim().toLowerCase();
44
50
  const matchedEntries: Hit[] = (ql
45
51
  ? entries.filter(
46
- (e) => e.title.toLowerCase().includes(ql) || e.desc.toLowerCase().includes(ql) || e.key.toLowerCase().includes(ql)
52
+ (e) => matchesQuery(e, ql)
47
53
  )
48
54
  : entries.slice(0, 6)
49
55
  ).map((e) => ({ key: e.key, title: e.title, ns: crumb(e.key) }));
@@ -55,7 +61,8 @@ export default function Search({ onClose }: { onClose: () => void }) {
55
61
  }, []);
56
62
 
57
63
  const onKey = (e: React.KeyboardEvent) => {
58
- if (e.key === 'Escape') return onClose();
64
+ // Escape is owned by the overlay hook (document capture); only the list
65
+ // navigation lives here.
59
66
  if (e.key === 'ArrowDown') {
60
67
  e.preventDefault();
61
68
  setSel((s) => Math.min(s + 1, Math.max(0, total - 1)));
@@ -64,17 +71,33 @@ export default function Search({ onClose }: { onClose: () => void }) {
64
71
  setSel((s) => Math.max(0, s - 1));
65
72
  } else if (e.key === 'Enter') {
66
73
  e.preventDefault();
67
- (boxRef.current?.querySelector('.grove-search__row[data-sel="1"]') as HTMLElement | null)?.click();
74
+ (dialogRef.current?.querySelector('.grove-search__row[data-sel="1"]') as HTMLElement | null)?.click();
68
75
  }
69
76
  };
70
77
 
78
+ const activeId = total > 0 ? `${listId}-opt-${sel}` : undefined;
79
+
71
80
  return (
72
81
  <div className="grove-search" onClick={onClose}>
73
- <div className="grove-search__box" ref={boxRef} onClick={(e) => e.stopPropagation()}>
82
+ <div
83
+ className="grove-search__box"
84
+ ref={dialogRef}
85
+ tabIndex={-1}
86
+ role="dialog"
87
+ aria-modal="true"
88
+ aria-label="Search entries and tags"
89
+ onClick={(e) => e.stopPropagation()}
90
+ >
74
91
  <div className="grove-search__in">
75
92
  <Icon name="search" />
76
93
  <input
77
94
  ref={inputRef}
95
+ role="combobox"
96
+ aria-expanded="true"
97
+ aria-controls={listId}
98
+ aria-activedescendant={activeId}
99
+ aria-autocomplete="list"
100
+ aria-label="Search entries and tags"
78
101
  value={query}
79
102
  placeholder="Search entries and tags…"
80
103
  onChange={(e) => {
@@ -85,7 +108,7 @@ export default function Search({ onClose }: { onClose: () => void }) {
85
108
  />
86
109
  <kbd>esc</kbd>
87
110
  </div>
88
- <div className="grove-search__res">
111
+ <div className="grove-search__res" role="listbox" id={listId} aria-label="Results">
89
112
  {total === 0 ? (
90
113
  <div className="grove-search__empty">
91
114
  No entry matches <code>{query}</code>.
@@ -99,24 +122,36 @@ export default function Search({ onClose }: { onClose: () => void }) {
99
122
  href={keyToHref(h.key)}
100
123
  className="grove-search__row"
101
124
  data-sel={sel === i ? '1' : '0'}
125
+ id={`${listId}-opt-${i}`}
126
+ role="option"
127
+ aria-selected={sel === i}
102
128
  onClick={onClose}
103
129
  >
104
130
  <Icon name="file" />
105
- <span className="t">{h.title}</span>
131
+ <span className="t">
132
+ <InlineProse text={h.title} trimPeriod />
133
+ </span>
106
134
  <span className="c">/{h.ns}</span>
107
135
  </Link>
108
136
  ))}
109
137
  {matchedTags.length ? <div className="grove-search__grp">Tags</div> : null}
110
138
  {matchedTags.map((t, i) => (
111
- <div
139
+ <button
112
140
  key={t}
141
+ type="button"
113
142
  className="grove-search__row"
114
143
  data-sel={sel === matchedEntries.length + i ? '1' : '0'}
115
- onClick={onClose}
144
+ id={`${listId}-opt-${matchedEntries.length + i}`}
145
+ role="option"
146
+ aria-selected={sel === matchedEntries.length + i}
147
+ onClick={() => {
148
+ setQuery(t);
149
+ setSel(0);
150
+ }}
116
151
  >
117
152
  <Icon name="list" />
118
153
  <span className="t">#{t}</span>
119
- </div>
154
+ </button>
120
155
  ))}
121
156
  </>
122
157
  )}
@@ -0,0 +1,135 @@
1
+ // @vitest-environment jsdom
2
+ // Sidebar tree navigation (R3-608, APG Tree View): exactly ONE tabbable row
3
+ // over a real filesMetadata fixture (the producers `lib/directory.test.ts`
4
+ // exercises build this same map), Down walks the rendered rows, and Right
5
+ // expands a collapsed folder through the SAME handler #63's toggle button
6
+ // uses.
7
+ import { describe, expect, it } from 'vitest';
8
+ import { act } from 'react';
9
+ import { createRoot, type Root } from 'react-dom/client';
10
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
11
+
12
+ const { default: Sidebar } = await import('./Sidebar');
13
+
14
+ // A real corpus shape: home + a wiki folder holding a leaf and a nested
15
+ // (initially collapsed) guide folder.
16
+ const FILES = {
17
+ '/app/content/home.mdx': { title: 'Home.' },
18
+ '/app/content/wiki/security.mdx': { title: 'Security.' },
19
+ '/app/content/wiki/guide/api.mdx': { title: 'API guide.' },
20
+ '/app/content/wiki/guide/deploy.mdx': { title: 'Deploying.' },
21
+ };
22
+
23
+ const NAV = {
24
+ outerHref: 'https://example.immediately.run/app/x',
25
+ navigationState: { sandboxPath: '/app/x', provider: 'github', namespace: 'immediately-run', repository: 'docs', ref: 'main', hash: '', search: '' },
26
+ routingSpec: {} as never,
27
+ filesMetadata: FILES,
28
+ };
29
+
30
+ async function mountSidebar(): Promise<{ root: Root; container: HTMLElement }> {
31
+ const container = document.createElement('div');
32
+ document.body.appendChild(container);
33
+ const root = createRoot(container);
34
+ await act(async () => {
35
+ root.render(
36
+ <TinkerableContext.Provider value={NAV as never}>
37
+ <Sidebar />
38
+ </TinkerableContext.Provider>,
39
+ );
40
+ });
41
+ return { root, container };
42
+ }
43
+
44
+ const rows = (container: HTMLElement): HTMLElement[] =>
45
+ [...container.querySelectorAll('[data-tree-row]')] as HTMLElement[];
46
+
47
+ describe('Sidebar tree navigation (R3-608)', () => {
48
+ it('exposes exactly ONE tabbable row — the roving stop', async () => {
49
+ const { container } = await mountSidebar();
50
+ const stops = container.querySelectorAll('.gs-tree [tabindex="0"]');
51
+ expect(stops.length).toBe(1);
52
+ });
53
+
54
+ it('Down moves focus through the rendered rows in order', async () => {
55
+ const { container } = await mountSidebar();
56
+ const list = rows(container);
57
+ expect(list.length).toBeGreaterThanOrEqual(3); // home, wiki folder, security
58
+ list[0].focus();
59
+ await act(async () => {
60
+ list[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
61
+ });
62
+ expect(document.activeElement).toBe(list[1]);
63
+ await act(async () => {
64
+ list[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
65
+ });
66
+ expect(document.activeElement).toBe(list[2]);
67
+ // Home returns to the first row.
68
+ await act(async () => {
69
+ list[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true, cancelable: true }));
70
+ });
71
+ expect(document.activeElement).toBe(list[0]);
72
+ });
73
+
74
+ it('Right expands a collapsed folder through the #63 toggle handler', async () => {
75
+ const { container } = await mountSidebar();
76
+ // Rows: home, [wiki folder], security, [guide folder — depth 1, collapsed].
77
+ const guide = rows(container).find((r) => r.getAttribute('aria-expanded') === 'false');
78
+ expect(guide).toBeTruthy();
79
+ const before = rows(container).length;
80
+ guide!.focus();
81
+ await act(async () => {
82
+ guide!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }));
83
+ });
84
+ // The folder opened: its two leaf rows joined the rendered set, and the
85
+ // same row now announces expanded.
86
+ expect(rows(container).length).toBe(before + 2);
87
+ expect(guide!.getAttribute('aria-expanded')).toBe('true');
88
+ // Left collapses it again.
89
+ await act(async () => {
90
+ guide!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }));
91
+ });
92
+ expect(rows(container).length).toBe(before);
93
+ expect(guide!.getAttribute('aria-expanded')).toBe('false');
94
+ });
95
+
96
+ it('the rows carry treeitem semantics under a tree', async () => {
97
+ const { container } = await mountSidebar();
98
+ const tree = container.querySelector('[role="tree"]');
99
+ expect(tree).toBeTruthy();
100
+ expect(container.querySelectorAll('[role="treeitem"]').length).toBe(rows(container).length);
101
+ });
102
+
103
+ it('collapsing the branch that holds the current entry never strands the tab stop', async () => {
104
+ // Current entry = the api leaf deep in wiki/guide: its branch renders open.
105
+ const container = document.createElement('div');
106
+ document.body.appendChild(container);
107
+ const root = createRoot(container);
108
+ await act(async () => {
109
+ root.render(
110
+ <TinkerableContext.Provider value={{ ...NAV, navigationState: { ...NAV.navigationState, sandboxPath: '/app/content/wiki/guide/api.mdx' } } as never}>
111
+ <Sidebar />
112
+ </TinkerableContext.Provider>,
113
+ );
114
+ });
115
+ // The current row IS the roving stop.
116
+ expect(container.querySelectorAll('.gs-tree [tabindex="0"]').length).toBe(1);
117
+ expect((container.querySelector('.gs-tree [tabindex="0"]') as HTMLElement).getAttribute('data-cur')).toBe('1');
118
+ // Collapse the guide folder (it contains the current entry): its button
119
+ // becomes the stop — the nearest rendered ancestor — and the tree keeps
120
+ // exactly one tabbable row.
121
+ const guide = rows(container).find((r) => r.getAttribute('aria-expanded') === 'true' && r.textContent?.includes('guide'));
122
+ expect(guide).toBeTruthy();
123
+ guide!.focus();
124
+ await act(async () => {
125
+ guide!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }));
126
+ });
127
+ expect(guide!.getAttribute('aria-expanded')).toBe('false');
128
+ const stops = container.querySelectorAll('.gs-tree [tabindex="0"]');
129
+ expect(stops.length).toBe(1);
130
+ expect(stops[0]).toBe(guide);
131
+ await act(async () => {
132
+ root.unmount();
133
+ });
134
+ });
135
+ });
@@ -5,6 +5,8 @@ import type { Metadata } from '@immediately-run/sdk';
5
5
  import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
6
  import { contentDir, keyToHref, sandboxPathToKey } from '../lib/content';
7
7
  import { sidebarQuery } from '../lib/queries';
8
+ import { plainLabel } from '../lib/queries';
9
+ import InlineProse from './InlineProse';
8
10
  import type { SidebarRecord } from '../lib/queries';
9
11
  import Icon from './Icon';
10
12
 
@@ -34,9 +36,43 @@ function hasDescendant(node: TreeNode, key: string): boolean {
34
36
  return Object.values(node.children).some((c) => c.key === key || hasDescendant(c, key));
35
37
  }
36
38
 
39
+ function treeHoldsKey(node: TreeNode, key: string): boolean {
40
+ return node.key === key || hasDescendant(node, key);
41
+ }
42
+
43
+ /** A row's stable id: a leaf is its entry key; a folder is its names-path
44
+ * (the `#dir:` prefix cannot collide with an entry key). `dirPath` is the
45
+ * accumulated folder names from the tree root, trailing slash included. */
46
+ function folderRowId(dirPath: string, name: string): string {
47
+ return `#dir:${dirPath}${name}`;
48
+ }
49
+
50
+ /** The FIRST rendered row under a node (children in sorted order): the top
51
+ * child itself — a folder's button, or a leaf's link. */
52
+ function firstRowIdOf(node: TreeNode, dirPath: string): string | null {
53
+ const childKeys = Object.keys(node.children).sort();
54
+ if (!childKeys.length) return node.key ?? null;
55
+ const first = node.children[childKeys[0]];
56
+ return first.key ?? folderRowId(dirPath, first.name);
57
+ }
58
+
37
59
  // A folder (has children) or a leaf entry. Folders are collapsible; the branch
38
- // holding the current entry starts open.
39
- function Branch({ node, currentKey, depth }: { node: TreeNode; currentKey: string; depth: number }) {
60
+ // holding the current entry starts open. Rows are `treeitem`s under a roving
61
+ // tab stop (R3-608, APG Tree View): exactly one row is tabbable, arrows walk
62
+ // the tree — the #63 folder toggle keeps its handler and its aria-expanded.
63
+ function Branch({
64
+ node,
65
+ currentKey,
66
+ depth,
67
+ dirPath,
68
+ stopRowId,
69
+ }: {
70
+ node: TreeNode;
71
+ currentKey: string;
72
+ depth: number;
73
+ dirPath: string;
74
+ stopRowId: string | null;
75
+ }) {
40
76
  const childKeys = Object.keys(node.children).sort();
41
77
  const isFolder = childKeys.length > 0;
42
78
  const containsCurrent = !!currentKey && (node.key === currentKey || hasDescendant(node, currentKey));
@@ -44,24 +80,57 @@ function Branch({ node, currentKey, depth }: { node: TreeNode; currentKey: strin
44
80
 
45
81
  if (!isFolder) {
46
82
  return (
47
- <Link href={keyToHref(node.key!)} className="gs-tree__row" data-cur={node.key === currentKey ? '1' : '0'}>
83
+ <Link
84
+ href={keyToHref(node.key!)}
85
+ className="gs-tree__row"
86
+ data-cur={node.key === currentKey ? '1' : '0'}
87
+ data-tree-row="1"
88
+ role="treeitem"
89
+ aria-level={depth + 1}
90
+ aria-current={node.key === currentKey ? 'page' : undefined}
91
+ tabIndex={node.key === stopRowId ? 0 : -1}
92
+ >
48
93
  <Icon name="file" />
49
- {(node.title || node.name).replace(/\.$/, '')}
94
+ <InlineProse text={node.title || node.name} trimPeriod />
50
95
  </Link>
51
96
  );
52
97
  }
53
98
 
99
+ const rowId = folderRowId(dirPath, node.name);
54
100
  return (
55
101
  <div>
56
- <div className="gs-tree__row gs-folder" data-open={open ? '1' : '0'} onClick={() => setOpen((o) => !o)}>
102
+ <button
103
+ type="button"
104
+ className="gs-tree__row gs-folder"
105
+ data-open={open ? '1' : '0'}
106
+ data-tree-row="1"
107
+ role="treeitem"
108
+ aria-level={depth + 1}
109
+ aria-expanded={open}
110
+ /* Roving stop, with the collapsed-ancestor fallback (R3-608 review):
111
+ * the folder holds the stop when it IS the stop's row, or when it is
112
+ * collapsed yet CONTAINS the current entry — collapsing the branch the
113
+ * reader is on must never unmount the tree's only tab stop. Exactly
114
+ * one row qualifies either way: the collapsed folder is the nearest
115
+ * rendered ancestor of the (unrendered) current row. */
116
+ tabIndex={rowId === stopRowId || (!open && containsCurrent) ? 0 : -1}
117
+ onClick={() => setOpen((o) => !o)}
118
+ >
57
119
  <Icon name="chevron-down" className="chev" />
58
120
  {node.name}
59
121
  <span className="ct">{childKeys.length}</span>
60
- </div>
122
+ </button>
61
123
  {open ? (
62
- <div className="gs-tree__children">
124
+ <div className="gs-tree__children" role="group">
63
125
  {childKeys.map((k) => (
64
- <Branch key={k} node={node.children[k]} currentKey={currentKey} depth={depth + 1} />
126
+ <Branch
127
+ key={k}
128
+ node={node.children[k]}
129
+ currentKey={currentKey}
130
+ depth={depth + 1}
131
+ dirPath={`${dirPath}${node.name}/`}
132
+ stopRowId={stopRowId}
133
+ />
65
134
  ))}
66
135
  </div>
67
136
  ) : null}
@@ -85,7 +154,7 @@ export default function Sidebar() {
85
154
  const secs: { key: string; label: string }[] = [];
86
155
  rows.forEach(({ path, title, tags, nav }) => {
87
156
  insert(root, path, title);
88
- if (tags.includes('ui/sidebar')) secs.push({ key: path, label: nav || title || path });
157
+ if (tags.includes('ui/sidebar')) secs.push({ key: path, label: plainLabel(nav || title || path) });
89
158
  });
90
159
  return { tree: root, sections: secs };
91
160
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -93,19 +162,52 @@ export default function Sidebar() {
93
162
 
94
163
  const topKeys = Object.keys(tree.children).sort();
95
164
 
165
+ // The roving tab stop: the current entry's own row (its branch opens by
166
+ // construction), else the tree's first rendered row.
167
+ const stopRowId = useMemo(() => {
168
+ if (currentKey && treeHoldsKey(tree, currentKey)) return currentKey;
169
+ return firstRowIdOf(tree, '');
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, [rowsKey, currentKey]);
172
+
173
+ // The tree's keyboard controller (R3-608): Up/Down/Home/End walk the
174
+ // RENDERED rows in DOM order; Right/Left expand/collapse by delegating to
175
+ // the folder row's own handler (the #63 button's onClick) when the row's
176
+ // aria-expanded says the arrow applies.
177
+ const onTreeKeyDown = (e: React.KeyboardEvent) => {
178
+ const rowsInOrder = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[data-tree-row]')];
179
+ if (!rowsInOrder.length) return;
180
+ const activeIdx = rowsInOrder.indexOf(document.activeElement as HTMLElement);
181
+ const move = (idx: number) => {
182
+ e.preventDefault();
183
+ rowsInOrder[Math.max(0, Math.min(rowsInOrder.length - 1, idx))]?.focus();
184
+ };
185
+ if (e.key === 'ArrowDown') move(activeIdx + 1);
186
+ else if (e.key === 'ArrowUp') move(activeIdx - 1);
187
+ else if (e.key === 'Home') move(0);
188
+ else if (e.key === 'End') move(rowsInOrder.length - 1);
189
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
190
+ const row = rowsInOrder[activeIdx];
191
+ const wantOpen = e.key === 'ArrowRight';
192
+ if (!row || row.getAttribute('aria-expanded') !== (wantOpen ? 'false' : 'true')) return;
193
+ e.preventDefault();
194
+ row.click(); // the #63 folder toggle's own handler
195
+ }
196
+ };
197
+
96
198
  return (
97
199
  <aside className="grove-sidebar">
98
200
  <div className="gs-block">
99
201
  <div className="gs-block__h">Browse</div>
100
- <div className="gs-tree">
202
+ <div className="gs-tree" role="tree" aria-label="Entries" onKeyDown={onTreeKeyDown}>
101
203
  {topKeys.map((k) => (
102
- <Branch key={k} node={tree.children[k]} currentKey={currentKey} depth={0} />
204
+ <Branch key={k} node={tree.children[k]} currentKey={currentKey} depth={0} dirPath="" stopRowId={stopRowId} />
103
205
  ))}
104
206
  </div>
105
207
  </div>
106
208
  {sections.map((s) => (
107
209
  <div className="gs-block" key={s.key}>
108
- <div className="gs-block__h">{s.label.replace(/\.$/, '')}</div>
210
+ <div className="gs-block__h">{s.label}</div>
109
211
  <div className="gs-section__body">
110
212
  <Link href={keyToHref(s.key)} className="grove-wikilink" data-state="ok">
111
213
  Open
@@ -1,7 +1,8 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
3
  import { act } from 'react';
4
- import { createRoot } from 'react-dom/client';
4
+ import type { ReactNode } from 'react';
5
+ import { createRoot, type Root } from 'react-dom/client';
5
6
  import TableOfContents from './TableOfContents';
6
7
 
7
8
  // jsdom performs no layout, so every geometry property this component reads is 0. The
@@ -54,6 +55,25 @@ function layout(nav: HTMLElement, opts: { viewHeight?: number; scrollHeight?: nu
54
55
 
55
56
  const IDS = Array.from({ length: 40 }, (_, i) => `sec-${i + 1}`);
56
57
 
58
+ // Every root this file mounts, so `afterEach` can tear them down. Clearing `document.body`
59
+ // is not unmounting: `useHeadings` arms 120/300/600ms re-scan timers and only clears them
60
+ // on unmount, so an abandoned root leaves a timer that fires after the file's jsdom
61
+ // environment is gone — an unhandled `document is not defined` attributed to whichever
62
+ // file happened to be running.
63
+ const roots: Root[] = [];
64
+
65
+ /** Mount `node` into a fresh host and remember the root for teardown. */
66
+ async function renderInto(node: ReactNode): Promise<HTMLElement> {
67
+ const host = document.createElement('div');
68
+ document.body.appendChild(host);
69
+ const root = createRoot(host);
70
+ roots.push(root);
71
+ await act(async () => {
72
+ root.render(node);
73
+ });
74
+ return host;
75
+ }
76
+
57
77
  let scrollTo: ReturnType<typeof vi.fn>;
58
78
  let scrollIntoView: ReturnType<typeof vi.fn>;
59
79
 
@@ -66,17 +86,16 @@ beforeEach(() => {
66
86
  Element.prototype.scrollIntoView = scrollIntoView as never;
67
87
  });
68
88
  afterEach(() => {
89
+ act(() => {
90
+ roots.splice(0).forEach((r) => r.unmount());
91
+ });
69
92
  document.body.innerHTML = '';
70
93
  vi.restoreAllMocks();
71
94
  });
72
95
 
73
96
  async function mount(): Promise<HTMLElement> {
74
97
  plantProse(IDS);
75
- const host = document.createElement('div');
76
- document.body.appendChild(host);
77
- await act(async () => {
78
- createRoot(host).render(<TableOfContents entryKey="/app/content/x.mdx" />);
79
- });
98
+ const host = await renderInto(<TableOfContents entryKey="/app/content/x.mdx" />);
80
99
  return layout(host.querySelector('nav')!);
81
100
  }
82
101
 
@@ -112,11 +131,7 @@ describe('<TableOfContents/>', () => {
112
131
 
113
132
  it('does nothing when the whole list fits', async () => {
114
133
  plantProse(IDS);
115
- const host = document.createElement('div');
116
- document.body.appendChild(host);
117
- await act(async () => {
118
- createRoot(host).render(<TableOfContents entryKey="/app/content/x.mdx" />);
119
- });
134
+ const host = await renderInto(<TableOfContents entryKey="/app/content/x.mdx" />);
120
135
  layout(host.querySelector('nav')!, { viewHeight: 1000, scrollHeight: 1000 });
121
136
  await spyTo('sec-30');
122
137
  expect(scrollTo).not.toHaveBeenCalled();
@@ -141,11 +156,7 @@ describe('<TableOfContents/>', () => {
141
156
  });
142
157
 
143
158
  it('renders nothing for an entry with no sub-headings', async () => {
144
- const host = document.createElement('div');
145
- document.body.appendChild(host);
146
- await act(async () => {
147
- createRoot(host).render(<TableOfContents entryKey="/app/content/empty.mdx" />);
148
- });
159
+ const host = await renderInto(<TableOfContents entryKey="/app/content/empty.mdx" />);
149
160
  expect(host.querySelector('nav')).toBeNull();
150
161
  });
151
162
 
@@ -0,0 +1,109 @@
1
+ // @vitest-environment jsdom
2
+ // R3-315's component-level exits: the engine emission renders the minted
3
+ // @font-face set inside the engine cascade layer, revokes on unmount (a switch
4
+ // leaks nothing), degrades to nothing when the bytes are unreadable, and — the
5
+ // offline exit — no shipped stylesheet carries a third-party @import.
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+ import { act } from 'react';
8
+ import { createRoot, type Root } from 'react-dom/client';
9
+ import type { ReactNode } from 'react';
10
+
11
+ const readFile = vi.fn();
12
+ (globalThis as { __sandpackSharedFs?: unknown }).__sandpackSharedFs = {
13
+ promises: { readFile: (...a: unknown[]) => readFile(...a) },
14
+ };
15
+
16
+ const { default: ThemeAssets } = await import('./ThemeAssets');
17
+ const { DEFAULT_THEME_ASSETS } = await import('../data/themeFonts');
18
+
19
+ const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
20
+
21
+ async function renderSwap(nodes: ReactNode[]): Promise<HTMLElement> {
22
+ const container = document.createElement('div');
23
+ document.body.appendChild(container);
24
+ const root = createRoot(container);
25
+ for (const node of nodes) {
26
+ await act(async () => {
27
+ root.render(node);
28
+ });
29
+ }
30
+ return container;
31
+ }
32
+
33
+ beforeEach(() => {
34
+ readFile.mockReset();
35
+ revokeSpy.mockClear();
36
+ });
37
+
38
+ describe('the engine emission', () => {
39
+ it('renders the minted faces inside the ENGINE cascade layer, blob: srcs only', async () => {
40
+ readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
41
+ const el = await renderSwap([<ThemeAssets declarations={DEFAULT_THEME_ASSETS} />]);
42
+ const style = el.querySelector('style[data-grove-theme-assets]');
43
+ expect(style).toBeTruthy();
44
+ expect(style?.textContent).toContain('@layer grove.engine{');
45
+ expect(style?.textContent).toContain('"Gabarito"');
46
+ expect(style?.textContent).toContain('"Public Sans"');
47
+ expect(style?.textContent).toContain('"Space Mono"');
48
+ expect((style?.textContent?.match(/src: url\("blob:/g) ?? []).length).toBe(12);
49
+ // The read set is the in-repo face set — no network fetch is involved at all.
50
+ // The read goes through the SDK root-mount (leading slash stripped — the
51
+ // root-anchored relPath, the same key space MountImage reads).
52
+ expect(readFile).toHaveBeenCalledWith('/app/assets/fonts/gabarito-latin-400-normal.woff2');
53
+ });
54
+
55
+ it('unmount revokes every minted URL — switching sets leaks nothing', async () => {
56
+ readFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
57
+ const container = document.createElement('div');
58
+ document.body.appendChild(container);
59
+ let root: Root | null = createRoot(container);
60
+ await act(async () => {
61
+ root?.render(<ThemeAssets declarations={DEFAULT_THEME_ASSETS} />);
62
+ });
63
+ expect(revokeSpy).not.toHaveBeenCalled(); // live set holds its URLs
64
+ await act(async () => {
65
+ root?.render(<ThemeAssets declarations={{ fonts: [{ family: 'Other', src: './o.woff2' }] }} />);
66
+ });
67
+ // The DEFAULT set (12 URLs) was the OUTGOING set — revoked on the swap.
68
+ expect(revokeSpy).toHaveBeenCalledTimes(12);
69
+ await act(async () => {}); // let the incoming set's mint land (it is now live)
70
+ await act(async () => {
71
+ root?.unmount();
72
+ });
73
+ root = null;
74
+ expect(revokeSpy).toHaveBeenCalledTimes(13); // + the incoming set's 1
75
+ });
76
+
77
+ it('unreadable bytes degrade to no style element at all (the fallback stack answers)', async () => {
78
+ readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
79
+ const el = await renderSwap([<ThemeAssets declarations={DEFAULT_THEME_ASSETS} />]);
80
+ expect(el.querySelector('style[data-grove-theme-assets]')).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe('the offline exit — no third-party @import survives in any shipped stylesheet', () => {
85
+ it('grep over the shipped css: no @import names a network location', async () => {
86
+ const { readdir, readFile: rf } = await import('node:fs/promises');
87
+ const walk = async (dir: string): Promise<string[]> => {
88
+ const out: string[] = [];
89
+ for (const e of await readdir(dir, { withFileTypes: true })) {
90
+ const p = `${dir}/${e.name}`;
91
+ if (e.isDirectory()) out.push(...(await walk(p)));
92
+ else if (p.endsWith('.css')) out.push(p);
93
+ }
94
+ return out;
95
+ };
96
+ const files = await walk('src');
97
+ expect(files.length).toBeGreaterThan(0);
98
+ for (const f of files) {
99
+ const css = await rf(f, 'utf8');
100
+ for (const line of css.split('\n')) {
101
+ if (line.includes('@import')) {
102
+ // Only same-package/relative imports could ever be acceptable; a URL is
103
+ // the withdrawn exception.
104
+ expect(line).not.toMatch(/https?:\/\//);
105
+ }
106
+ }
107
+ }
108
+ });
109
+ });