@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.
- package/README.md +43 -115
- package/llms.txt +5 -2
- package/package.json +10 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +484 -152
- package/src/GroveWiki.tsx +105 -30
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +23 -12
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -150
- package/src/components/GroveNav.test.tsx +145 -5
- package/src/components/GroveNav.tsx +51 -8
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +18 -3
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +16 -7
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +17 -4
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +7 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +2 -2
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- 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"
|
|
28
|
-
{meta.description && <div className="gdl-row__d"
|
|
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 —
|
|
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"
|
|
49
|
-
{meta.description && <div className="gdl-card__d"
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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' : ''}>
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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>
|