@immediately-run/grove 0.1.1

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 (92) hide show
  1. package/README.md +122 -0
  2. package/docs/ENGINE_BOUNDARY.md +233 -0
  3. package/llms.txt +115 -0
  4. package/package.json +82 -0
  5. package/src/App.tsx +131 -0
  6. package/src/GroveApp.css +2774 -0
  7. package/src/GroveWiki.tsx +386 -0
  8. package/src/components/AssetImage.tsx +58 -0
  9. package/src/components/Backlinks.tsx +104 -0
  10. package/src/components/Callout.tsx +26 -0
  11. package/src/components/ChildPages.tsx +40 -0
  12. package/src/components/DefaultLayout.tsx +27 -0
  13. package/src/components/Directory.tsx +65 -0
  14. package/src/components/DirectoryList.test.tsx +275 -0
  15. package/src/components/DirectoryList.tsx +189 -0
  16. package/src/components/DirectoryView.tsx +68 -0
  17. package/src/components/DocList.tsx +109 -0
  18. package/src/components/DocsByTag.tsx +13 -0
  19. package/src/components/Drawer.tsx +45 -0
  20. package/src/components/EntryHeader.tsx +51 -0
  21. package/src/components/FamilyTree.tsx +95 -0
  22. package/src/components/GroveAgent.tsx +264 -0
  23. package/src/components/GroveFooter.tsx +20 -0
  24. package/src/components/GroveNav.tsx +102 -0
  25. package/src/components/Icon.tsx +56 -0
  26. package/src/components/Infobox.tsx +19 -0
  27. package/src/components/Kbd.tsx +10 -0
  28. package/src/components/KeyValue.tsx +32 -0
  29. package/src/components/Lede.tsx +6 -0
  30. package/src/components/More.tsx +10 -0
  31. package/src/components/Outlet.tsx +11 -0
  32. package/src/components/PageMeta.tsx +24 -0
  33. package/src/components/PageView.tsx +98 -0
  34. package/src/components/Quote.tsx +36 -0
  35. package/src/components/RecentlyUpdated.tsx +6 -0
  36. package/src/components/SafeEntryBody.tsx +72 -0
  37. package/src/components/SafeLayout.tsx +35 -0
  38. package/src/components/ScrollToFragment.tsx +63 -0
  39. package/src/components/Search.tsx +127 -0
  40. package/src/components/Sidebar.tsx +118 -0
  41. package/src/components/TableOfContents.test.tsx +163 -0
  42. package/src/components/TableOfContents.tsx +101 -0
  43. package/src/components/TagCloud.tsx +46 -0
  44. package/src/components/TagList.tsx +31 -0
  45. package/src/components/Timeline.tsx +55 -0
  46. package/src/components/Toc.tsx +14 -0
  47. package/src/components/WikiLink.tsx +112 -0
  48. package/src/data/themes.ts +14 -0
  49. package/src/devfs.d.ts +4 -0
  50. package/src/hooks/useContentComponents.ts +122 -0
  51. package/src/hooks/useCorpusMetadata.ts +43 -0
  52. package/src/hooks/useDirectoryListing.ts +56 -0
  53. package/src/hooks/useHeadings.ts +96 -0
  54. package/src/hooks/useOpenWikiBoot.ts +95 -0
  55. package/src/index.css +120 -0
  56. package/src/lib/compose.test.ts +92 -0
  57. package/src/lib/compose.ts +99 -0
  58. package/src/lib/content.test.ts +269 -0
  59. package/src/lib/content.ts +267 -0
  60. package/src/lib/contentRoot.ts +61 -0
  61. package/src/lib/corpusComponents.test.ts +101 -0
  62. package/src/lib/corpusComponents.ts +117 -0
  63. package/src/lib/corpusScan.test.ts +157 -0
  64. package/src/lib/corpusScan.ts +105 -0
  65. package/src/lib/directory.test.ts +216 -0
  66. package/src/lib/directory.ts +262 -0
  67. package/src/lib/fragment.test.ts +88 -0
  68. package/src/lib/fragment.ts +55 -0
  69. package/src/lib/frontmatter.ts +26 -0
  70. package/src/lib/layout.ts +84 -0
  71. package/src/lib/openWiki.test.ts +216 -0
  72. package/src/lib/openWiki.ts +84 -0
  73. package/src/lib/queries.test.ts +74 -0
  74. package/src/lib/queries.ts +84 -0
  75. package/src/lib/safeIntrinsics.test.tsx +99 -0
  76. package/src/lib/safeIntrinsics.tsx +77 -0
  77. package/src/lib/safeRender.test.ts +359 -0
  78. package/src/lib/safeSources.ts +25 -0
  79. package/src/lib/shell.ts +71 -0
  80. package/src/lib/sourceCache.test.ts +66 -0
  81. package/src/lib/sourceCache.ts +42 -0
  82. package/src/lib/tocScroll.test.ts +71 -0
  83. package/src/lib/tocScroll.ts +93 -0
  84. package/src/lib/wiki.test.ts +194 -0
  85. package/src/lib/wiki.ts +175 -0
  86. package/src/lib.ts +54 -0
  87. package/src/main.tsx +19 -0
  88. package/src/mdx.d.ts +9 -0
  89. package/src/mdxComponents.ts +89 -0
  90. package/src/test/setup.ts +19 -0
  91. package/viewer-manifest.schema.json +62 -0
  92. package/viewer.manifest.json +267 -0
@@ -0,0 +1,127 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { Link, useMetadataQuery } from '@immediately-run/sdk';
3
+ import type { Metadata } from '@immediately-run/sdk';
4
+ import { keyToHref } from '../lib/content';
5
+ import { crumb } from '../lib/wiki';
6
+ import { searchQuery } from '../lib/queries';
7
+ import type { SearchRecord } from '../lib/queries';
8
+ import Icon from './Icon';
9
+
10
+ interface Hit {
11
+ key: string;
12
+ title: string;
13
+ ns: string;
14
+ }
15
+
16
+ // `.grove-search` — the ⌘K command palette: client-side fuzzy filter over the
17
+ // in-memory index, grouped into entries + tags, keyboard-navigable.
18
+ export default function Search({ onClose }: { onClose: () => void }) {
19
+ const [query, setQuery] = useState('');
20
+ const [sel, setSel] = useState(0);
21
+ const boxRef = useRef<HTMLDivElement>(null);
22
+ const inputRef = useRef<HTMLInputElement>(null);
23
+
24
+ // Records, not tab-encoded paths (R3-276a): `tags` is the array itself, so the
25
+ // join/split round-trip is gone.
26
+ const q = useMetadataQuery<Metadata, SearchRecord>(searchQuery);
27
+ const rows: SearchRecord[] = Array.isArray(q) ? q : [];
28
+
29
+ const rowsKey = rows.map((r) => r.path).join('|');
30
+ 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
+ }));
37
+ const tagSet = new Set<string>();
38
+ es.forEach((e) => e.tags.split(',').filter(Boolean).forEach((t) => tagSet.add(t)));
39
+ return { entries: es, tags: Array.from(tagSet).sort() };
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [rowsKey]);
42
+
43
+ const ql = query.trim().toLowerCase();
44
+ const matchedEntries: Hit[] = (ql
45
+ ? entries.filter(
46
+ (e) => e.title.toLowerCase().includes(ql) || e.desc.toLowerCase().includes(ql) || e.key.toLowerCase().includes(ql)
47
+ )
48
+ : entries.slice(0, 6)
49
+ ).map((e) => ({ key: e.key, title: e.title, ns: crumb(e.key) }));
50
+ const matchedTags = ql ? tags.filter((t) => t.toLowerCase().includes(ql)) : [];
51
+ const total = matchedEntries.length + matchedTags.length;
52
+
53
+ useEffect(() => {
54
+ inputRef.current?.focus();
55
+ }, []);
56
+
57
+ const onKey = (e: React.KeyboardEvent) => {
58
+ if (e.key === 'Escape') return onClose();
59
+ if (e.key === 'ArrowDown') {
60
+ e.preventDefault();
61
+ setSel((s) => Math.min(s + 1, Math.max(0, total - 1)));
62
+ } else if (e.key === 'ArrowUp') {
63
+ e.preventDefault();
64
+ setSel((s) => Math.max(0, s - 1));
65
+ } else if (e.key === 'Enter') {
66
+ e.preventDefault();
67
+ (boxRef.current?.querySelector('.grove-search__row[data-sel="1"]') as HTMLElement | null)?.click();
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div className="grove-search" onClick={onClose}>
73
+ <div className="grove-search__box" ref={boxRef} onClick={(e) => e.stopPropagation()}>
74
+ <div className="grove-search__in">
75
+ <Icon name="search" />
76
+ <input
77
+ ref={inputRef}
78
+ value={query}
79
+ placeholder="Search entries and tags…"
80
+ onChange={(e) => {
81
+ setQuery(e.target.value);
82
+ setSel(0);
83
+ }}
84
+ onKeyDown={onKey}
85
+ />
86
+ <kbd>esc</kbd>
87
+ </div>
88
+ <div className="grove-search__res">
89
+ {total === 0 ? (
90
+ <div className="grove-search__empty">
91
+ No entry matches <code>{query}</code>.
92
+ </div>
93
+ ) : (
94
+ <>
95
+ {matchedEntries.length ? <div className="grove-search__grp">{ql ? 'Entries' : 'Recent'}</div> : null}
96
+ {matchedEntries.map((h, i) => (
97
+ <Link
98
+ key={h.key}
99
+ href={keyToHref(h.key)}
100
+ className="grove-search__row"
101
+ data-sel={sel === i ? '1' : '0'}
102
+ onClick={onClose}
103
+ >
104
+ <Icon name="file" />
105
+ <span className="t">{h.title}</span>
106
+ <span className="c">/{h.ns}</span>
107
+ </Link>
108
+ ))}
109
+ {matchedTags.length ? <div className="grove-search__grp">Tags</div> : null}
110
+ {matchedTags.map((t, i) => (
111
+ <div
112
+ key={t}
113
+ className="grove-search__row"
114
+ data-sel={sel === matchedEntries.length + i ? '1' : '0'}
115
+ onClick={onClose}
116
+ >
117
+ <Icon name="list" />
118
+ <span className="t">#{t}</span>
119
+ </div>
120
+ ))}
121
+ </>
122
+ )}
123
+ </div>
124
+ </div>
125
+ </div>
126
+ );
127
+ }
@@ -0,0 +1,118 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useContext, useMemo, useState } from 'react';
3
+ import { Link, useMetadataQuery } from '@immediately-run/sdk';
4
+ import type { Metadata } from '@immediately-run/sdk';
5
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
6
+ import { contentDir, keyToHref, sandboxPathToKey } from '../lib/content';
7
+ import { sidebarQuery } from '../lib/queries';
8
+ import type { SidebarRecord } from '../lib/queries';
9
+ import Icon from './Icon';
10
+
11
+ interface TreeNode {
12
+ name: string;
13
+ key?: string; // leaf entry key
14
+ title?: string;
15
+ children: Record<string, TreeNode>;
16
+ }
17
+
18
+ function insert(root: TreeNode, key: string, title: string) {
19
+ const rel = key.replace(contentDir(), '').replace(/\.mdx?$/, '');
20
+ const parts = rel.split('/');
21
+ let node = root;
22
+ parts.forEach((part, i) => {
23
+ const leaf = i === parts.length - 1;
24
+ node.children[part] = node.children[part] || { name: part, children: {} };
25
+ node = node.children[part];
26
+ if (leaf) {
27
+ node.key = key;
28
+ node.title = title;
29
+ }
30
+ });
31
+ }
32
+
33
+ function hasDescendant(node: TreeNode, key: string): boolean {
34
+ return Object.values(node.children).some((c) => c.key === key || hasDescendant(c, key));
35
+ }
36
+
37
+ // 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 }) {
40
+ const childKeys = Object.keys(node.children).sort();
41
+ const isFolder = childKeys.length > 0;
42
+ const containsCurrent = !!currentKey && (node.key === currentKey || hasDescendant(node, currentKey));
43
+ const [open, setOpen] = useState(depth < 1 || containsCurrent);
44
+
45
+ if (!isFolder) {
46
+ return (
47
+ <Link href={keyToHref(node.key!)} className="gs-tree__row" data-cur={node.key === currentKey ? '1' : '0'}>
48
+ <Icon name="file" />
49
+ {(node.title || node.name).replace(/\.$/, '')}
50
+ </Link>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <div>
56
+ <div className="gs-tree__row gs-folder" data-open={open ? '1' : '0'} onClick={() => setOpen((o) => !o)}>
57
+ <Icon name="chevron-down" className="chev" />
58
+ {node.name}
59
+ <span className="ct">{childKeys.length}</span>
60
+ </div>
61
+ {open ? (
62
+ <div className="gs-tree__children">
63
+ {childKeys.map((k) => (
64
+ <Branch key={k} node={node.children[k]} currentKey={currentKey} depth={depth + 1} />
65
+ ))}
66
+ </div>
67
+ ) : null}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ // `.grove-sidebar` — the wiki shell's left rail: a namespace tree + author-defined
73
+ // `ui/sidebar` sections (a header + a link into the tagged entry).
74
+ export default function Sidebar() {
75
+ const ctx = useContext(TinkerableContext) as any;
76
+ const currentKey = sandboxPathToKey(ctx?.navigationState?.sandboxPath || '/');
77
+
78
+ // Records, not tab-encoded paths (R3-276a).
79
+ const q = useMetadataQuery<Metadata, SidebarRecord>(sidebarQuery);
80
+ const rows: SidebarRecord[] = Array.isArray(q) ? q : [];
81
+
82
+ const rowsKey = rows.map((r) => r.path).join('|');
83
+ const { tree, sections } = useMemo(() => {
84
+ const root: TreeNode = { name: '', children: {} };
85
+ const secs: { key: string; label: string }[] = [];
86
+ rows.forEach(({ path, title, tags, nav }) => {
87
+ insert(root, path, title);
88
+ if (tags.includes('ui/sidebar')) secs.push({ key: path, label: nav || title || path });
89
+ });
90
+ return { tree: root, sections: secs };
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [rowsKey]);
93
+
94
+ const topKeys = Object.keys(tree.children).sort();
95
+
96
+ return (
97
+ <aside className="grove-sidebar">
98
+ <div className="gs-block">
99
+ <div className="gs-block__h">Browse</div>
100
+ <div className="gs-tree">
101
+ {topKeys.map((k) => (
102
+ <Branch key={k} node={tree.children[k]} currentKey={currentKey} depth={0} />
103
+ ))}
104
+ </div>
105
+ </div>
106
+ {sections.map((s) => (
107
+ <div className="gs-block" key={s.key}>
108
+ <div className="gs-block__h">{s.label.replace(/\.$/, '')}</div>
109
+ <div className="gs-section__body">
110
+ <Link href={keyToHref(s.key)} className="grove-wikilink" data-state="ok">
111
+ Open
112
+ </Link>
113
+ </div>
114
+ </div>
115
+ ))}
116
+ </aside>
117
+ );
118
+ }
@@ -0,0 +1,163 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import { act } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+ import TableOfContents from './TableOfContents';
6
+
7
+ // jsdom performs no layout, so every geometry property this component reads is 0. The
8
+ // numbers are planted instead — which is fine, because the arithmetic itself is proven in
9
+ // `lib/tocScroll.test.ts`. What THIS file proves is the wiring: that the component reads
10
+ // the right elements, moves the right one, and declines when it should.
11
+
12
+ let ioCallback: ((entries: unknown[]) => void) | null = null;
13
+ class FakeIO {
14
+ constructor(cb: (entries: unknown[]) => void) {
15
+ ioCallback = cb;
16
+ }
17
+ observe() {}
18
+ disconnect() {
19
+ ioCallback = null;
20
+ }
21
+ }
22
+
23
+ /** Report a heading as the one the reader is currently in. */
24
+ async function spyTo(id: string) {
25
+ await act(async () => {
26
+ ioCallback?.([{ isIntersecting: true, target: document.getElementById(id) }]);
27
+ });
28
+ }
29
+
30
+ function plantProse(ids: string[]) {
31
+ const prose = document.createElement('div');
32
+ prose.className = 'grove-prose';
33
+ prose.innerHTML = ids.map((id) => `<h2 id="${id}">${id}</h2>`).join('');
34
+ document.body.appendChild(prose);
35
+ }
36
+
37
+ /** Give the rendered list a fake layout: 200px tall over 1000px of content, each row
38
+ * 20px starting at 40px (below the "On this page" header). */
39
+ function layout(nav: HTMLElement, opts: { viewHeight?: number; scrollHeight?: number } = {}) {
40
+ Object.defineProperty(nav, 'clientHeight', { value: opts.viewHeight ?? 200, configurable: true });
41
+ Object.defineProperty(nav, 'scrollHeight', { value: opts.scrollHeight ?? 1000, configurable: true });
42
+ let top = 0;
43
+ Object.defineProperty(nav, 'scrollTop', {
44
+ get: () => top,
45
+ set: (v) => { top = v; },
46
+ configurable: true,
47
+ });
48
+ nav.querySelectorAll('a').forEach((a, i) => {
49
+ Object.defineProperty(a, 'offsetTop', { value: 40 + i * 20, configurable: true });
50
+ Object.defineProperty(a, 'offsetHeight', { value: 20, configurable: true });
51
+ });
52
+ return nav;
53
+ }
54
+
55
+ const IDS = Array.from({ length: 40 }, (_, i) => `sec-${i + 1}`);
56
+
57
+ let scrollTo: ReturnType<typeof vi.fn>;
58
+ let scrollIntoView: ReturnType<typeof vi.fn>;
59
+
60
+ beforeEach(() => {
61
+ ioCallback = null;
62
+ (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = FakeIO;
63
+ scrollTo = vi.fn();
64
+ scrollIntoView = vi.fn();
65
+ Element.prototype.scrollTo = scrollTo as never;
66
+ Element.prototype.scrollIntoView = scrollIntoView as never;
67
+ });
68
+ afterEach(() => {
69
+ document.body.innerHTML = '';
70
+ vi.restoreAllMocks();
71
+ });
72
+
73
+ async function mount(): Promise<HTMLElement> {
74
+ 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
+ });
80
+ return layout(host.querySelector('nav')!);
81
+ }
82
+
83
+ describe('<TableOfContents/>', () => {
84
+ it('lists the rendered entry’s headings', async () => {
85
+ const nav = await mount();
86
+ expect(nav.querySelectorAll('a')).toHaveLength(40);
87
+ expect(nav.querySelector('.grove-toc__h')?.textContent).toBe('On this page');
88
+ });
89
+
90
+ it('scrolls its OWN container when the active entry is below the fold', async () => {
91
+ const nav = await mount();
92
+ await spyTo('sec-30'); // offsetTop 620, bottom 640 — far past a 200px view
93
+ expect(scrollTo).toHaveBeenCalledTimes(1);
94
+ expect(scrollTo.mock.calls[0][0]).toMatchObject({ top: 640 + 28 - 200 });
95
+ // …and on the LIST, not on some ancestor. The whole claim is "its own container".
96
+ expect(scrollTo.mock.instances[0]).toBe(nav);
97
+ });
98
+
99
+ it('never calls scrollIntoView on a list entry — that would scroll the PAGE', async () => {
100
+ // The trap this component exists to avoid: `scrollIntoView` walks every scrollable
101
+ // ancestor, so using it here moves the document and the article jumps under the reader.
102
+ await mount();
103
+ await spyTo('sec-30');
104
+ expect(scrollIntoView).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('does nothing when the active entry is already in view', async () => {
108
+ await mount();
109
+ await spyTo('sec-3'); // offsetTop 80 — comfortably inside a 200px view at scrollTop 0
110
+ expect(scrollTo).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('does nothing when the whole list fits', async () => {
114
+ 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
+ });
120
+ layout(host.querySelector('nav')!, { viewHeight: 1000, scrollHeight: 1000 });
121
+ await spyTo('sec-30');
122
+ expect(scrollTo).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('does not fight a reader who just scrolled the list by hand', async () => {
126
+ const nav = await mount();
127
+ await act(async () => {
128
+ nav.dispatchEvent(new WheelEvent('wheel', { bubbles: true }));
129
+ });
130
+ await spyTo('sec-30');
131
+ expect(scrollTo).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('marks the active entry for styling and for assistive tech', async () => {
135
+ const nav = await mount();
136
+ await spyTo('sec-12');
137
+ const active = nav.querySelector('a[data-cur="1"]');
138
+ expect(active?.getAttribute('href')).toBe('#sec-12');
139
+ expect(active?.getAttribute('aria-current')).toBe('location');
140
+ expect(nav.querySelectorAll('a[data-cur="1"]')).toHaveLength(1);
141
+ });
142
+
143
+ 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
+ });
149
+ expect(host.querySelector('nav')).toBeNull();
150
+ });
151
+
152
+ it('clicking an entry scrolls the DOCUMENT to that heading, and suspends the auto-scroll', async () => {
153
+ const nav = await mount();
154
+ const link = nav.querySelector('a[href="#sec-30"]') as HTMLAnchorElement;
155
+ await act(async () => {
156
+ link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
157
+ });
158
+ // The heading, not the list entry.
159
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
160
+ await spyTo('sec-30');
161
+ expect(scrollTo).not.toHaveBeenCalled();
162
+ });
163
+ });
@@ -0,0 +1,101 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { useHeadings, useActiveHeading } from '../hooks/useHeadings';
3
+ import { measure, scrollOffsetFor } from '../lib/tocScroll';
4
+
5
+ interface Props {
6
+ /** Re-scan when the rendered entry changes. */
7
+ entryKey?: string;
8
+ /** Heading above the list; `null`/`""` renders none. */
9
+ title?: string | null;
10
+ /** Extra class on the `<nav>` — how `<Toc>` keeps its rail styling. */
11
+ className?: string;
12
+ }
13
+
14
+ /** Breathing room kept between the active entry and the edge it is pulled away from. One
15
+ * row's worth: an entry flush against the fold reads as "the last one", and the margin is
16
+ * what tells the reader the list continues. */
17
+ const EDGE_MARGIN = 28;
18
+
19
+ /** How long a manual scroll of the list suppresses the automatic one. Without this the
20
+ * reader who scrolls the contents to look ahead is yanked back on the next heading the
21
+ * DOCUMENT passes — the list fighting the hand that moved it. */
22
+ const MANUAL_SCROLL_GRACE_MS = 2000;
23
+
24
+ /**
25
+ * `<TableOfContents/>` — the on-this-page list, which **keeps the current entry in view**.
26
+ *
27
+ * A long entry has a long contents list, and a list taller than its rail scrolls. Without
28
+ * this the reader scrolls into §12 and the contents still shows §1–§9 with nothing
29
+ * highlighted: the one surface whose whole job is "where am I" stops answering exactly
30
+ * when the document is long enough to need it.
31
+ *
32
+ * Three things it deliberately does NOT do:
33
+ *
34
+ * - **It never scrolls the page.** `scrollIntoView()` walks every scrollable ancestor, so
35
+ * the obvious implementation moves the document too — the reader passes a heading and
36
+ * the article jumps. This assigns `scrollTop` on its OWN container and nothing else can
37
+ * move (`lib/tocScroll`).
38
+ * - **It does not scroll when the entry is already visible.** Otherwise the list twitches
39
+ * on every heading the reader passes.
40
+ * - **It does not fight the reader.** A manual scroll of the list suspends the automatic
41
+ * one briefly.
42
+ */
43
+ export default function TableOfContents({ entryKey, title = 'On this page', className }: Props) {
44
+ const heads = useHeadings(entryKey);
45
+ const cur = useActiveHeading(heads);
46
+ const listRef = useRef<HTMLElement | null>(null);
47
+ const itemsRef = useRef(new Map<string, HTMLAnchorElement>());
48
+ const manualUntilRef = useRef(0);
49
+
50
+ const noteManualScroll = useCallback(() => {
51
+ manualUntilRef.current = Date.now() + MANUAL_SCROLL_GRACE_MS;
52
+ }, []);
53
+
54
+ useEffect(() => {
55
+ const list = listRef.current;
56
+ const item = cur ? itemsRef.current.get(cur) : undefined;
57
+ if (!list || !item) return;
58
+ if (Date.now() < manualUntilRef.current) return;
59
+ const offset = scrollOffsetFor(...measure(list, item), { margin: EDGE_MARGIN });
60
+ if (offset === null) return; // already in view — "when necessary"
61
+ // `scroll()` on the element, never `scrollIntoView()` on the item: only this container
62
+ // may move. Motion is opt-out — a reader who asked the OS for less of it gets a jump.
63
+ const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
64
+ list.scrollTo({ top: offset, behavior: reduced ? 'auto' : 'smooth' });
65
+ }, [cur]);
66
+
67
+ if (!heads.length) return null;
68
+
69
+ return (
70
+ <nav
71
+ ref={listRef}
72
+ className={['grove-toc', className].filter(Boolean).join(' ')}
73
+ aria-label={title || 'On this page'}
74
+ onWheel={noteManualScroll}
75
+ onTouchMove={noteManualScroll}
76
+ >
77
+ {title ? <div className="grove-toc__h">{title}</div> : null}
78
+ {heads.map((h) => (
79
+ <a
80
+ key={h.id}
81
+ ref={(el) => {
82
+ if (el) itemsRef.current.set(h.id, el);
83
+ else itemsRef.current.delete(h.id);
84
+ }}
85
+ href={`#${h.id}`}
86
+ data-cur={cur === h.id ? '1' : '0'}
87
+ aria-current={cur === h.id ? 'location' : undefined}
88
+ className={h.level === 3 ? 'lvl3' : undefined}
89
+ onClick={(e) => {
90
+ e.preventDefault();
91
+ // Clicking IS the reader moving the list, so don't also auto-scroll it.
92
+ noteManualScroll();
93
+ document.getElementById(h.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
94
+ }}
95
+ >
96
+ {h.text}
97
+ </a>
98
+ ))}
99
+ </nav>
100
+ );
101
+ }
@@ -0,0 +1,46 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback } from 'react';
3
+ import { useMetadataQuery } from '@immediately-run/sdk';
4
+ import { contentDir } from '../lib/content';
5
+ import { queryPaths } from '../lib/wiki';
6
+
7
+ // Import-free engine component: every tag across the site, sized by frequency.
8
+ // Chrome tags (`ui/*`) are excluded — they drive layout, not classification.
9
+ export default function TagCloud() {
10
+ const queryFn = useCallback((filesMetadata: Record<string, any>) => {
11
+ const counts: Record<string, number> = {};
12
+ Object.entries(filesMetadata).forEach(([p, m]) => {
13
+ if (!p.startsWith(contentDir())) return;
14
+ if (m && Array.isArray(m.tags)) {
15
+ m.tags.forEach((t: string) => {
16
+ if (t.startsWith('ui/')) return;
17
+ counts[t] = (counts[t] || 0) + 1;
18
+ });
19
+ }
20
+ });
21
+ // Encode counts into the string[] the query contract returns.
22
+ return Object.keys(counts)
23
+ .sort()
24
+ .map((t) => `${t}:${counts[t]}`);
25
+ }, []);
26
+
27
+ const result = useMetadataQuery(queryFn);
28
+ const entries: string[] = queryPaths(result);
29
+
30
+ return (
31
+ <div className="grove-tagcloud">
32
+ {entries.map((e) => {
33
+ const [tag, count] = e.split(':');
34
+ return (
35
+ <span
36
+ key={tag}
37
+ className="grove-tag"
38
+ style={{ fontSize: 11 + Number(count) * 2 + 'px', padding: '4px 12px' }}
39
+ >
40
+ #{tag} <span style={{ opacity: 0.5, marginLeft: 4 }}>{count}</span>
41
+ </span>
42
+ );
43
+ })}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,31 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback } from 'react';
3
+ import { useMetadataQuery } from '@immediately-run/sdk';
4
+ import { contentDir } from '../lib/content';
5
+ import { queryPaths } from '../lib/wiki';
6
+
7
+ // `<TagList/>` — every content tag, sorted, as plain chips (the "related tags"
8
+ // strip on a tag page). Reserved `ui/*` chrome tags are excluded.
9
+ export default function TagList() {
10
+ const queryFn = useCallback((fm: Record<string, any>) => {
11
+ const set = new Set<string>();
12
+ Object.entries(fm).forEach(([p, m]: [string, any]) => {
13
+ if (!p.startsWith(contentDir())) return;
14
+ (Array.isArray(m?.tags) ? m.tags : []).forEach((t: string) => {
15
+ if (!t.startsWith('ui/')) set.add(t);
16
+ });
17
+ });
18
+ return Array.from(set).sort();
19
+ }, []);
20
+ const q = useMetadataQuery(queryFn);
21
+ const tags: string[] = queryPaths(q);
22
+
23
+ if (!tags.length) return null;
24
+ return (
25
+ <div className="grove-tagcloud">
26
+ {tags.map((t) => (
27
+ <span key={t} className="grove-tag">#{t}</span>
28
+ ))}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,55 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useCallback } from 'react';
3
+ import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
4
+ import { isContentEntry, keyToHref } from '../lib/content';
5
+ import { queryPaths } from '../lib/wiki';
6
+
7
+ // One dated entry on the axis: mono date · node · card.
8
+ function Row({ path }: { path: string }) {
9
+ const m = useFileMetadata(path) as any;
10
+ if (!m) return null;
11
+ const tags: string[] = Array.isArray(m.tags) ? m.tags.filter((t: string) => !t.startsWith('ui/')) : [];
12
+ return (
13
+ <Link href={keyToHref(path)} className="gtl-row">
14
+ <div className="gtl-date">{m.date}</div>
15
+ <div className="gtl-node" />
16
+ <div className="gtl-card">
17
+ <div className="gtl-title">{(m.title || path).replace(/\.$/, '')}</div>
18
+ {m.description && <div className="gtl-desc">{m.description}</div>}
19
+ {tags.length ? (
20
+ <div className="gtl-tags">
21
+ {tags.slice(0, 3).map((t) => (
22
+ <span key={t} className="grove-tag">#{t}</span>
23
+ ))}
24
+ </div>
25
+ ) : null}
26
+ </div>
27
+ </Link>
28
+ );
29
+ }
30
+
31
+ // `<Timeline/>` — an agent-added view: every entry with a `date`, laid on a time
32
+ // axis newest-first, with a restrained per-row reveal (CSS `gtlin`).
33
+ export default function Timeline() {
34
+ const queryFn = useCallback(
35
+ (fm: Record<string, any>) =>
36
+ Object.keys(fm)
37
+ .filter((p) => isContentEntry(p) && fm[p]?.date && !fm[p]?.view)
38
+ .sort((a, b) => String(fm[b].date).localeCompare(String(fm[a].date))),
39
+ []
40
+ );
41
+ const q = useMetadataQuery(queryFn);
42
+ const loaded = Array.isArray(q);
43
+ const paths: string[] = queryPaths(q);
44
+
45
+ if (loaded && !paths.length) {
46
+ return <p className="grove-search__empty">No dated entries yet.</p>;
47
+ }
48
+ return (
49
+ <div className="grove-timeline">
50
+ {paths.map((p) => (
51
+ <Row key={p} path={p} />
52
+ ))}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,14 @@
1
+ import TableOfContents from './TableOfContents';
2
+
3
+ // `<Toc/>` — the on-this-page rail, and the name every existing layout and entry uses.
4
+ //
5
+ // It is now an alias for `<TableOfContents/>`, which is the same list plus "keep the
6
+ // current entry in view". Kept as its own name rather than rewritten at the call sites
7
+ // because `Toc` is registered content vocabulary: a corpus may already say `<Toc/>`, and
8
+ // the engine's names are an interface (spec_style's §-number rule, applied to components).
9
+ //
10
+ // Deliberately NOT a second implementation. The two used to be one copy of the DOM scan
11
+ // each, which is precisely the drift ENGINE_BOUNDARY §4 was written about.
12
+ export default function Toc({ entryKey }: { entryKey?: string }) {
13
+ return <TableOfContents entryKey={entryKey} />;
14
+ }