@immediately-run/grove 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -115
- package/llms.txt +6 -2
- package/package.json +20 -8
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +485 -85
- package/src/GroveWiki.tsx +176 -54
- 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 +24 -15
- 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 -149
- package/src/components/GroveNav.test.tsx +229 -0
- package/src/components/GroveNav.tsx +69 -20
- 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 +19 -4
- 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 +44 -4
- package/src/devfs.d.ts +5 -4
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +99 -0
- package/src/hooks/useOpenWikiBoot.ts +1 -1
- 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/contentRoot.ts +16 -1
- 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/editTarget.test.ts +108 -0
- package/src/lib/editTarget.ts +93 -0
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/openWiki.test.ts +46 -5
- package/src/lib/openWiki.ts +18 -3
- 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 +17 -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 +52 -0
- package/src/lib/themeSelection.ts +59 -0
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +37 -0
- package/viewer.manifest.json +133 -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,14 +1,54 @@
|
|
|
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
|
+
//
|
|
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
|
+
//
|
|
8
|
+
// R3-308: every theme declares BOTH polarities (its CSS block pair) and a
|
|
9
|
+
// `preferred` one — the polarity a wiki opens in when neither the reader nor the
|
|
10
|
+
// host has said otherwise (02-theme-contract §4). The catalogue ids must match
|
|
11
|
+
// the `[data-grove-theme="…"]` selectors in GroveApp.css; the contrast check in
|
|
12
|
+
// `scripts/check-theme-contrast.mjs` reads both and fails if they drift apart.
|
|
13
|
+
import type { Polarity } from '../lib/themeSelection';
|
|
14
|
+
|
|
3
15
|
export interface Theme {
|
|
4
16
|
id: string;
|
|
5
17
|
label: string;
|
|
6
18
|
swatch: string;
|
|
19
|
+
/** The polarity this theme opens in absent a reader/host opinion. */
|
|
20
|
+
preferred: Polarity;
|
|
7
21
|
}
|
|
8
22
|
|
|
9
23
|
export const THEMES: Theme[] = [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
{
|
|
25
|
+
id: 'default',
|
|
26
|
+
label: 'immediately.run',
|
|
27
|
+
swatch: 'linear-gradient(96deg,#f6f1fb,#f49ad4 46%,#b285f2)',
|
|
28
|
+
preferred: 'dark',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'archive',
|
|
32
|
+
label: 'Archive',
|
|
33
|
+
swatch: 'linear-gradient(96deg,#b89a56,#7a5c20 55%,#3f5a63)',
|
|
34
|
+
preferred: 'light',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'journal',
|
|
38
|
+
label: 'Journal',
|
|
39
|
+
swatch: 'linear-gradient(96deg,#e8c9a0,#c08552 50%,#a25a3c)',
|
|
40
|
+
preferred: 'light',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'editorial',
|
|
44
|
+
label: 'Editorial',
|
|
45
|
+
swatch: 'linear-gradient(96deg,#ffd64f,#ff4d2e 55%,#37d4a8)',
|
|
46
|
+
preferred: 'dark',
|
|
47
|
+
},
|
|
14
48
|
];
|
|
49
|
+
|
|
50
|
+
/** The preferred polarity of a palette id — unknown ids fall back to dark, the
|
|
51
|
+
* long-standing Grove default, rather than throwing in a render path. */
|
|
52
|
+
export function preferredPolarity(id: string): Polarity {
|
|
53
|
+
return THEMES.find((t) => t.id === id)?.preferred ?? 'dark';
|
|
54
|
+
}
|
package/src/devfs.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// code
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// The ambient `fs` + `module` types the immediately.run SANDBOX provides to app
|
|
2
|
+
// code — declared by the package that owns the surface: `@immediately-run/sdk`
|
|
3
|
+
// (R3-276b moved them there from `@immediately-run/dev-fs`, whose job is the local
|
|
4
|
+
// `vite dev` disk bridge, not the contract). One line, complete from sdk 0.49.0.
|
|
5
|
+
/// <reference types="@immediately-run/sdk/ambient" />
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// R3-266 — the one place Grove decides whether to offer an edit, and how to deliver it.
|
|
2
|
+
//
|
|
3
|
+
// Every edit affordance in the wiki (the entry header's pencil, the 404's "Create it",
|
|
4
|
+
// the agent panel's link, the nav's "New entry") used to call `requestEdit` directly with
|
|
5
|
+
// a repo-relative path. That is correct for a FORK and wrong under DISPATCH, where it
|
|
6
|
+
// names a path in Grove's own repo rather than in the corpus on screen — so the affordance
|
|
7
|
+
// was withheld entirely and the wiki became read-only for the one packaging where the
|
|
8
|
+
// content is most obviously somebody's to edit.
|
|
9
|
+
//
|
|
10
|
+
// The decision is pure (`lib/editTarget`); this hook is the wiring: it reads the LIVE
|
|
11
|
+
// mount list so a role downgrade hides the affordance on the next render rather than
|
|
12
|
+
// producing `EROFS` on click, and it hands back one `openEditor(entryKey)` the chrome
|
|
13
|
+
// calls without knowing which packaging it is in.
|
|
14
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
15
|
+
import { capFile, invokeTask, requestEdit, useMounts } from '@immediately-run/sdk';
|
|
16
|
+
import { getContentRoot, getCorpusMountId, isDispatched } from '../lib/contentRoot';
|
|
17
|
+
import { corpusWritable, editTarget } from '../lib/editTarget';
|
|
18
|
+
|
|
19
|
+
export interface EditAffordance {
|
|
20
|
+
/** Whether to render an edit affordance at all — the MOUNT's answer, live. */
|
|
21
|
+
writable: boolean;
|
|
22
|
+
/** True while an editor is being summoned (for a busy label). */
|
|
23
|
+
busy: boolean;
|
|
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. */
|
|
29
|
+
openEditor: (entryKey: string) => void;
|
|
30
|
+
/**
|
|
31
|
+
* What a save actually does, so the affordance can say so.
|
|
32
|
+
*
|
|
33
|
+
* **A stated residual (2026-08-27, R3-266).** The CoW overlay and the contribute (PR)
|
|
34
|
+
* flow are anchored on the APP's repo. Under a fork the app and the corpus are one
|
|
35
|
+
* repo, so "save" and "propose a change" are one story. Under dispatch they are two:
|
|
36
|
+
* the write lands in the corpus mount correctly, and *"open a PR against the content
|
|
37
|
+
* repo"* has no wired target. That is real remaining work — and it is not a reason to
|
|
38
|
+
* withhold editing, because a viewer that saves but cannot yet propose is strictly
|
|
39
|
+
* better than one that refuses to save. It IS a reason not to imply otherwise, so the
|
|
40
|
+
* chrome labels the dispatched case for what it is.
|
|
41
|
+
*/
|
|
42
|
+
editHint: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
46
|
+
const mounts = useMounts();
|
|
47
|
+
const [busy, setBusy] = useState(false);
|
|
48
|
+
const [refused, setRefused] = useState(false);
|
|
49
|
+
|
|
50
|
+
// Read the corpus identity through the mount list's identity, so the memo re-runs when
|
|
51
|
+
// the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
|
|
52
|
+
// the MODE is not, and that is the half this hook exists to keep current.
|
|
53
|
+
const corpus = useMemo(
|
|
54
|
+
() => ({ dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() }),
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
[mounts],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const writable = !readOnly && corpusWritable(mounts, corpus);
|
|
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
|
+
|
|
68
|
+
const openEditor = useCallback(
|
|
69
|
+
(entryKey: string) => {
|
|
70
|
+
const target = editTarget(entryKey, corpus);
|
|
71
|
+
if (!target) return;
|
|
72
|
+
setBusy(true);
|
|
73
|
+
setRefused(false);
|
|
74
|
+
const done = () => setBusy(false);
|
|
75
|
+
if (target.via === 'self') {
|
|
76
|
+
// The fork: the present→edit transition on our own source. Self-scoped by
|
|
77
|
+
// contract, which is exactly right when the corpus IS our repo.
|
|
78
|
+
requestEdit({ path: target.path }).catch(refusedUnlessCancelled).finally(done);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Dispatch: attenuate the corpus delegation down to this one file and hand it to
|
|
82
|
+
// the platform editor. Nothing new is minted — we already hold the directory, and
|
|
83
|
+
// `edit-file` is one hop further along a chain §5.7.1 bounds at depth 4. The host
|
|
84
|
+
// resolves the cap against OUR grants, so this can only ever narrow.
|
|
85
|
+
invokeTask('edit-file', {
|
|
86
|
+
file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
|
|
87
|
+
})
|
|
88
|
+
.catch(refusedUnlessCancelled) // `cancelled` is how a reader closes the editor
|
|
89
|
+
.finally(done);
|
|
90
|
+
},
|
|
91
|
+
[corpus],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const editHint = corpus.dispatched
|
|
95
|
+
? 'Edits save to the mounted content. Proposing a change back to its repository is not wired yet.'
|
|
96
|
+
: 'Edit this entry';
|
|
97
|
+
|
|
98
|
+
return { writable, busy, refused, openEditor, editHint };
|
|
99
|
+
}
|
|
@@ -44,7 +44,7 @@ export function useOpenWikiBoot(): OpenWikiBoot {
|
|
|
44
44
|
// effect would run AFTER the first content render, which is the whole failure this
|
|
45
45
|
// gate exists to prevent. Idempotent and purely derived, so a StrictMode double
|
|
46
46
|
// render sets the same value twice.
|
|
47
|
-
setContentRoot(resolution.root, { readOnly: resolution.readOnly });
|
|
47
|
+
setContentRoot(resolution.root, { readOnly: resolution.readOnly, mountId: resolution.mountId });
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// The module IS the latch: once a root is set, the delegation is final for the life of
|
|
@@ -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
|
+
});
|