@immediately-run/grove 0.1.2 → 0.1.4
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 +12 -10
- 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 +67 -0
- package/viewer.manifest.json +132 -6
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { mintThemeAssets, type MintedThemeAssets, type ThemeAssetDeclarations } from '../lib/themeAssets';
|
|
3
|
+
import { openFs } from '@immediately-run/sdk';
|
|
4
|
+
|
|
5
|
+
// The engine-owned emission of a declaration set's minted assets (R3-315): one
|
|
6
|
+
// `<style data-grove-theme-assets>` carrying the `@font-face` rules inside the
|
|
7
|
+
// ENGINE cascade layer and the `--asset-*` assignments on `.grove-root`. The
|
|
8
|
+
// revoke scope is the declaration set's lifetime — switching themes (or a reader
|
|
9
|
+
// override re-resolving the set) revokes the outgoing URLs before the incoming
|
|
10
|
+
// ones mint, so repeated switches leak nothing.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here is content-styleable: the layer order in `index.css` namespaces
|
|
13
|
+
// these rules as `grove.engine`, below the reset but above theme and content.
|
|
14
|
+
|
|
15
|
+
/** The declaring-file anchor for the engine's own default set — the app repo root
|
|
16
|
+
* (fork AND dispatch load the engine from this repo; `/app` is its own tree). */
|
|
17
|
+
const ENGINE_DECLARING_FILE = '/app/src/index.css';
|
|
18
|
+
|
|
19
|
+
// The whole sandbox fs, `/`-rooted — the same anchor `AssetImage`/`EntryImage` use.
|
|
20
|
+
const ROOT_MOUNT = { path: '/', type: 'repo' } as const;
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
declarations: ThemeAssetDeclarations;
|
|
24
|
+
/** Absolute fs path of the file the declarations live in (resolution base). */
|
|
25
|
+
basePath?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function ThemeAssets({ declarations, basePath = ENGINE_DECLARING_FILE }: Props) {
|
|
29
|
+
const [minted, setMinted] = useState<MintedThemeAssets | null>(null);
|
|
30
|
+
// The CURRENT set, reached from the cleanup — a setState updater does NOT run
|
|
31
|
+
// on unmount (React 18+), so reaching the outgoing URLs only through state
|
|
32
|
+
// would leak them exactly there.
|
|
33
|
+
const mintedRef = useRef<MintedThemeAssets | null>(null);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let alive = true;
|
|
37
|
+
// Read through the SDK's fs surface (the same one `MountImage` reads
|
|
38
|
+
// through): it resolves the sandbox's injected shared fs in every stance,
|
|
39
|
+
// where a bare `import fs` would only work where the bundler shims it.
|
|
40
|
+
const rootFs = openFs(ROOT_MOUNT);
|
|
41
|
+
void mintThemeAssets(declarations, basePath, async (p) => {
|
|
42
|
+
const bytes = await rootFs.readFile(p.replace(/^\/+/, ''));
|
|
43
|
+
return typeof bytes === 'string' ? new TextEncoder().encode(bytes) : new Uint8Array(bytes);
|
|
44
|
+
}).then((m) => {
|
|
45
|
+
if (!alive) {
|
|
46
|
+
m.revoke(); // the set changed/unmounted mid-mint — never leak the late arrival
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
mintedRef.current = m;
|
|
50
|
+
setMinted(m);
|
|
51
|
+
});
|
|
52
|
+
return () => {
|
|
53
|
+
alive = false;
|
|
54
|
+
// Revoke the OUTGOING set on switch/unmount — the theme's lifetime ends here.
|
|
55
|
+
mintedRef.current?.revoke();
|
|
56
|
+
mintedRef.current = null;
|
|
57
|
+
setMinted(null);
|
|
58
|
+
};
|
|
59
|
+
}, [declarations, basePath]);
|
|
60
|
+
|
|
61
|
+
if (!minted || (!minted.fontFaceCss && !minted.assetVarsCss)) return null;
|
|
62
|
+
return <style data-grove-theme-assets="">{`@layer grove.engine{${minted.fontFaceCss}}\n${minted.assetVarsCss}`}</style>;
|
|
63
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { manifestThemes } from '../data/catalogue';
|
|
2
|
+
import { THEMES } from '../data/themes';
|
|
3
|
+
|
|
4
|
+
// The themes gallery (R3-311): one row per SHIPPED theme, enumerated from
|
|
5
|
+
// viewer.manifest.json — adding a theme to the manifest adds a row here with no
|
|
6
|
+
// edit to the entry. The swatch is the live gradient each catalogue entry
|
|
7
|
+
// declares (data/themes.ts), rendered with the theme's OWN tokens applied to the
|
|
8
|
+
// swatch chip, so the comparison is the real thing, not a screenshot of it.
|
|
9
|
+
|
|
10
|
+
export default function ThemeGallery() {
|
|
11
|
+
const rows = manifestThemes();
|
|
12
|
+
return (
|
|
13
|
+
<div className="gg-gallery" role="list" aria-label="The shipped themes">
|
|
14
|
+
{rows.map(([id, t]) => {
|
|
15
|
+
const swatch = THEMES.find((x) => x.id === id)?.swatch ?? 'linear-gradient(96deg,#888,#555)';
|
|
16
|
+
return (
|
|
17
|
+
<div className="gg-row" role="listitem" key={id} data-gallery-id={id}>
|
|
18
|
+
<span className="gg-swatch" style={{ background: swatch }} aria-hidden />
|
|
19
|
+
<div className="gg-body">
|
|
20
|
+
<div className="gg-title">
|
|
21
|
+
<code>{id}</code> — {t.label}
|
|
22
|
+
<span className="gg-meta">opens {t.preferred}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="gg-summary">{t.summary}</div>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
})}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { useCallback } from 'react';
|
|
3
3
|
import { Link, useFileMetadata, useMetadataQuery } from '@immediately-run/sdk';
|
|
4
4
|
import { isContentEntry, keyToHref } from '../lib/content';
|
|
5
|
+
import InlineProse from './InlineProse';
|
|
5
6
|
import { queryPaths } from '../lib/wiki';
|
|
7
|
+
import EntryImage from './EntryImage';
|
|
6
8
|
|
|
7
9
|
// One dated entry on the axis: mono date · node · card.
|
|
8
10
|
function Row({ path }: { path: string }) {
|
|
@@ -14,8 +16,9 @@ function Row({ path }: { path: string }) {
|
|
|
14
16
|
<div className="gtl-date">{m.date}</div>
|
|
15
17
|
<div className="gtl-node" />
|
|
16
18
|
<div className="gtl-card">
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
+
<EntryImage entryPath={path} src={m.cover} alt="" className="gtl-cover" degrade={null} />
|
|
20
|
+
<div className="gtl-title"><InlineProse text={m.title || path} trimPeriod /></div>
|
|
21
|
+
{m.description && <div className="gtl-desc"><InlineProse text={m.description} /></div>}
|
|
19
22
|
{tags.length ? (
|
|
20
23
|
<div className="gtl-tags">
|
|
21
24
|
{tags.slice(0, 3).map((t) => (
|
|
@@ -79,7 +79,7 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
return (
|
|
82
|
-
<span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`}>
|
|
82
|
+
<span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`} aria-label={`No entry at ${href}`}>
|
|
83
83
|
<Icon name="unlink" />
|
|
84
84
|
{children}
|
|
85
85
|
</span>
|
|
@@ -97,7 +97,7 @@ export default function WikiLink({ href = '', children, ...rest }: Props) {
|
|
|
97
97
|
const exists = !loaded || keys.includes(targetKey); // optimistic until loaded
|
|
98
98
|
if (!exists) {
|
|
99
99
|
return (
|
|
100
|
-
<span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`}>
|
|
100
|
+
<span className="grove-wikilink" data-state="broken" title={`No entry at ${href}`} aria-label={`No entry at ${href}`}>
|
|
101
101
|
<Icon name="unlink" />
|
|
102
102
|
{children}
|
|
103
103
|
</span>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Typed views over viewer.manifest.json's catalogue sections — the manifest is
|
|
2
|
+
// the contract (R3-309/R3-311): every shipped theme, page variant, starter and
|
|
3
|
+
// collection shape is enumerated THERE, and the gallery entries render FROM it,
|
|
4
|
+
// so adding an entry to the manifest adds a row with no edit to any entry.
|
|
5
|
+
|
|
6
|
+
import manifest from '../../viewer.manifest.json';
|
|
7
|
+
|
|
8
|
+
export interface ManifestTheme {
|
|
9
|
+
label: string;
|
|
10
|
+
preferred: 'light' | 'dark';
|
|
11
|
+
ships: boolean;
|
|
12
|
+
summary: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ManifestPageVariant {
|
|
15
|
+
key: string;
|
|
16
|
+
value: string;
|
|
17
|
+
ships: boolean;
|
|
18
|
+
summary: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ManifestLayout {
|
|
21
|
+
layoutRole: string;
|
|
22
|
+
arranges: string;
|
|
23
|
+
ships: boolean;
|
|
24
|
+
summary: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ManifestCollection {
|
|
27
|
+
component?: string;
|
|
28
|
+
props?: Record<string, string>;
|
|
29
|
+
ships: boolean;
|
|
30
|
+
summary: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const cat = manifest as unknown as {
|
|
34
|
+
themes: Record<string, ManifestTheme>;
|
|
35
|
+
pageVariants: Record<string, ManifestPageVariant>;
|
|
36
|
+
layouts: Record<string, ManifestLayout>;
|
|
37
|
+
collections: Record<string, ManifestCollection>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The shipped themes, manifest order. */
|
|
41
|
+
export const manifestThemes = (): Array<[string, ManifestTheme]> =>
|
|
42
|
+
Object.entries(cat.themes).filter(([, t]) => t.ships);
|
|
43
|
+
|
|
44
|
+
/** The shipped page variants (bucket B — a frontmatter key on one entry). */
|
|
45
|
+
export const manifestPageVariants = (): Array<[string, ManifestPageVariant]> =>
|
|
46
|
+
Object.entries(cat.pageVariants).filter(([, v]) => v.ships);
|
|
47
|
+
|
|
48
|
+
/** The shipped layout starters (bucket A — a copyable `_layout.mdx`). */
|
|
49
|
+
export const manifestLayouts = (): Array<[string, ManifestLayout]> =>
|
|
50
|
+
Object.entries(cat.layouts).filter(([, l]) => l.ships);
|
|
51
|
+
|
|
52
|
+
/** The shipped collection shapes (bucket C — a component call). */
|
|
53
|
+
export const manifestCollections = (): Array<[string, ManifestCollection]> =>
|
|
54
|
+
Object.entries(cat.collections).filter(([, c]) => c.ships);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The per-theme face sets (R3-310; the R3-315 declared-asset mechanism): the
|
|
2
|
+
// engine default's families plus the two catalogue looks that change the reading
|
|
3
|
+
// face. `archive` puts a serif in `--sans` — the contract's documented role rule
|
|
4
|
+
// ("`--sans` is the body face, whatever face that is"); `journal` reads warm in
|
|
5
|
+
// Lora; `editorial` is loud through weight, tracking and colour, not through a
|
|
6
|
+
// new face, so it keeps the default set. Data, not components.
|
|
7
|
+
|
|
8
|
+
import type { ThemeAssetDeclarations } from '../lib/themeAssets';
|
|
9
|
+
|
|
10
|
+
const F = (family: string, file: string, weight: string) => ({ family, src: `/app/assets/fonts/${file}`, weight, style: 'normal' });
|
|
11
|
+
|
|
12
|
+
/** The engine default: Gabarito (display) · Public Sans (body) · Space Mono. */
|
|
13
|
+
export const DEFAULT_THEME_ASSETS: ThemeAssetDeclarations = {
|
|
14
|
+
fonts: [
|
|
15
|
+
F('Gabarito', 'gabarito-latin-400-normal.woff2', '400'),
|
|
16
|
+
F('Gabarito', 'gabarito-latin-500-normal.woff2', '500'),
|
|
17
|
+
F('Gabarito', 'gabarito-latin-600-normal.woff2', '600'),
|
|
18
|
+
F('Gabarito', 'gabarito-latin-700-normal.woff2', '700'),
|
|
19
|
+
F('Gabarito', 'gabarito-latin-800-normal.woff2', '800'),
|
|
20
|
+
F('Gabarito', 'gabarito-latin-900-normal.woff2', '900'),
|
|
21
|
+
F('Public Sans', 'public-sans-latin-400-normal.woff2', '400'),
|
|
22
|
+
F('Public Sans', 'public-sans-latin-500-normal.woff2', '500'),
|
|
23
|
+
F('Public Sans', 'public-sans-latin-600-normal.woff2', '600'),
|
|
24
|
+
F('Public Sans', 'public-sans-latin-700-normal.woff2', '700'),
|
|
25
|
+
F('Space Mono', 'space-mono-latin-400-normal.woff2', '400'),
|
|
26
|
+
F('Space Mono', 'space-mono-latin-700-normal.woff2', '700'),
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** `archive` — the reading face is Source Serif 4 (dense reference); display
|
|
31
|
+
* keeps Gabarito so nav and furniture stay legible-brand. */
|
|
32
|
+
export const ARCHIVE_THEME_ASSETS: ThemeAssetDeclarations = {
|
|
33
|
+
fonts: [
|
|
34
|
+
...DEFAULT_THEME_ASSETS.fonts!,
|
|
35
|
+
F('Source Serif 4', 'source-serif-4-latin-400-normal.woff2', '400'),
|
|
36
|
+
F('Source Serif 4', 'source-serif-4-latin-500-normal.woff2', '500'),
|
|
37
|
+
F('Source Serif 4', 'source-serif-4-latin-600-normal.woff2', '600'),
|
|
38
|
+
F('Source Serif 4', 'source-serif-4-latin-700-normal.woff2', '700'),
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** `journal` — the reading face is Lora (warm, personal); display keeps Gabarito. */
|
|
43
|
+
export const JOURNAL_THEME_ASSETS: ThemeAssetDeclarations = {
|
|
44
|
+
fonts: [
|
|
45
|
+
...DEFAULT_THEME_ASSETS.fonts!,
|
|
46
|
+
F('Lora', 'lora-latin-400-normal.woff2', '400'),
|
|
47
|
+
F('Lora', 'lora-latin-500-normal.woff2', '500'),
|
|
48
|
+
F('Lora', 'lora-latin-600-normal.woff2', '600'),
|
|
49
|
+
F('Lora', 'lora-latin-700-normal.woff2', '700'),
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The declaration set a theme mints — default/editorial share the engine set. */
|
|
54
|
+
export function themeAssetsFor(theme: string): ThemeAssetDeclarations {
|
|
55
|
+
if (theme === 'archive') return ARCHIVE_THEME_ASSETS;
|
|
56
|
+
if (theme === 'journal') return JOURNAL_THEME_ASSETS;
|
|
57
|
+
return DEFAULT_THEME_ASSETS;
|
|
58
|
+
}
|
package/src/data/themes.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// The theme catalogue for the theme menu (id → label + swatch gradient). Data,
|
|
2
2
|
// not components — kept out of the chrome components per the Fast-Refresh rule.
|
|
3
3
|
//
|
|
4
|
+
// R3-310: the catalogue is four shippable looks; the three showcase skins
|
|
5
|
+
// (pixies/family/lotr — drawn for a fictional handbook, reading as costumes) are
|
|
6
|
+
// retired. archive/journal/editorial are keyed to uses a real corpus has.
|
|
7
|
+
//
|
|
4
8
|
// R3-308: every theme declares BOTH polarities (its CSS block pair) and a
|
|
5
9
|
// `preferred` one — the polarity a wiki opens in when neither the reader nor the
|
|
6
10
|
// host has said otherwise (02-theme-contract §4). The catalogue ids must match
|
|
@@ -23,19 +27,24 @@ export const THEMES: Theme[] = [
|
|
|
23
27
|
swatch: 'linear-gradient(96deg,#f6f1fb,#f49ad4 46%,#b285f2)',
|
|
24
28
|
preferred: 'dark',
|
|
25
29
|
},
|
|
26
|
-
{ id: 'pixies', label: 'Pixies', swatch: 'linear-gradient(96deg,#ffe14d,#ff2d8e 50%,#9d29ff)', preferred: 'dark' },
|
|
27
30
|
{
|
|
28
|
-
id: '
|
|
29
|
-
label: '
|
|
30
|
-
swatch: 'linear-gradient(96deg,#
|
|
31
|
+
id: 'archive',
|
|
32
|
+
label: 'Archive',
|
|
33
|
+
swatch: 'linear-gradient(96deg,#b89a56,#7a5c20 55%,#3f5a63)',
|
|
31
34
|
preferred: 'light',
|
|
32
35
|
},
|
|
33
36
|
{
|
|
34
|
-
id: '
|
|
35
|
-
label: '
|
|
36
|
-
swatch: 'linear-gradient(96deg,#
|
|
37
|
+
id: 'journal',
|
|
38
|
+
label: 'Journal',
|
|
39
|
+
swatch: 'linear-gradient(96deg,#e8c9a0,#c08552 50%,#a25a3c)',
|
|
37
40
|
preferred: 'light',
|
|
38
41
|
},
|
|
42
|
+
{
|
|
43
|
+
id: 'editorial',
|
|
44
|
+
label: 'Editorial',
|
|
45
|
+
swatch: 'linear-gradient(96deg,#ffd64f,#ff4d2e 55%,#37d4a8)',
|
|
46
|
+
preferred: 'dark',
|
|
47
|
+
},
|
|
39
48
|
];
|
|
40
49
|
|
|
41
50
|
/** The preferred polarity of a palette id — unknown ids fall back to dark, the
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// The dispatched
|
|
1
|
+
// The dispatched bundle's frontmatter index (R3-265).
|
|
2
2
|
//
|
|
3
3
|
// A FORK gets its index from the bundler and this hook does nothing. A DISPATCHED viewer
|
|
4
|
-
// has to build it: the
|
|
4
|
+
// has to build it: the bundle is a mount the bundler never scanned, so without this the
|
|
5
5
|
// wiki renders with empty nav, empty sidebar, no search, no backlinks and no routing —
|
|
6
|
-
// silently, because an absent index is indistinguishable from an empty
|
|
6
|
+
// silently, because an absent index is indistinguishable from an empty bundle.
|
|
7
7
|
//
|
|
8
8
|
// The scan runs ONCE per root and the result is handed to `TinkerableContext.filesMetadata`,
|
|
9
9
|
// which is where `useMetadataQuery` / `useFileMetadata` / `useAllMetadata` already read
|
|
@@ -13,21 +13,21 @@ import { useEffect, useState } from 'react';
|
|
|
13
13
|
import fs from 'fs';
|
|
14
14
|
import { scanCorpus, type CorpusMetadata, type ScanFs } from '../lib/corpusScan';
|
|
15
15
|
|
|
16
|
-
export interface
|
|
16
|
+
export interface BundleIndex {
|
|
17
17
|
/** `idle` — a fork, nothing to scan · `scanning` — hold the render · `ready` — use it. */
|
|
18
18
|
status: 'idle' | 'scanning' | 'ready';
|
|
19
19
|
metadata: CorpusMetadata | null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** Scan `root`, or do nothing when it is null (the fork packaging). */
|
|
23
|
-
export function
|
|
23
|
+
export function useBundleMetadata(root: string | null): BundleIndex {
|
|
24
24
|
const [index, setIndex] = useState<{ root: string; metadata: CorpusMetadata } | null>(null);
|
|
25
25
|
|
|
26
26
|
useEffect(() => {
|
|
27
27
|
if (!root || index?.root === root) return;
|
|
28
28
|
let cancelled = false;
|
|
29
29
|
void scanCorpus(root, fs.promises as unknown as ScanFs).then((metadata) => {
|
|
30
|
-
// A
|
|
30
|
+
// A bundle that resolves to nothing is still a result: `ready` with an empty map
|
|
31
31
|
// renders the 404 index, which tells the reader the folder has no entries. Staying
|
|
32
32
|
// in `scanning` forever would show a spinner and say nothing.
|
|
33
33
|
if (!cancelled) setIndex({ root, metadata });
|
|
@@ -96,7 +96,7 @@ async function loadDeclared(root: string): Promise<Omit<ContentComponents, 'stat
|
|
|
96
96
|
* Returns `loading` until every declaration has resolved, so the caller can hold the
|
|
97
97
|
* content paint. That is the §2 invariant — compose the complete provider before content
|
|
98
98
|
* paints, never render into a partial one — and it costs nothing here because Grove
|
|
99
|
-
* already gates on `useOpenWikiBoot` and `
|
|
99
|
+
* already gates on `useOpenWikiBoot` and `useBundleMetadata`. Rendering into a
|
|
100
100
|
* half-composed provider would flash a missing-component error for `<RoadmapBoard>` until
|
|
101
101
|
* registration landed, which is exactly the error this path removes.
|
|
102
102
|
*/
|
|
@@ -21,7 +21,11 @@ export interface EditAffordance {
|
|
|
21
21
|
writable: boolean;
|
|
22
22
|
/** True while an editor is being summoned (for a busy label). */
|
|
23
23
|
busy: boolean;
|
|
24
|
-
/**
|
|
24
|
+
/** True when the host REFUSED the last edit request — callers render it as
|
|
25
|
+
* text where the affordance was offered (3.3.1 / 4.1.3, R3-608). A
|
|
26
|
+
* `cancelled` rejection (the reader closed the editor) never sets this. */
|
|
27
|
+
refused: boolean;
|
|
28
|
+
/** Open `entryKey` in the platform editor. Never throws; a refusal is reported. */
|
|
25
29
|
openEditor: (entryKey: string) => void;
|
|
26
30
|
/**
|
|
27
31
|
* What a save actually does, so the affordance can say so.
|
|
@@ -41,6 +45,7 @@ export interface EditAffordance {
|
|
|
41
45
|
export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
42
46
|
const mounts = useMounts();
|
|
43
47
|
const [busy, setBusy] = useState(false);
|
|
48
|
+
const [refused, setRefused] = useState(false);
|
|
44
49
|
|
|
45
50
|
// Read the corpus identity through the mount list's identity, so the memo re-runs when
|
|
46
51
|
// the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
|
|
@@ -53,16 +58,24 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
53
58
|
|
|
54
59
|
const writable = !readOnly && corpusWritable(mounts, corpus);
|
|
55
60
|
|
|
61
|
+
// A refusal surfaces where the affordance was offered (3.3.1, R3-608);
|
|
62
|
+
// `cancelled` — the reader closing the editor — stays silent by contract.
|
|
63
|
+
const refusedUnlessCancelled = (e: unknown): undefined => {
|
|
64
|
+
if ((e as { code?: string } | null)?.code !== 'cancelled') setRefused(true);
|
|
65
|
+
return undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
56
68
|
const openEditor = useCallback(
|
|
57
69
|
(entryKey: string) => {
|
|
58
70
|
const target = editTarget(entryKey, corpus);
|
|
59
71
|
if (!target) return;
|
|
60
72
|
setBusy(true);
|
|
73
|
+
setRefused(false);
|
|
61
74
|
const done = () => setBusy(false);
|
|
62
75
|
if (target.via === 'self') {
|
|
63
76
|
// The fork: the present→edit transition on our own source. Self-scoped by
|
|
64
77
|
// contract, which is exactly right when the corpus IS our repo.
|
|
65
|
-
requestEdit({ path: target.path }).catch(
|
|
78
|
+
requestEdit({ path: target.path }).catch(refusedUnlessCancelled).finally(done);
|
|
66
79
|
return;
|
|
67
80
|
}
|
|
68
81
|
// Dispatch: attenuate the corpus delegation down to this one file and hand it to
|
|
@@ -72,7 +85,7 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
72
85
|
invokeTask('edit-file', {
|
|
73
86
|
file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
|
|
74
87
|
})
|
|
75
|
-
.catch(
|
|
88
|
+
.catch(refusedUnlessCancelled) // `cancelled` is how a reader closes the editor
|
|
76
89
|
.finally(done);
|
|
77
90
|
},
|
|
78
91
|
[corpus],
|
|
@@ -82,5 +95,5 @@ export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
|
82
95
|
? 'Edits save to the mounted content. Proposing a change back to its repository is not wired yet.'
|
|
83
96
|
: 'Edit this entry';
|
|
84
97
|
|
|
85
|
-
return { writable, busy, openEditor, editHint };
|
|
98
|
+
return { writable, busy, refused, openEditor, editHint };
|
|
86
99
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// useOverlayFocusDismiss (R3-608): the dialog contract AND the stack — one
|
|
3
|
+
// Escape closes only the TOP overlay; after it closes, the next.
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { act } from 'react';
|
|
7
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
8
|
+
import { useOverlayFocusDismiss } from './useOverlayFocusDismiss';
|
|
9
|
+
|
|
10
|
+
function Overlay({ name, onClose }: { name: string; onClose: () => void }) {
|
|
11
|
+
const ref = useOverlayFocusDismiss(true, onClose);
|
|
12
|
+
return (
|
|
13
|
+
<div data-testid={name} ref={ref} tabIndex={-1}>
|
|
14
|
+
<button>{name} first</button>
|
|
15
|
+
<button>{name} last</button>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One open overlay with a real trigger — the contract's basics. */
|
|
21
|
+
function Single({ onClose }: { onClose: () => void }) {
|
|
22
|
+
const [open, setOpen] = useState(false);
|
|
23
|
+
return (
|
|
24
|
+
<div>
|
|
25
|
+
<button
|
|
26
|
+
onClick={(e) => {
|
|
27
|
+
setOpen(true);
|
|
28
|
+
void e;
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
open
|
|
32
|
+
</button>
|
|
33
|
+
{open ? (
|
|
34
|
+
<Overlay
|
|
35
|
+
name="one"
|
|
36
|
+
onClose={() => {
|
|
37
|
+
setOpen(false);
|
|
38
|
+
onClose();
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Two stacked overlays (a below, b above) — the stack discipline. */
|
|
47
|
+
function Stacked({ aClose, bClose }: { aClose: () => void; bClose: () => void }) {
|
|
48
|
+
const [a, setA] = useState(true);
|
|
49
|
+
const [b, setB] = useState(true);
|
|
50
|
+
return (
|
|
51
|
+
<div>
|
|
52
|
+
{a ? (
|
|
53
|
+
<Overlay
|
|
54
|
+
name="a"
|
|
55
|
+
onClose={() => {
|
|
56
|
+
setA(false);
|
|
57
|
+
aClose();
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
) : null}
|
|
61
|
+
{b ? (
|
|
62
|
+
<Overlay
|
|
63
|
+
name="b"
|
|
64
|
+
onClose={() => {
|
|
65
|
+
setB(false);
|
|
66
|
+
bClose();
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function mount(ui: React.ReactNode): Promise<{ root: Root; container: HTMLElement }> {
|
|
75
|
+
const container = document.createElement('div');
|
|
76
|
+
document.body.appendChild(container);
|
|
77
|
+
const root = createRoot(container);
|
|
78
|
+
await act(async () => {
|
|
79
|
+
root.render(ui);
|
|
80
|
+
});
|
|
81
|
+
return { root, container };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const esc = async (): Promise<void> => {
|
|
85
|
+
await act(async () => {
|
|
86
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
describe('useOverlayFocusDismiss (R3-608)', () => {
|
|
91
|
+
it('focus moves IN on open and RETURNS to the trigger on Escape-close', async () => {
|
|
92
|
+
const close = vi.fn();
|
|
93
|
+
const { container } = await mount(<Single onClose={close} />);
|
|
94
|
+
const trigger = container.querySelector('button') as HTMLButtonElement;
|
|
95
|
+
trigger.focus();
|
|
96
|
+
expect(document.activeElement).toBe(trigger);
|
|
97
|
+
await act(async () => {
|
|
98
|
+
trigger.click();
|
|
99
|
+
});
|
|
100
|
+
// Focus moved IN — to the overlay's first control.
|
|
101
|
+
expect(document.activeElement?.textContent).toBe('one first');
|
|
102
|
+
await esc();
|
|
103
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(container.querySelector('[data-testid="one"]')).toBeNull();
|
|
105
|
+
expect(document.activeElement).toBe(trigger);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('Tab is trapped at the overlay edges', async () => {
|
|
109
|
+
const close = vi.fn();
|
|
110
|
+
const { container } = await mount(<Single onClose={close} />);
|
|
111
|
+
const trigger = container.querySelector('button') as HTMLButtonElement;
|
|
112
|
+
trigger.focus();
|
|
113
|
+
await act(async () => {
|
|
114
|
+
trigger.click();
|
|
115
|
+
});
|
|
116
|
+
const buttons = [...container.querySelectorAll('[data-testid="one"] button')] as HTMLButtonElement[];
|
|
117
|
+
buttons[1].focus();
|
|
118
|
+
await act(async () => {
|
|
119
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
|
120
|
+
});
|
|
121
|
+
expect(document.activeElement).toBe(buttons[0]); // wrapped forward→first
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('one Escape closes only the TOP overlay; the next closes the one below', async () => {
|
|
125
|
+
const aClose = vi.fn();
|
|
126
|
+
const bClose = vi.fn();
|
|
127
|
+
const { container } = await mount(<Stacked aClose={aClose} bClose={bClose} />);
|
|
128
|
+
expect(container.querySelector('[data-testid="a"]')).toBeTruthy();
|
|
129
|
+
expect(container.querySelector('[data-testid="b"]')).toBeTruthy();
|
|
130
|
+
await esc();
|
|
131
|
+
expect(bClose).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(aClose).not.toHaveBeenCalled();
|
|
133
|
+
expect(container.querySelector('[data-testid="b"]')).toBeNull();
|
|
134
|
+
expect(container.querySelector('[data-testid="a"]')).toBeTruthy();
|
|
135
|
+
await esc();
|
|
136
|
+
expect(aClose).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(container.querySelector('[data-testid="a"]')).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The dialog/menu contract for Grove's overlays (R3-608, R-IX-1): focus moves
|
|
2
|
+
// IN on open, Tab is trapped while open (unless the surface is a menu — menus
|
|
3
|
+
// do not trap Tab), Escape closes only the TOP of a module-level stack, and
|
|
4
|
+
// focus RETURNS to the element that opened the overlay. The stack exists
|
|
5
|
+
// because the surfaces overlap (GroveNav's own comment names drawer, search
|
|
6
|
+
// and theme menu as shared interactive state): one Escape closes one overlay.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'react';
|
|
9
|
+
|
|
10
|
+
/** Elements that can hold focus inside the overlay. */
|
|
11
|
+
const FOCUSABLE =
|
|
12
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
13
|
+
|
|
14
|
+
/** The open-overlay stack, newest last. Only the top entry answers Escape —
|
|
15
|
+
* a nested pair closes one per key, never both at once. */
|
|
16
|
+
const stack: { close: () => void }[] = [];
|
|
17
|
+
|
|
18
|
+
interface OverlayFocusDismissOptions {
|
|
19
|
+
/** Menus do not trap Tab (the APG menu pattern keeps Tab as leave); dialogs
|
|
20
|
+
* do. Default true. */
|
|
21
|
+
trapTab?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Wire the contract to an overlay root. Attach the returned ref to the element
|
|
26
|
+
* that bounds the overlay (its panel/menu box).
|
|
27
|
+
*
|
|
28
|
+
* @param open the overlay's open state — the effect arms on true and cleans
|
|
29
|
+
* up (returning focus) on false/unmount.
|
|
30
|
+
* @param onClose Escape's meaning: close without committing.
|
|
31
|
+
*/
|
|
32
|
+
export function useOverlayFocusDismiss(
|
|
33
|
+
open: boolean,
|
|
34
|
+
onClose: () => void,
|
|
35
|
+
opts: OverlayFocusDismissOptions = {},
|
|
36
|
+
) {
|
|
37
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
38
|
+
// The latest close callback, kept current in an effect (never a render-time
|
|
39
|
+
// ref write — the react-hooks compiler forbids it, correctly).
|
|
40
|
+
const onCloseRef = useRef(onClose);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
onCloseRef.current = onClose;
|
|
43
|
+
});
|
|
44
|
+
const trapTab = opts.trapTab !== false;
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) return;
|
|
48
|
+
// The trigger to return to, captured BEFORE focus moves in.
|
|
49
|
+
const trigger = document.activeElement as HTMLElement | null;
|
|
50
|
+
const entry = {
|
|
51
|
+
close: () => onCloseRef.current(),
|
|
52
|
+
};
|
|
53
|
+
stack.push(entry);
|
|
54
|
+
// Set when a MENU surface closes because Tab left it: the browser's own
|
|
55
|
+
// focus move must stand (APG Menu Button), so the cleanup below must NOT
|
|
56
|
+
// yank focus back to the trigger.
|
|
57
|
+
let closedViaTab = false;
|
|
58
|
+
const root = rootRef.current;
|
|
59
|
+
const focusables = root ? [...root.querySelectorAll<HTMLElement>(FOCUSABLE)] : [];
|
|
60
|
+
// Focus IN: the first control; a panel with no focusable content takes
|
|
61
|
+
// focus itself so Escape has a home.
|
|
62
|
+
(focusables[0] ?? root)?.focus();
|
|
63
|
+
|
|
64
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
65
|
+
const isTop = stack[stack.length - 1] === entry;
|
|
66
|
+
if (e.key === 'Escape') {
|
|
67
|
+
if (!isTop) return; // a nested overlay above owns this key
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
onCloseRef.current();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!trapTab && e.key === 'Tab') {
|
|
74
|
+
// APG Menu: Tab is LEAVE — the focus move proceeds (no preventDefault)
|
|
75
|
+
// and the menu closes behind it, never stranded over a scrim. The
|
|
76
|
+
// flag keeps the cleanup from pulling focus back to the trigger: the
|
|
77
|
+
// move the browser just made is the contract.
|
|
78
|
+
if (isTop) {
|
|
79
|
+
closedViaTab = true;
|
|
80
|
+
onCloseRef.current();
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!trapTab || e.key !== 'Tab' || !root) return;
|
|
85
|
+
const list = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => !el.hasAttribute('disabled'));
|
|
86
|
+
if (!list.length) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const first = list[0];
|
|
91
|
+
const last = list[list.length - 1];
|
|
92
|
+
const active = document.activeElement;
|
|
93
|
+
if (!root.contains(active)) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
first.focus();
|
|
96
|
+
} else if (e.shiftKey && active === first) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
last.focus();
|
|
99
|
+
} else if (!e.shiftKey && active === last) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
first.focus();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
// Capture phase: the contract runs before any app-level key handling.
|
|
105
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
106
|
+
return () => {
|
|
107
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
108
|
+
const i = stack.indexOf(entry);
|
|
109
|
+
if (i >= 0) stack.splice(i, 1);
|
|
110
|
+
// Focus RETURNS to the trigger (R-IX-1) — unless the menu closed BECAUSE
|
|
111
|
+
// Tab left it: that move is the contract and stands. Guarded: the
|
|
112
|
+
// trigger may be gone.
|
|
113
|
+
if (!closedViaTab && trigger && document.contains(trigger)) trigger.focus();
|
|
114
|
+
};
|
|
115
|
+
}, [open, trapTab]);
|
|
116
|
+
|
|
117
|
+
return rootRef;
|
|
118
|
+
}
|