@immediately-run/grove 0.1.2 → 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 (77) hide show
  1. package/README.md +43 -115
  2. package/llms.txt +5 -2
  3. package/package.json +10 -8
  4. package/src/App.tsx +11 -7
  5. package/src/GroveApp.css +484 -152
  6. package/src/GroveWiki.tsx +105 -30
  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 +23 -12
  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 -150
  21. package/src/components/GroveNav.test.tsx +145 -5
  22. package/src/components/GroveNav.tsx +51 -8
  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 +18 -3
  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 +16 -7
  40. package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
  41. package/src/hooks/useContentComponents.ts +1 -1
  42. package/src/hooks/useEditAffordance.ts +17 -4
  43. package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
  44. package/src/hooks/useOverlayFocusDismiss.ts +118 -0
  45. package/src/hooks/useScrollReset.test.tsx +132 -0
  46. package/src/hooks/useScrollReset.ts +52 -0
  47. package/src/index.css +9 -2
  48. package/src/lib/agentPrompt.test.ts +96 -0
  49. package/src/lib/agentPrompt.ts +86 -0
  50. package/src/lib/agentTools.test.ts +115 -0
  51. package/src/lib/agentTools.ts +132 -0
  52. package/src/lib/agentTranscript.ts +50 -0
  53. package/src/lib/assetPath.test.ts +36 -0
  54. package/src/lib/assetPath.ts +42 -0
  55. package/src/lib/collectionCalls.test.ts +69 -0
  56. package/src/lib/content.test.ts +10 -2
  57. package/src/lib/content.ts +7 -0
  58. package/src/lib/contentStylesheet.test.ts +80 -0
  59. package/src/lib/contentStylesheet.ts +103 -0
  60. package/src/lib/corpusScan.test.ts +37 -3
  61. package/src/lib/corpusScan.ts +17 -2
  62. package/src/lib/inlineProse.parity.test.ts +52 -0
  63. package/src/lib/layout.ts +29 -0
  64. package/src/lib/pageVariants.test.tsx +87 -0
  65. package/src/lib/queries.test.ts +33 -1
  66. package/src/lib/queries.ts +33 -1
  67. package/src/lib/reachCard.test.ts +94 -0
  68. package/src/lib/reachCard.ts +112 -0
  69. package/src/lib/shell.ts +7 -0
  70. package/src/lib/starterSweep.test.tsx +160 -0
  71. package/src/lib/starterSweep.ts +97 -0
  72. package/src/lib/themeAssets.test.ts +135 -0
  73. package/src/lib/themeAssets.ts +143 -0
  74. package/src/lib/themeSelection.test.ts +2 -2
  75. package/src/mdxComponents.ts +4 -0
  76. package/viewer-manifest.schema.json +37 -0
  77. package/viewer.manifest.json +132 -6
@@ -0,0 +1,111 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { gateStylesheet } from '../lib/contentStylesheet';
3
+ import { mintThemeAssets, type MintedThemeAssets } from '../lib/themeAssets';
4
+ import { openFs } from '@immediately-run/sdk';
5
+
6
+ // A bundle's own look, carried as content (R3-316): each `ui/stylesheet` entry's
7
+ // body is gated by the grammar (declarations only — a selector, at-rule, `url(`,
8
+ // `@import` or `@font-face` is rejected with the line named, never silently
9
+ // dropped) and admitted ONLY into the lowest cascade layer (`grove.content`),
10
+ // where it can style tokens and nothing else. The entry's frontmatter may
11
+ // declare `fonts:`/`assets:` — minted by the engine exactly as an engine theme's
12
+ // are, so a content theme can change the reading face without naming a location.
13
+ //
14
+ // Gating is PURE and runs in render (a rejected sheet contributes nothing that
15
+ // frame); only the byte-reading mint is effectful.
16
+ //
17
+ // THE ESCAPE HATCH: the theme control (`.grove-theme-control`) is pinned by
18
+ // protective declarations in `grove.reset` — the FIRST layer, which for
19
+ // !important beats every later layer including `grove.content` — so a planted
20
+ // stylesheet cannot hide the way back to a shipped theme. Proven by test, not by
21
+ // reading the layer declaration.
22
+
23
+ const ROOT_MOUNT = { path: '/', type: 'repo' } as const;
24
+
25
+ export interface ContentStylesheet {
26
+ /** The entry's absolute fs path (the declaring file for its asset refs). */
27
+ path: string;
28
+ /** The raw body bytes (CSS). */
29
+ css: string;
30
+ /** The entry's declared fonts/assets, if any. */
31
+ declarations: { fonts?: unknown; assets?: unknown };
32
+ }
33
+
34
+ interface Props {
35
+ sheets: ContentStylesheet[];
36
+ /** Where a rejected sheet's verdict goes (the reader-facing degrade). */
37
+ onRejected?: (path: string, verdict: { line: number; reason: string; excerpt: string }) => void;
38
+ }
39
+
40
+ export default function ContentTheme({ sheets, onRejected }: Props) {
41
+ // Gate per render — pure. A sheet with declarations rides; a sheet with a
42
+ // verdict rides its verdict to the caller instead.
43
+ const results = useMemo(
44
+ () => sheets.map((sheet) => ({ sheet, verdict: gateStylesheet(sheet.css) })),
45
+ [sheets],
46
+ );
47
+ const accepted = results.filter((r) => r.verdict.ok);
48
+ const rejected = results.filter((r) => !r.verdict.ok);
49
+ useEffect(() => {
50
+ for (const { sheet, verdict } of rejected) {
51
+ if (verdict.ok) continue;
52
+ onRejected?.(sheet.path, verdict);
53
+ }
54
+ }, [rejected, onRejected]);
55
+
56
+ // Declared faces mint through the engine path; the revoke scope is this
57
+ // sheet set's lifetime.
58
+ const [assetCss, setAssetCss] = useState<string>('');
59
+ const mintedRef = useRef<MintedThemeAssets | null>(null);
60
+ const mintKey = accepted.map((a) => a.sheet.path).join('|');
61
+ useEffect(() => {
62
+ let alive = true;
63
+ const sheetsWithAssets = accepted.filter((r) => {
64
+ const d = r.sheet.declarations;
65
+ return (Array.isArray(d.fonts) && d.fonts.length) || (d.assets && Object.keys(d.assets as object).length);
66
+ });
67
+ if (!sheetsWithAssets.length) {
68
+ mintedRef.current?.revoke();
69
+ mintedRef.current = null;
70
+ return;
71
+ }
72
+ const fsApi = openFs(ROOT_MOUNT);
73
+ const decl = accepted.length ? sheetsWithAssets[0].sheet.declarations : {};
74
+ void mintThemeAssets(
75
+ decl as never,
76
+ sheetsWithAssets[0].sheet.path,
77
+ async (p) => {
78
+ const bytes = await fsApi.readFile(p.replace(/^\/+/, ''));
79
+ return typeof bytes === 'string' ? new TextEncoder().encode(bytes) : new Uint8Array(bytes);
80
+ },
81
+ ).then((m) => {
82
+ if (!alive) {
83
+ m.revoke();
84
+ return;
85
+ }
86
+ mintedRef.current?.revoke();
87
+ mintedRef.current = m;
88
+ setAssetCss([m.fontFaceCss, m.assetVarsCss].filter(Boolean).join('\n'));
89
+ });
90
+ return () => {
91
+ alive = false;
92
+ };
93
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mintKey is the sheet-set identity
94
+ }, [mintKey]);
95
+
96
+ useEffect(
97
+ () => () => {
98
+ mintedRef.current?.revoke();
99
+ },
100
+ [],
101
+ );
102
+
103
+ if (!accepted.length && !assetCss) return null;
104
+ const decls = accepted.map((a) => (a.verdict.ok ? a.verdict.declarations : '')).filter(Boolean).join('\n');
105
+ return (
106
+ <>
107
+ {assetCss ? <style data-grove-content-assets="">{assetCss}</style> : null}
108
+ {decls ? <style data-grove-content-theme="">{`@layer grove.content{.grove-root{\n${decls}\n}}`}</style> : null}
109
+ </>
110
+ );
111
+ }
@@ -0,0 +1,177 @@
1
+ // @vitest-environment jsdom
2
+ // The windowed feed (R3-314) — every adversarial exit criterion, driven through
3
+ // the real component over a 142-entry fixture:
4
+ // one batch renders, then extends on scroll, append-only (no boundary shift);
5
+ // the sentinel is a button — reachable and actionable by keyboard alone;
6
+ // the end states itself ("that is all N entries"), never merely stops;
7
+ // no IntersectionObserver → the full list renders (proven by removing it).
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import { act } from 'react';
10
+ import { createRoot, type Root } from 'react-dom/client';
11
+ import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
12
+ import type { GroveShell } from '../lib/shell';
13
+ import { GroveShellContext } from '../lib/shell';
14
+ import { resetContentRoot, setContentRoot } from '../lib/contentRoot';
15
+
16
+ const { default: DocList } = await import('./DocList');
17
+
18
+ // ── the fixture: 142 dated entries, frontmatter-shaped exactly as the query reads ──
19
+ const N = 142;
20
+ const METADATA: Record<string, Record<string, unknown>> = {};
21
+ for (let i = 1; i <= N; i++) {
22
+ const day = String((i % 28) + 1).padStart(2, '0');
23
+ // Entry 1 carries a frontmatter code span in title AND description (R3-531):
24
+ // the field is markdown, and the row must render it as such.
25
+ const title = i === 1 ? 'Entry 1 — `cache.yml` edition.' : `Entry ${i}.`;
26
+ const description = i === 1 ? 'The 1st fixture entry, with a `code` span.' : `The ${i}th fixture entry.`;
27
+ METADATA[`/app/content/log/entry-${String(i).padStart(3, '0')}.mdx`] = {
28
+ title,
29
+ description,
30
+ date: `2026-01-${day}`,
31
+ };
32
+ }
33
+
34
+ const NAV = {
35
+ outerHref: 'https://example.immediately.run/app/x',
36
+ navigationState: { sandboxPath: '/app/x' },
37
+ filesMetadata: METADATA,
38
+ };
39
+ const SHELL = { navMode: 'side', vw: 'desktop', navItems: [] } as unknown as GroveShell;
40
+
41
+ // ── a controllable IntersectionObserver stand-in (the useHeadings pattern,
42
+ // driven from the test instead of by a real scroll) ──────────────────────────
43
+ let observerInstances: ControllableObserver[] = [];
44
+ class ControllableObserver {
45
+ cb: IntersectionObserverCallback;
46
+ targets: Element[] = [];
47
+ constructor(cb: IntersectionObserverCallback) {
48
+ this.cb = cb;
49
+ observerInstances.push(this);
50
+ }
51
+ observe(t: Element) {
52
+ this.targets.push(t);
53
+ }
54
+ disconnect() {
55
+ this.targets = [];
56
+ }
57
+ unobserve() {
58
+ /* unused */
59
+ }
60
+ /** The browser's part: "the sentinel is now intersecting". */
61
+ intersect() {
62
+ for (const t of this.targets) this.cb([{ isIntersecting: true, target: t } as IntersectionObserverEntry], this as never);
63
+ }
64
+ }
65
+
66
+ const rowsOf = (host: HTMLElement): string[] =>
67
+ [...host.querySelectorAll('.gdl-row__t')].map((e) => e.textContent?.trim() ?? '');
68
+
69
+ const mount = async (props: Record<string, unknown> = {}): Promise<{ host: HTMLElement; root: Root }> => {
70
+ const host = document.createElement('div');
71
+ document.body.appendChild(host);
72
+ const root = createRoot(host);
73
+ await act(async () => {
74
+ root.render(
75
+ <TinkerableContext.Provider value={NAV as never}>
76
+ <GroveShellContext.Provider value={SHELL}>
77
+ <DocList paginate="infinite" batch="20" {...props} />
78
+ </GroveShellContext.Provider>
79
+ </TinkerableContext.Provider>,
80
+ );
81
+ });
82
+ return { host, root };
83
+ };
84
+
85
+ describe('the windowed feed (R3-314)', () => {
86
+ beforeEach(() => {
87
+ setContentRoot('/app/content/');
88
+ observerInstances = [];
89
+ (window as unknown as Record<string, unknown>).IntersectionObserver = ControllableObserver as never;
90
+ });
91
+ afterEach(() => {
92
+ delete (window as unknown as Record<string, unknown>).IntersectionObserver;
93
+ resetContentRoot();
94
+ document.body.innerHTML = '';
95
+ });
96
+
97
+ it('renders ONE batch, not all 142, with the sentinel present', async () => {
98
+ const { host } = await mount();
99
+ expect(rowsOf(host)).toHaveLength(20);
100
+ expect(host.querySelector('.gdl-sentinel')).not.toBeNull();
101
+ expect(host.querySelector('.gdl-end')).toBeNull();
102
+ });
103
+
104
+ it('extends on intersect — append-only, so no boundary shift', async () => {
105
+ // "No layout shift, asserted, not eyeballed": the first batch's rows are
106
+ // still mounted, in the same order, and the list only ever APPENDS. Rows
107
+ // re-created (keys changed, order shuffled) is how a windowed list shifts.
108
+ const { host } = await mount();
109
+ const before = rowsOf(host);
110
+ const firstNodes = [...host.querySelectorAll('.gdl-row__t')];
111
+ expect(observerInstances.length).toBeGreaterThan(0);
112
+ await act(async () => {
113
+ observerInstances[0]!.intersect();
114
+ });
115
+ const after = rowsOf(host);
116
+ expect(after).toHaveLength(40);
117
+ expect(after.slice(0, 20)).toEqual(before);
118
+ const stillMounted = [...host.querySelectorAll('.gdl-row__t')].slice(0, 20);
119
+ expect(stillMounted.every((el, i) => el === firstNodes[i])).toBe(true);
120
+ });
121
+
122
+ it('the sentinel is a BUTTON — actionable by keyboard alone, to the very end', async () => {
123
+ const { host } = await mount();
124
+ const sentinel = host.querySelector<HTMLButtonElement>('.gdl-sentinel');
125
+ expect(sentinel?.tagName).toBe('BUTTON'); // focusable, Enter/Space actionable
126
+ let rounds = 0;
127
+ while (host.querySelector('.gdl-sentinel') && rounds < 20) {
128
+ await act(async () => {
129
+ host.querySelector<HTMLButtonElement>('.gdl-sentinel')?.click();
130
+ });
131
+ rounds++;
132
+ }
133
+ expect(rowsOf(host)).toHaveLength(N); // a keyboard-only user reaches the end
134
+ expect(host.querySelector('.gdl-sentinel')).toBeNull();
135
+ expect(host.querySelector('.gdl-end')?.textContent).toContain(`all ${N} entries`);
136
+ });
137
+
138
+ it('the end states itself — the list never merely stops', async () => {
139
+ const { host } = await mount();
140
+ for (let i = 0; i < 20 && host.querySelector('.gdl-sentinel'); i++) {
141
+ await act(async () => {
142
+ observerInstances[0]?.intersect();
143
+ });
144
+ }
145
+ const end = host.querySelector('.gdl-end');
146
+ expect(end?.getAttribute('role')).toBe('status');
147
+ expect(end?.textContent).toMatch(/^That is all 142 entries\.$/);
148
+ });
149
+
150
+ it('with no IntersectionObserver at all, the FULL list renders — proven by removing it', async () => {
151
+ delete (window as unknown as Record<string, unknown>).IntersectionObserver;
152
+ const { host } = await mount();
153
+ expect(rowsOf(host)).toHaveLength(N);
154
+ expect(host.querySelector('.gdl-sentinel')).toBeNull();
155
+ expect(host.querySelector('.gdl-end')).toBeNull(); // nothing was windowed
156
+ });
157
+
158
+ it('a frontmatter code span renders as <code>, never as a literal backtick (R3-531)', async () => {
159
+ // sort:'title' puts entry 1 — the code-span carrier — in the first batch.
160
+ const { host } = await mount({ sort: 'title' });
161
+ expect(host.querySelector('.gdl-row__t code')?.textContent).toBe('cache.yml');
162
+ expect(host.querySelector('.gdl-row__d code')?.textContent).toBe('code');
163
+ expect(host.textContent).not.toContain('`');
164
+ });
165
+
166
+ it('limit still caps the query — a pinned list is never windowed past its pin', async () => {
167
+ const { host } = await mount({ limit: '35' });
168
+ expect(rowsOf(host)).toHaveLength(20);
169
+ for (let i = 0; i < 3 && host.querySelector('.gdl-sentinel'); i++) {
170
+ await act(async () => {
171
+ observerInstances[0]?.intersect();
172
+ });
173
+ }
174
+ expect(rowsOf(host)).toHaveLength(35);
175
+ expect(host.querySelector('.gdl-end')?.textContent).toContain('all 35 entries');
176
+ });
177
+ });
@@ -1,8 +1,10 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useCallback } from 'react';
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
4
4
  import { isContentEntry, keyToHref, slugToKey } from '../lib/content';
5
+ import InlineProse from './InlineProse';
5
6
  import { queryPaths } from '../lib/wiki';
7
+ import EntryImage from './EntryImage';
6
8
 
7
9
  interface Props {
8
10
  shape?: 'feed' | 'grid';
@@ -11,6 +13,13 @@ interface Props {
11
13
  tag?: string;
12
14
  limit?: string | number;
13
15
  sort?: 'date' | 'title';
16
+ /** "infinite" — a windowed feed that grows on scroll (R3-314). A LITERAL
17
+ * attribute: the safe renderer copies `paginate="infinite"` verbatim but drops
18
+ * `paginate={mode}` silently, and the list then renders whole with nothing
19
+ * saying why. */
20
+ paginate?: string;
21
+ /** How many entries a windowed batch adds (literal string or number). */
22
+ batch?: string | number;
14
23
  }
15
24
 
16
25
  function tagsOf(meta: any): string[] {
@@ -24,8 +33,8 @@ function Row({ path }: { path: string }) {
24
33
  return (
25
34
  <Link href={keyToHref(path)} className="gdl-row">
26
35
  <div>
27
- <div className="gdl-row__t">{(meta.title || path).replace(/\.$/, '')}</div>
28
- {meta.description && <div className="gdl-row__d">{meta.description}</div>}
36
+ <div className="gdl-row__t"><InlineProse text={meta.title || path} trimPeriod /></div>
37
+ {meta.description && <div className="gdl-row__d"><InlineProse text={meta.description} /></div>}
29
38
  <div className="gdl-row__tags">
30
39
  {tagsOf(meta).slice(0, 3).map((t) => (
31
40
  <span key={t} className="grove-tag">#{t}</span>
@@ -37,16 +46,21 @@ function Row({ path }: { path: string }) {
37
46
  );
38
47
  }
39
48
 
40
- // One entry as a grid card — link-graph placeholder pic + a footer of meta.
49
+ // One entry as a grid card — the entry's `cover:` when it declares one (resolved
50
+ // against the OWNING entry, R3-313 — a card must show its own picture, not the
51
+ // rendered entry's), the link-graph lattice when it does not or the asset is
52
+ // unreadable. Degrade, never break.
41
53
  function CardTile({ path }: { path: string }) {
42
54
  const meta = useFileMetadata(path) as any;
43
55
  if (!meta) return <div className="gdl-card"><div className="gdl-card__pic" /></div>;
44
56
  return (
45
57
  <Link href={keyToHref(path)} className="gdl-card">
46
- <div className="gdl-card__pic" />
58
+ <div className="gdl-card__pic">
59
+ <EntryImage entryPath={path} src={meta.cover} alt="" className="gdl-card__cover" degrade={<span className="gdl-card__lattice" />} />
60
+ </div>
47
61
  <div className="gdl-card__foot">
48
- <div className="gdl-card__t">{(meta.title || path).replace(/\.$/, '')}</div>
49
- {meta.description && <div className="gdl-card__d">{meta.description}</div>}
62
+ <div className="gdl-card__t"><InlineProse text={meta.title || path} trimPeriod /></div>
63
+ {meta.description && <div className="gdl-card__d"><InlineProse text={meta.description} /></div>}
50
64
  <div className="gdl-card__tags">
51
65
  {tagsOf(meta).slice(0, 3).map((t) => (
52
66
  <span key={t} className="grove-tag">#{t}</span>
@@ -60,7 +74,26 @@ function CardTile({ path }: { path: string }) {
60
74
  // Import-free engine component: a frontmatter-driven index of entries, in a feed
61
75
  // or grid shape. `slugs` pins an explicit ordered set; otherwise it queries the
62
76
  // whole content space (optionally filtered by `tag`), excluding view/index pages.
63
- export default function DocList({ shape = 'feed', title, slugs, tag, limit, sort = 'date' }: Props) {
77
+ //
78
+ // `paginate="infinite"` (R3-314): a windowed feed with an IntersectionObserver
79
+ // sentinel — the repo's other observer (useHeadings, the contents rail) is the
80
+ // pattern, and this one reuses its shape rather than inventing machinery. Three
81
+ // states beyond "rows": a skeleton batch (rows render `.sk` until their metadata
82
+ // resolves, occupying the real box so nothing shifts), a sentinel that is a
83
+ // BUTTON — reachable and actionable by keyboard alone, not only by scrolling —
84
+ // and an explicit end ("that is all N entries") rather than a list that merely
85
+ // stops. No IntersectionObserver in the environment → the full list renders;
86
+ // that degradation is the honest fallback, not an error.
87
+ export default function DocList({
88
+ shape = 'feed',
89
+ title,
90
+ slugs,
91
+ tag,
92
+ limit,
93
+ sort = 'date',
94
+ paginate,
95
+ batch,
96
+ }: Props) {
64
97
  const queryFn = useCallback(
65
98
  (fm: Record<string, any>) => {
66
99
  const keys = Object.keys(fm).filter((p) => {
@@ -87,6 +120,32 @@ export default function DocList({ shape = 'feed', title, slugs, tag, limit, sort
87
120
  const n = limit ? Number(limit) : undefined;
88
121
  if (n && paths.length > n) paths = paths.slice(0, n);
89
122
 
123
+ // ── the windowed feed (R3-314) ─────────────────────────────────────────────
124
+ const windowed = paginate === 'infinite' && typeof IntersectionObserver !== 'undefined';
125
+ const batchSize = Math.max(1, Number(batch) || 20);
126
+ const [visible, setVisible] = useState(batchSize);
127
+ const sentinelRef = useRef<HTMLButtonElement | null>(null);
128
+ const more = windowed && visible < paths.length;
129
+ const shown = windowed ? paths.slice(0, visible) : paths;
130
+
131
+ useEffect(() => {
132
+ if (!more) return;
133
+ const el = sentinelRef.current;
134
+ if (!el) return;
135
+ // Load a little BEFORE the sentinel is fully reached, so a slow scroll never
136
+ // sees the end of the list — same observer shape as useHeadings' scroll-spy.
137
+ const obs = new IntersectionObserver(
138
+ (entries) => {
139
+ if (entries.some((e) => e.isIntersecting)) setVisible((v) => v + batchSize);
140
+ },
141
+ { rootMargin: '0px 0px 240px 0px', threshold: 0 }
142
+ );
143
+ obs.observe(el);
144
+ return () => obs.disconnect();
145
+ }, [more, batchSize]);
146
+
147
+ const loadMore = () => setVisible((v) => v + batchSize);
148
+
90
149
  return (
91
150
  <div className="grove-doclist-wrap">
92
151
  {title ? (
@@ -99,9 +158,23 @@ export default function DocList({ shape = 'feed', title, slugs, tag, limit, sort
99
158
  <p className="grove-search__empty">No entries yet.</p>
100
159
  ) : (
101
160
  <div className="grove-doclist" data-shape={shape}>
102
- {paths.map((p: string) =>
161
+ {shown.map((p: string) =>
103
162
  shape === 'grid' ? <CardTile key={p} path={p} /> : <Row key={p} path={p} />
104
163
  )}
164
+ {more ? (
165
+ <button
166
+ ref={sentinelRef}
167
+ className="gdl-sentinel"
168
+ onClick={loadMore}
169
+ aria-label={`Load the next ${Math.min(batchSize, paths.length - shown.length)} entries`}
170
+ >
171
+ <span className="sk sk-line" style={{ width: '42%' }} />
172
+ <span className="sk sk-line" style={{ width: '68%' }} />
173
+ </button>
174
+ ) : null}
175
+ {windowed && !more && paths.length > batchSize ? (
176
+ <p className="gdl-end" role="status">That is all {paths.length} entries.</p>
177
+ ) : null}
105
178
  </div>
106
179
  )}
107
180
  </div>
@@ -1,4 +1,5 @@
1
1
  import { Link } from '@immediately-run/sdk';
2
+ import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
2
3
  import Sidebar from './Sidebar';
3
4
  import Icon from './Icon';
4
5
 
@@ -9,7 +10,9 @@ interface NavItem {
9
10
  }
10
11
 
11
12
  // `.grove-drawer` — the mobile nav drawer: site mark + nav items + the full
12
- // namespace tree, over a dismiss scrim.
13
+ // namespace tree, over a dismiss scrim. The panel carries the dialog contract
14
+ // (R3-608): focus in, Tab trapped, Escape, focus return — via the shared
15
+ // overlay hook, whose stack makes nested overlays close one per Escape.
13
16
  export default function Drawer({
14
17
  siteTitle,
15
18
  nav,
@@ -19,9 +22,18 @@ export default function Drawer({
19
22
  nav: NavItem[];
20
23
  onClose: () => void;
21
24
  }) {
25
+ const dialogRef = useOverlayFocusDismiss(true, onClose);
22
26
  return (
23
27
  <div className="grove-drawer" onClick={onClose}>
24
- <div className="grove-drawer__panel" onClick={(e) => e.stopPropagation()}>
28
+ <div
29
+ className="grove-drawer__panel"
30
+ ref={dialogRef}
31
+ tabIndex={-1}
32
+ role="dialog"
33
+ aria-modal="true"
34
+ aria-label="Site menu"
35
+ onClick={(e) => e.stopPropagation()}
36
+ >
25
37
  <div className="grove-drawer__h">
26
38
  <span className="mk" />
27
39
  <span className="t">{siteTitle}</span>
@@ -1,8 +1,9 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { useFileMetadata } from '@immediately-run/sdk';
3
- import { useShell } from '../lib/shell';
3
+ import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
4
4
  import { crumb } from '../lib/wiki';
5
5
  import Icon from './Icon';
6
+ import InlineProse from './InlineProse';
6
7
 
7
8
  // The entry's header block: breadcrumb, title (optional gradient), meta row
8
9
  // (date · reading time · tags) and the unobtrusive edit affordance. Rendered by
@@ -16,7 +17,7 @@ export default function EntryHeader({
16
17
  writable: boolean;
17
18
  mins: number;
18
19
  }) {
19
- const { openEditor, editBusy, editHint } = useShell();
20
+ const { openEditor, editBusy, editRefused, editHint } = useShell();
20
21
  const meta = useFileMetadata(entryKey) as any;
21
22
  if (!meta) return null;
22
23
  const tags: string[] = Array.isArray(meta.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];
@@ -24,7 +25,10 @@ export default function EntryHeader({
24
25
  return (
25
26
  <header className="grove-entry-header">
26
27
  {cr.includes('/') ? <nav className="crumb">{cr}</nav> : null}
27
- <h1 className={meta.grad ? 'grad' : ''}>{meta.title || cr}</h1>
28
+ <h1 className={meta.grad ? 'grad' : ''}>
29
+ {/* R3-531 — the title is frontmatter prose: code spans render as code. */}
30
+ <InlineProse text={meta.title || cr} />
31
+ </h1>
28
32
  <div className="grove-meta">
29
33
  {meta.date && <span>{meta.date}</span>}
30
34
  {mins ? <span>→ {mins} min read</span> : null}
@@ -33,15 +37,22 @@ export default function EntryHeader({
33
37
  <span key={t} className="grove-tag">#{t}</span>
34
38
  ))}
35
39
  {writable && (
36
- <button
37
- className="grove-edit-affordance"
38
- data-busy={editBusy ? '1' : '0'}
39
- title={editHint}
40
- onClick={() => openEditor(entryKey)}
41
- >
42
- <Icon name="pencil" />
43
- {editBusy ? 'Opening editor…' : 'Edit'}
44
- </button>
40
+ <>
41
+ <button
42
+ className="grove-edit-affordance"
43
+ data-busy={editBusy ? '1' : '0'}
44
+ title={editHint}
45
+ onClick={() => openEditor(entryKey)}
46
+ >
47
+ <Icon name="pencil" />
48
+ {editBusy ? 'Opening editor…' : 'Edit'}
49
+ </button>
50
+ {editRefused && (
51
+ <span className="grove-edit-refused" role="status">
52
+ {EDIT_REFUSED_NOTICE}
53
+ </span>
54
+ )}
55
+ </>
45
56
  )}
46
57
  </div>
47
58
  </header>