@immediately-run/grove 0.1.1 → 0.1.2
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/llms.txt +2 -1
- package/package.json +13 -3
- package/src/GroveApp.css +69 -1
- package/src/GroveWiki.tsx +72 -25
- package/src/components/EntryHeader.tsx +10 -12
- package/src/components/GroveAgent.tsx +4 -3
- package/src/components/GroveNav.test.tsx +89 -0
- package/src/components/GroveNav.tsx +20 -14
- package/src/components/PageView.tsx +3 -3
- package/src/data/themes.ts +35 -4
- package/src/devfs.d.ts +5 -4
- package/src/hooks/useEditAffordance.ts +86 -0
- package/src/hooks/useOpenWikiBoot.ts +1 -1
- package/src/lib/contentRoot.ts +16 -1
- package/src/lib/editTarget.test.ts +108 -0
- package/src/lib/editTarget.ts +93 -0
- package/src/lib/openWiki.test.ts +46 -5
- package/src/lib/openWiki.ts +18 -3
- package/src/lib/shell.ts +10 -0
- package/src/lib/themeSelection.test.ts +52 -0
- package/src/lib/themeSelection.ts +59 -0
- package/viewer.manifest.json +1 -0
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @immediately-run/grove — the viewer kit for directory-as-content wikis
|
|
2
2
|
|
|
3
|
-
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.
|
|
3
|
+
> The Grove viewer: a kit of React components, layouts and themes for building immediately.run wikis. Composable as a fork, a dispatch target, or a pinned library. (v0.1.2)
|
|
4
4
|
|
|
5
5
|
Grove is NOT a wiki engine: routing, MDX compilation, the frontmatter index, link
|
|
6
6
|
spaces and heading anchors live in the sandbox + `@immediately-run/sdk`. What this
|
|
@@ -94,6 +94,7 @@ is one corpus's conventions (declared by that corpus, not this repo).
|
|
|
94
94
|
## Frontmatter keys the engine reads
|
|
95
95
|
|
|
96
96
|
- `site`
|
|
97
|
+
- `theme`
|
|
97
98
|
- `layout`
|
|
98
99
|
- `view`
|
|
99
100
|
- `frame`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@immediately-run/grove",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/main.tsx",
|
|
6
6
|
"immediately.run": {
|
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
"task": "open-wiki",
|
|
11
11
|
"version": "1.0"
|
|
12
12
|
}
|
|
13
|
-
]
|
|
13
|
+
],
|
|
14
|
+
"invokes": [
|
|
15
|
+
{
|
|
16
|
+
"task": "edit-file",
|
|
17
|
+
"version": "^1"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"requests": {
|
|
21
|
+
"task:invoke": {}
|
|
22
|
+
}
|
|
14
23
|
},
|
|
15
24
|
"scripts": {
|
|
16
25
|
"dev": "vite",
|
|
@@ -18,7 +27,8 @@
|
|
|
18
27
|
"lint": "eslint .",
|
|
19
28
|
"test": "vitest run",
|
|
20
29
|
"preview": "vite preview",
|
|
21
|
-
"verify": "npm run check:llms && npm run check:engine-api:selftest && npm run check:engine-api && npm run check:manifest && npm run check:deps && npm run lint && npm run build && npm run test",
|
|
30
|
+
"verify": "npm run check:llms && npm run check:engine-api:selftest && npm run check:engine-api && npm run check:manifest && npm run check:deps && npm run check:theme-contrast && npm run lint && npm run build && npm run test",
|
|
31
|
+
"check:theme-contrast": "node scripts/check-theme-contrast.mjs --self-test && node scripts/check-theme-contrast.mjs",
|
|
22
32
|
"check:manifest": "node scripts/check-manifest.mjs",
|
|
23
33
|
"check:deps": "node scripts/check-app-dependencies.mjs --self-test && node scripts/check-app-dependencies.mjs",
|
|
24
34
|
"check:engine-api": "node scripts/check-engine-api.mjs",
|
package/src/GroveApp.css
CHANGED
|
@@ -67,6 +67,11 @@
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/* —— alternate themes: identical DOM, CSS-only re-skin —— */
|
|
70
|
+
/* R3-308: every theme ships BOTH polarities. The bare [data-grove-theme] block is
|
|
71
|
+
the theme's PREFERRED polarity (the one it opens in absent other opinions); the
|
|
72
|
+
paired block with an explicit [data-theme] is the other one. GroveWiki always
|
|
73
|
+
emits data-theme, so a wiki follows the reader or the host into either polarity
|
|
74
|
+
of ANY theme — alternates are no longer single-polarity by construction. */
|
|
70
75
|
.grove-root[data-grove-theme="pixies"] {
|
|
71
76
|
--bg: #0c0410;
|
|
72
77
|
--panel: #1a0a1e;
|
|
@@ -88,6 +93,27 @@
|
|
|
88
93
|
--radius-shape: 8px;
|
|
89
94
|
--wash: radial-gradient(70% 55% at 85% -5%, rgba(255, 45, 142, .2), transparent 60%), radial-gradient(55% 45% at 4% 2%, rgba(157, 41, 255, .16), transparent 60%);
|
|
90
95
|
}
|
|
96
|
+
.grove-root[data-grove-theme="pixies"][data-theme="light"] {
|
|
97
|
+
--bg: #fdf6fb;
|
|
98
|
+
--panel: #ffffff;
|
|
99
|
+
--panel-2: #f9edf7;
|
|
100
|
+
--line: rgba(123, 47, 247, .18);
|
|
101
|
+
--line-2: rgba(123, 47, 247, .32);
|
|
102
|
+
--ink: #2c1030;
|
|
103
|
+
--ink-2: #6d3f76;
|
|
104
|
+
--ink-3: #7d5f84;
|
|
105
|
+
--accent: #d10f74;
|
|
106
|
+
--accent-2: #6b21c9;
|
|
107
|
+
--accent-3: #8a5a00;
|
|
108
|
+
--grad: linear-gradient(96deg, #ffe14d 0%, #ff2d8e 50%, #9d29ff 100%);
|
|
109
|
+
--glow: 0 0 0 1px rgba(209, 15, 116, .35), 0 8px 22px rgba(209, 15, 116, .18);
|
|
110
|
+
--accent-pink: #d10f74;
|
|
111
|
+
--accent-violet: #6b21c9;
|
|
112
|
+
--disp-weight: 900;
|
|
113
|
+
--prose-measure: 66ch;
|
|
114
|
+
--radius-shape: 8px;
|
|
115
|
+
--wash: radial-gradient(70% 55% at 85% -5%, rgba(209, 15, 116, .08), transparent 60%), radial-gradient(55% 45% at 4% 2%, rgba(107, 33, 201, .06), transparent 60%);
|
|
116
|
+
}
|
|
91
117
|
.grove-root[data-grove-theme="family"] {
|
|
92
118
|
--bg: #faf6f1;
|
|
93
119
|
--panel: #fffdfa;
|
|
@@ -95,7 +121,7 @@
|
|
|
95
121
|
--line: rgba(120, 90, 60, .16);
|
|
96
122
|
--line-2: rgba(120, 90, 60, .28);
|
|
97
123
|
--ink: #3b342c;
|
|
98
|
-
--ink-2: #
|
|
124
|
+
--ink-2: #6b5f51;
|
|
99
125
|
--ink-3: #a89c8c;
|
|
100
126
|
--accent: #c8744f;
|
|
101
127
|
--accent-2: #9a8f5e;
|
|
@@ -109,6 +135,27 @@
|
|
|
109
135
|
--radius-shape: 20px;
|
|
110
136
|
--wash: radial-gradient(60% 50% at 82% -2%, rgba(224, 154, 106, .14), transparent 60%);
|
|
111
137
|
}
|
|
138
|
+
.grove-root[data-grove-theme="family"][data-theme="dark"] {
|
|
139
|
+
--bg: #241d16;
|
|
140
|
+
--panel: #2d251c;
|
|
141
|
+
--panel-2: #372c21;
|
|
142
|
+
--line: rgba(224, 154, 106, .16);
|
|
143
|
+
--line-2: rgba(224, 154, 106, .3);
|
|
144
|
+
--ink: #f2e9de;
|
|
145
|
+
--ink-2: #c9b8a5;
|
|
146
|
+
--ink-3: #a2937f;
|
|
147
|
+
--accent: #e09a6a;
|
|
148
|
+
--accent-2: #c8a06a;
|
|
149
|
+
--accent-3: #d9b98a;
|
|
150
|
+
--grad: linear-gradient(96deg, #f3cf9a 0%, #e09a6a 50%, #c8744f 100%);
|
|
151
|
+
--glow: 0 0 0 1px rgba(224, 154, 106, .4), 0 10px 26px rgba(224, 154, 106, .2);
|
|
152
|
+
--accent-pink: #e09a6a;
|
|
153
|
+
--accent-violet: #c8a06a;
|
|
154
|
+
--disp-weight: 700;
|
|
155
|
+
--prose-measure: 64ch;
|
|
156
|
+
--radius-shape: 20px;
|
|
157
|
+
--wash: radial-gradient(60% 50% at 82% -2%, rgba(224, 154, 106, .1), transparent 60%);
|
|
158
|
+
}
|
|
112
159
|
.grove-root[data-grove-theme="lotr"] {
|
|
113
160
|
--bg: #ece2cc;
|
|
114
161
|
--panel: #f4ebd6;
|
|
@@ -130,6 +177,27 @@
|
|
|
130
177
|
--radius-shape: 4px;
|
|
131
178
|
--wash: radial-gradient(70% 60% at 50% -5%, rgba(122, 90, 42, .1), transparent 65%);
|
|
132
179
|
}
|
|
180
|
+
.grove-root[data-grove-theme="lotr"][data-theme="dark"] {
|
|
181
|
+
--bg: #1d1810;
|
|
182
|
+
--panel: #262014;
|
|
183
|
+
--panel-2: #302818;
|
|
184
|
+
--line: rgba(184, 154, 86, .18);
|
|
185
|
+
--line-2: rgba(184, 154, 86, .32);
|
|
186
|
+
--ink: #ede2c8;
|
|
187
|
+
--ink-2: #c4b48e;
|
|
188
|
+
--ink-3: #9a8a6c;
|
|
189
|
+
--accent: #c9a45c;
|
|
190
|
+
--accent-2: #8aa468;
|
|
191
|
+
--accent-3: #b08a48;
|
|
192
|
+
--grad: linear-gradient(96deg, #b89a56 0%, #8a6a36 50%, #4a5a38 100%);
|
|
193
|
+
--glow: 0 0 0 1px rgba(201, 164, 92, .4), 0 8px 20px rgba(201, 164, 92, .22);
|
|
194
|
+
--accent-pink: #c9a45c;
|
|
195
|
+
--accent-violet: #8aa468;
|
|
196
|
+
--disp-weight: 700;
|
|
197
|
+
--prose-measure: 72ch;
|
|
198
|
+
--radius-shape: 4px;
|
|
199
|
+
--wash: radial-gradient(70% 60% at 50% -5%, rgba(201, 164, 92, .08), transparent 65%);
|
|
200
|
+
}
|
|
133
201
|
|
|
134
202
|
/* ============ SHELL ============ */
|
|
135
203
|
.grove-root a {
|
package/src/GroveWiki.tsx
CHANGED
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
Include,
|
|
8
8
|
useAllMetadata,
|
|
9
9
|
useFileMetadata,
|
|
10
|
+
useHostTheme,
|
|
10
11
|
useMetadataQuery,
|
|
11
|
-
useMounts,
|
|
12
12
|
} from '@immediately-run/sdk';
|
|
13
13
|
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
14
14
|
import { LinkSpaceContext } from '@immediately-run/sdk/linkSpace';
|
|
@@ -27,8 +27,11 @@ import { navQuery } from './lib/queries';
|
|
|
27
27
|
import type { NavRecord } from './lib/queries';
|
|
28
28
|
import { layoutChainForKey } from './lib/layout';
|
|
29
29
|
import { folderIndexKey } from './lib/directory';
|
|
30
|
+
import { resolvePalette, resolvePolarity, type Polarity } from './lib/themeSelection';
|
|
31
|
+
import { preferredPolarity } from './data/themes';
|
|
30
32
|
import { useDirectoryListing } from './hooks/useDirectoryListing';
|
|
31
|
-
import {
|
|
33
|
+
import { useEditAffordance } from './hooks/useEditAffordance';
|
|
34
|
+
import { getContentRoot } from './lib/contentRoot';
|
|
32
35
|
import type { RejectedComponent } from './lib/corpusComponents';
|
|
33
36
|
import { GroveShellContext, OutletContext } from './lib/shell';
|
|
34
37
|
import type { GroveShell, NavItem } from './lib/shell';
|
|
@@ -114,10 +117,44 @@ export default function GroveWiki({
|
|
|
114
117
|
}) {
|
|
115
118
|
const ctx = useContext(TinkerableContext) as any;
|
|
116
119
|
const sandboxPath: string = ctx?.navigationState?.sandboxPath || '/';
|
|
117
|
-
const mounts = useMounts() as any[];
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
// ── Theme selection (R3-308, 02-theme-contract §4) ─────────────────────────
|
|
122
|
+
//
|
|
123
|
+
// Two INDEPENDENT axes with three sources, resolved through ONE module
|
|
124
|
+
// (lib/themeSelection) so no surface re-derives the precedence:
|
|
125
|
+
//
|
|
126
|
+
// palette = reader override, else the author's `theme:` on the home entry, else default
|
|
127
|
+
// polarity = reader override, else the host's theme, else the palette's preferred
|
|
128
|
+
//
|
|
129
|
+
// The stored prefs are OVERRIDES, nullable by nature: absent until the reader
|
|
130
|
+
// acts, which is what gives the author's declaration its turn. They are written
|
|
131
|
+
// by the user actions (chooseTheme/choosePolarity), NEVER by an effect on mount
|
|
132
|
+
// — the old effects promoted the initial value to a reader choice on first
|
|
133
|
+
// visit, which is exactly why a `theme:` declaration could never have won.
|
|
134
|
+
// (Visitors from before this change carry a mount-written pref; it stands —
|
|
135
|
+
// they are Grove readers, and a reader outranks an author.)
|
|
136
|
+
const [readerTheme, setReaderTheme] = useState<string | null>(() => readPref('grove:theme'));
|
|
137
|
+
const [readerAppearance, setReaderAppearance] = useState<Polarity | null>(() => {
|
|
138
|
+
const p = readPref('grove:appearance');
|
|
139
|
+
return p === 'light' || p === 'dark' ? p : null;
|
|
140
|
+
});
|
|
141
|
+
const chooseTheme = (id: string) => {
|
|
142
|
+
setReaderTheme(id);
|
|
143
|
+
writePref('grove:theme', id);
|
|
144
|
+
};
|
|
145
|
+
const choosePolarity = (wantLight: boolean) => {
|
|
146
|
+
const p: Polarity = wantLight ? 'light' : 'dark';
|
|
147
|
+
setReaderAppearance(p);
|
|
148
|
+
writePref('grove:appearance', p);
|
|
149
|
+
};
|
|
150
|
+
// The host drives POLARITY ONLY (`theme:read` is the one theme capability the
|
|
151
|
+
// open-wiki binding holds) — and only when there IS a host. `useHostTheme`'s
|
|
152
|
+
// channel reports an `initial: 'dark'` before any host speaks, so an unframed
|
|
153
|
+
// standalone `vite dev` render would otherwise carry a phantom host opinion
|
|
154
|
+
// and flip light-preferred themes. Framed === a host exists to have one.
|
|
155
|
+
const framed = typeof window !== 'undefined' && window.parent !== window;
|
|
156
|
+
const hostTheme = useHostTheme();
|
|
157
|
+
const hostPolarity: Polarity | null = framed ? hostTheme : null;
|
|
121
158
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
122
159
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
123
160
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
@@ -132,8 +169,6 @@ export default function GroveWiki({
|
|
|
132
169
|
mq.addEventListener('change', on);
|
|
133
170
|
return () => mq.removeEventListener('change', on);
|
|
134
171
|
}, []);
|
|
135
|
-
useEffect(() => writePref('grove:theme', theme), [theme]);
|
|
136
|
-
useEffect(() => writePref('grove:appearance', light ? 'light' : 'dark'), [light]);
|
|
137
172
|
|
|
138
173
|
// ⌘K / Ctrl-K opens search.
|
|
139
174
|
useEffect(() => {
|
|
@@ -147,28 +182,37 @@ export default function GroveWiki({
|
|
|
147
182
|
return () => window.removeEventListener('keydown', on);
|
|
148
183
|
}, []);
|
|
149
184
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// The affordance below calls `requestEdit`, which is **self-scoped by contract** ("v1
|
|
153
|
-
// supports only a repo-relative path in the CURRENT repo … editing a file in one of your
|
|
154
|
-
// mounts is the `edit-file` task, not this"). Under dispatch the corpus is a mount, so
|
|
155
|
-
// that call would edit GROVE rather than the corpus on screen. Offering it would be
|
|
156
|
-
// wrong; withholding it *as a design* is also wrong, and this comment exists so the next
|
|
157
|
-
// reader does not conclude the second from the first.
|
|
185
|
+
// R3-266 — dispatched content IS writable, and the MOUNT decides.
|
|
158
186
|
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
187
|
+
// This used to read `!isDispatched() && …`, withholding every edit affordance from a
|
|
188
|
+
// dispatched viewer. The reason was real but the conclusion was not: `requestEdit` is
|
|
189
|
+
// **self-scoped by contract**, so under dispatch it names a path in GROVE's repo rather
|
|
190
|
+
// than in the corpus on screen. The fix is a verb swap, not a withheld capability — see
|
|
191
|
+
// `lib/editTarget` — and the gate is the corpus mount's CURRENT mode, re-read on every
|
|
192
|
+
// mount change so a live role downgrade hides the affordance instead of producing
|
|
193
|
+
// `EROFS` on click.
|
|
194
|
+
const { writable, busy: editBusy, openEditor, editHint } = useEditAffordance(readOnly);
|
|
166
195
|
|
|
167
196
|
const routeKey = sandboxPathToKey(sandboxPath) || homeKey();
|
|
168
197
|
// The site brand is a wiki-wide constant, so read it from the home entry's
|
|
169
198
|
// `site` frontmatter — not the current entry's (which only home would carry),
|
|
170
199
|
// else the brand flips to the 'Grove' fallback on every sub-page.
|
|
171
200
|
const homeMeta = useFileMetadata(homeKey()) as any;
|
|
201
|
+
// R3-308: the author's palette declaration — `theme:` on the home entry, the
|
|
202
|
+
// wiki-wide sibling of `site:`. Like `site`, it is read from HOME and not the
|
|
203
|
+
// current entry, so a sub-page never flips the wiki's look back to `default`.
|
|
204
|
+
const authorTheme: string | null =
|
|
205
|
+
typeof homeMeta?.theme === 'string' && homeMeta.theme ? homeMeta.theme : null;
|
|
206
|
+
// The two axes, resolved through the one module that owns the precedence. `theme`
|
|
207
|
+
// and `light` below are the RESOLVED values every surface renders from — the raw
|
|
208
|
+
// reader overrides live only in state and in the menu handlers.
|
|
209
|
+
const theme = resolvePalette({ reader: readerTheme, author: authorTheme });
|
|
210
|
+
const polarity: Polarity = resolvePolarity({
|
|
211
|
+
reader: readerAppearance,
|
|
212
|
+
host: hostPolarity,
|
|
213
|
+
preferred: preferredPolarity(theme),
|
|
214
|
+
});
|
|
215
|
+
const light = polarity === 'light';
|
|
172
216
|
// Existence / 404: the whole index tells us if a followed link is dead. Layout
|
|
173
217
|
// files are structure, not entries, so they're excluded here (and everywhere).
|
|
174
218
|
const allKeysQuery = useCallback((fm: Record<string, any>) => Object.keys(fm).filter(isContentEntry), []);
|
|
@@ -257,9 +301,9 @@ export default function GroveWiki({
|
|
|
257
301
|
|
|
258
302
|
const shell: GroveShell = {
|
|
259
303
|
theme,
|
|
260
|
-
setTheme,
|
|
304
|
+
setTheme: chooseTheme,
|
|
261
305
|
light,
|
|
262
|
-
setLight,
|
|
306
|
+
setLight: choosePolarity,
|
|
263
307
|
menuOpen,
|
|
264
308
|
setMenuOpen,
|
|
265
309
|
searchOpen,
|
|
@@ -269,6 +313,9 @@ export default function GroveWiki({
|
|
|
269
313
|
vw,
|
|
270
314
|
navMode,
|
|
271
315
|
writable,
|
|
316
|
+
openEditor,
|
|
317
|
+
editBusy,
|
|
318
|
+
editHint,
|
|
272
319
|
siteTitle,
|
|
273
320
|
safe,
|
|
274
321
|
navItems,
|
|
@@ -336,7 +383,7 @@ export default function GroveWiki({
|
|
|
336
383
|
data-vw={vw}
|
|
337
384
|
data-nav={navMode}
|
|
338
385
|
data-grove-theme={theme === 'default' ? undefined : theme}
|
|
339
|
-
data-theme={
|
|
386
|
+
data-theme={polarity}
|
|
340
387
|
>
|
|
341
388
|
<div className="device__scroll">
|
|
342
389
|
{rejectedComponents.length > 0 ? (
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { keyToRepoRel } from '../lib/content';
|
|
2
|
+
import { useFileMetadata } from '@immediately-run/sdk';
|
|
3
|
+
import { useShell } from '../lib/shell';
|
|
5
4
|
import { crumb } from '../lib/wiki';
|
|
6
5
|
import Icon from './Icon';
|
|
7
6
|
|
|
@@ -17,17 +16,11 @@ export default function EntryHeader({
|
|
|
17
16
|
writable: boolean;
|
|
18
17
|
mins: number;
|
|
19
18
|
}) {
|
|
19
|
+
const { openEditor, editBusy, editHint } = useShell();
|
|
20
20
|
const meta = useFileMetadata(entryKey) as any;
|
|
21
|
-
const [busy, setBusy] = useState(false);
|
|
22
21
|
if (!meta) return null;
|
|
23
22
|
const tags: string[] = Array.isArray(meta.tags) ? meta.tags.filter((t: string) => !t.startsWith('ui/')) : [];
|
|
24
23
|
const cr = crumb(entryKey);
|
|
25
|
-
const edit = () => {
|
|
26
|
-
setBusy(true);
|
|
27
|
-
requestEdit({ path: keyToRepoRel(entryKey) })
|
|
28
|
-
.catch(() => undefined)
|
|
29
|
-
.finally(() => setBusy(false));
|
|
30
|
-
};
|
|
31
24
|
return (
|
|
32
25
|
<header className="grove-entry-header">
|
|
33
26
|
{cr.includes('/') ? <nav className="crumb">{cr}</nav> : null}
|
|
@@ -40,9 +33,14 @@ export default function EntryHeader({
|
|
|
40
33
|
<span key={t} className="grove-tag">#{t}</span>
|
|
41
34
|
))}
|
|
42
35
|
{writable && (
|
|
43
|
-
<button
|
|
36
|
+
<button
|
|
37
|
+
className="grove-edit-affordance"
|
|
38
|
+
data-busy={editBusy ? '1' : '0'}
|
|
39
|
+
title={editHint}
|
|
40
|
+
onClick={() => openEditor(entryKey)}
|
|
41
|
+
>
|
|
44
42
|
<Icon name="pencil" />
|
|
45
|
-
{
|
|
43
|
+
{editBusy ? 'Opening editor…' : 'Edit'}
|
|
46
44
|
</button>
|
|
47
45
|
)}
|
|
48
46
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { chat,
|
|
4
|
-
import {
|
|
3
|
+
import { chat, useChatProvider } from '@immediately-run/sdk';
|
|
4
|
+
import { useShell } from '../lib/shell';
|
|
5
5
|
import Icon from './Icon';
|
|
6
6
|
|
|
7
7
|
interface Msg {
|
|
@@ -27,6 +27,7 @@ const CHIPS = [
|
|
|
27
27
|
// banners rather than faking a host surface.
|
|
28
28
|
export default function GroveAgent({ writable, entryKey, entryTitle }: { writable: boolean; entryKey: string; entryTitle: string }) {
|
|
29
29
|
const provider = useChatProvider();
|
|
30
|
+
const { openEditor } = useShell();
|
|
30
31
|
const [open, setOpen] = useState(false);
|
|
31
32
|
const [detent, setDetent] = useState<'half' | 'full'>('half');
|
|
32
33
|
const [resting, setResting] = useState('');
|
|
@@ -212,7 +213,7 @@ export default function GroveAgent({ writable, entryKey, entryTitle }: { writabl
|
|
|
212
213
|
<div className="ga-foot__hand">
|
|
213
214
|
<span>Grove's own agent · scoped to your grants</span>
|
|
214
215
|
<a
|
|
215
|
-
onClick={() =>
|
|
216
|
+
onClick={() => openEditor(entryKey)}
|
|
216
217
|
role="button"
|
|
217
218
|
tabIndex={0}
|
|
218
219
|
>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// The appearance control is offered for EVERY theme (R3-308) — the bug this pins
|
|
3
|
+
// is "single-polarity by construction": the control used to render only when
|
|
4
|
+
// `theme === 'default'`, which is precisely how the alternates stayed
|
|
5
|
+
// light/dark-or-nothing. Rendered for a NON-default theme through the real
|
|
6
|
+
// component, so the gate cannot quietly come back.
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
import { act } from 'react';
|
|
9
|
+
import { createRoot } from 'react-dom/client';
|
|
10
|
+
import { GroveShellContext, type GroveShell } from '../lib/shell';
|
|
11
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
12
|
+
|
|
13
|
+
const { default: GroveNav } = await import('./GroveNav');
|
|
14
|
+
|
|
15
|
+
// The SDK's <Link> resolves hrefs against the host navigation state; without a
|
|
16
|
+
// provider `outerHref` is undefined and URL construction throws before any
|
|
17
|
+
// assertion runs. A minimal provider stands in for the host, exactly as the
|
|
18
|
+
// sandbox would supply it.
|
|
19
|
+
const NAV = {
|
|
20
|
+
outerHref: 'https://example.immediately.run/app/x',
|
|
21
|
+
navigationState: { sandboxPath: '/app/x' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const mount = async (shell: Partial<GroveShell>) => {
|
|
25
|
+
const host = document.createElement('div');
|
|
26
|
+
document.body.appendChild(host);
|
|
27
|
+
const full: GroveShell = {
|
|
28
|
+
theme: 'default',
|
|
29
|
+
setTheme: vi.fn(),
|
|
30
|
+
light: false,
|
|
31
|
+
setLight: vi.fn(),
|
|
32
|
+
menuOpen: false,
|
|
33
|
+
setMenuOpen: vi.fn(),
|
|
34
|
+
searchOpen: false,
|
|
35
|
+
setSearchOpen: vi.fn(),
|
|
36
|
+
drawerOpen: false,
|
|
37
|
+
setDrawerOpen: vi.fn(),
|
|
38
|
+
vw: 'desktop',
|
|
39
|
+
navMode: 'top',
|
|
40
|
+
writable: false,
|
|
41
|
+
openEditor: vi.fn(),
|
|
42
|
+
editBusy: false,
|
|
43
|
+
editHint: '',
|
|
44
|
+
siteTitle: 'Grove',
|
|
45
|
+
safe: false,
|
|
46
|
+
navItems: [{ key: 'a', href: '/a', label: 'A' }],
|
|
47
|
+
entryKey: '/a',
|
|
48
|
+
includePath: 'a.mdx',
|
|
49
|
+
layout: 'doc',
|
|
50
|
+
showRails: false,
|
|
51
|
+
mins: 0,
|
|
52
|
+
missing: false,
|
|
53
|
+
directory: { status: 'idle' },
|
|
54
|
+
...shell,
|
|
55
|
+
} as unknown as GroveShell;
|
|
56
|
+
await act(async () => {
|
|
57
|
+
createRoot(host).render(
|
|
58
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
59
|
+
<GroveShellContext.Provider value={full}>
|
|
60
|
+
<GroveNav />
|
|
61
|
+
</GroveShellContext.Provider>
|
|
62
|
+
</TinkerableContext.Provider>,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
return host;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe('the theme menu (R3-308 — two independent axes)', () => {
|
|
69
|
+
it('offers the appearance control for a NON-default theme', async () => {
|
|
70
|
+
const host = await mount({ theme: 'pixies', menuOpen: true, light: false });
|
|
71
|
+
const seg = host.querySelector('.gtm__seg');
|
|
72
|
+
expect(seg).not.toBeNull();
|
|
73
|
+
expect(seg!.querySelectorAll('button')).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('marks the RESOLVED polarity, not only a reader override', async () => {
|
|
77
|
+
// `light` is the resolved value the shell hands down (lib/themeSelection) —
|
|
78
|
+
// the control must reflect whatever the resolution produced, including the
|
|
79
|
+
// host-driven or preferred cases where no reader override exists.
|
|
80
|
+
const host = await mount({ theme: 'family', menuOpen: true, light: true });
|
|
81
|
+
const on = [...host.querySelectorAll('.gtm__seg button')].find((b) => b.getAttribute('data-on') === '1');
|
|
82
|
+
expect(on?.textContent).toMatch(/Light/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('lists every catalogue theme — the menu is how a reader reaches them', async () => {
|
|
86
|
+
const host = await mount({ menuOpen: true });
|
|
87
|
+
expect(host.querySelectorAll('.gtm__row').length).toBeGreaterThan(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Link
|
|
1
|
+
import { Link } from '@immediately-run/sdk';
|
|
2
2
|
import { useShell } from '../lib/shell';
|
|
3
|
+
import { getContentRoot } from '../lib/contentRoot';
|
|
3
4
|
import { THEMES } from '../data/themes';
|
|
4
5
|
import Icon from './Icon';
|
|
5
6
|
|
|
@@ -12,6 +13,7 @@ export default function GroveNav() {
|
|
|
12
13
|
navItems,
|
|
13
14
|
entryKey,
|
|
14
15
|
writable,
|
|
16
|
+
openEditor,
|
|
15
17
|
theme,
|
|
16
18
|
setTheme,
|
|
17
19
|
light,
|
|
@@ -26,7 +28,10 @@ export default function GroveNav() {
|
|
|
26
28
|
const el = (document.querySelector('.ga-foot input') || document.querySelector('.ga-line input')) as HTMLElement | null;
|
|
27
29
|
el?.focus();
|
|
28
30
|
};
|
|
29
|
-
|
|
31
|
+
// The new entry belongs to whichever corpus is mounted, so it is named from the
|
|
32
|
+
// content ROOT rather than the fork's `content/` literal — under dispatch the latter
|
|
33
|
+
// would create a file in Grove's own repo (R3-266).
|
|
34
|
+
const newEntry = () => openEditor(`${getContentRoot()}untitled.mdx`);
|
|
30
35
|
|
|
31
36
|
return (
|
|
32
37
|
<nav className="grove-nav">
|
|
@@ -79,19 +84,20 @@ export default function GroveNav() {
|
|
|
79
84
|
</button>
|
|
80
85
|
))}
|
|
81
86
|
</div>
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
{/* R3-308: the appearance control is offered for EVERY theme — each
|
|
88
|
+
catalogue entry ships both polarities, so gating it on
|
|
89
|
+
`default` would be single-polarity by construction again. */}
|
|
90
|
+
<div className="gtm__appearance">
|
|
91
|
+
<div className="gtm__sub">Appearance</div>
|
|
92
|
+
<div className="gtm__seg">
|
|
93
|
+
<button data-on={!light ? '1' : '0'} onClick={() => setLight(false)}>
|
|
94
|
+
<Icon name="moon" /> Dark
|
|
95
|
+
</button>
|
|
96
|
+
<button data-on={light ? '1' : '0'} onClick={() => setLight(true)}>
|
|
97
|
+
<Icon name="sun" /> Light
|
|
98
|
+
</button>
|
|
93
99
|
</div>
|
|
94
|
-
|
|
100
|
+
</div>
|
|
95
101
|
</div>
|
|
96
102
|
</>
|
|
97
103
|
) : null}
|
|
@@ -3,7 +3,6 @@ import { Include, Link } from '@immediately-run/sdk';
|
|
|
3
3
|
import { useShell } from '../lib/shell';
|
|
4
4
|
import { keyToHref, keyToRepoRel } from '../lib/content';
|
|
5
5
|
import { crumb } from '../lib/wiki';
|
|
6
|
-
import { requestEdit } from '@immediately-run/sdk';
|
|
7
6
|
import DirectoryView from './DirectoryView';
|
|
8
7
|
import EntryHeader from './EntryHeader';
|
|
9
8
|
import SafeEntryBody from './SafeEntryBody';
|
|
@@ -20,7 +19,8 @@ declare const module: any;
|
|
|
20
19
|
// site chrome (nav / sidebar / footer) — that's the layout's job — so the page
|
|
21
20
|
// stays free of shell concerns.
|
|
22
21
|
export default function PageView() {
|
|
23
|
-
const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, vw, safe, directory } =
|
|
22
|
+
const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, openEditor, vw, safe, directory } =
|
|
23
|
+
useShell();
|
|
24
24
|
|
|
25
25
|
// A folder URL. `checking` renders nothing rather than the 404: the readdir that
|
|
26
26
|
// decides between them is one RPC away, and a 404 that appears and then turns into a
|
|
@@ -39,7 +39,7 @@ export default function PageView() {
|
|
|
39
39
|
</p>
|
|
40
40
|
<div className="grove-state__actions">
|
|
41
41
|
<Link className="btn-ghost" href="/"><Icon name="chevron-right" /> Back to home</Link>
|
|
42
|
-
{writable ? <button className="btn-primary" onClick={() =>
|
|
42
|
+
{writable ? <button className="btn-primary" onClick={() => openEditor(entryKey)}><Icon name="file-plus" /> Create it</button> : null}
|
|
43
43
|
</div>
|
|
44
44
|
</div>
|
|
45
45
|
);
|
package/src/data/themes.ts
CHANGED
|
@@ -1,14 +1,45 @@
|
|
|
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-308: every theme declares BOTH polarities (its CSS block pair) and a
|
|
5
|
+
// `preferred` one — the polarity a wiki opens in when neither the reader nor the
|
|
6
|
+
// host has said otherwise (02-theme-contract §4). The catalogue ids must match
|
|
7
|
+
// the `[data-grove-theme="…"]` selectors in GroveApp.css; the contrast check in
|
|
8
|
+
// `scripts/check-theme-contrast.mjs` reads both and fails if they drift apart.
|
|
9
|
+
import type { Polarity } from '../lib/themeSelection';
|
|
10
|
+
|
|
3
11
|
export interface Theme {
|
|
4
12
|
id: string;
|
|
5
13
|
label: string;
|
|
6
14
|
swatch: string;
|
|
15
|
+
/** The polarity this theme opens in absent a reader/host opinion. */
|
|
16
|
+
preferred: Polarity;
|
|
7
17
|
}
|
|
8
18
|
|
|
9
19
|
export const THEMES: Theme[] = [
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
20
|
+
{
|
|
21
|
+
id: 'default',
|
|
22
|
+
label: 'immediately.run',
|
|
23
|
+
swatch: 'linear-gradient(96deg,#f6f1fb,#f49ad4 46%,#b285f2)',
|
|
24
|
+
preferred: 'dark',
|
|
25
|
+
},
|
|
26
|
+
{ id: 'pixies', label: 'Pixies', swatch: 'linear-gradient(96deg,#ffe14d,#ff2d8e 50%,#9d29ff)', preferred: 'dark' },
|
|
27
|
+
{
|
|
28
|
+
id: 'family',
|
|
29
|
+
label: 'Family journal',
|
|
30
|
+
swatch: 'linear-gradient(96deg,#f3cf9a,#e09a6a 50%,#c8744f)',
|
|
31
|
+
preferred: 'light',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'lotr',
|
|
35
|
+
label: 'Middle-earth',
|
|
36
|
+
swatch: 'linear-gradient(96deg,#b89a56,#8a6a36 50%,#4a5a38)',
|
|
37
|
+
preferred: 'light',
|
|
38
|
+
},
|
|
14
39
|
];
|
|
40
|
+
|
|
41
|
+
/** The preferred polarity of a palette id — unknown ids fall back to dark, the
|
|
42
|
+
* long-standing Grove default, rather than throwing in a render path. */
|
|
43
|
+
export function preferredPolarity(id: string): Polarity {
|
|
44
|
+
return THEMES.find((t) => t.id === id)?.preferred ?? 'dark';
|
|
45
|
+
}
|
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" />
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
/** Open `entryKey` in the platform editor. Never throws; a refusal is a no-op. */
|
|
25
|
+
openEditor: (entryKey: string) => void;
|
|
26
|
+
/**
|
|
27
|
+
* What a save actually does, so the affordance can say so.
|
|
28
|
+
*
|
|
29
|
+
* **A stated residual (2026-08-27, R3-266).** The CoW overlay and the contribute (PR)
|
|
30
|
+
* flow are anchored on the APP's repo. Under a fork the app and the corpus are one
|
|
31
|
+
* repo, so "save" and "propose a change" are one story. Under dispatch they are two:
|
|
32
|
+
* the write lands in the corpus mount correctly, and *"open a PR against the content
|
|
33
|
+
* repo"* has no wired target. That is real remaining work — and it is not a reason to
|
|
34
|
+
* withhold editing, because a viewer that saves but cannot yet propose is strictly
|
|
35
|
+
* better than one that refuses to save. It IS a reason not to imply otherwise, so the
|
|
36
|
+
* chrome labels the dispatched case for what it is.
|
|
37
|
+
*/
|
|
38
|
+
editHint: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useEditAffordance(readOnly: boolean): EditAffordance {
|
|
42
|
+
const mounts = useMounts();
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
|
|
45
|
+
// Read the corpus identity through the mount list's identity, so the memo re-runs when
|
|
46
|
+
// the host re-announces a mount. The root itself is latched at boot (see `contentRoot`);
|
|
47
|
+
// the MODE is not, and that is the half this hook exists to keep current.
|
|
48
|
+
const corpus = useMemo(
|
|
49
|
+
() => ({ dispatched: isDispatched(), contentRoot: getContentRoot(), mountId: getCorpusMountId() }),
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
[mounts],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const writable = !readOnly && corpusWritable(mounts, corpus);
|
|
55
|
+
|
|
56
|
+
const openEditor = useCallback(
|
|
57
|
+
(entryKey: string) => {
|
|
58
|
+
const target = editTarget(entryKey, corpus);
|
|
59
|
+
if (!target) return;
|
|
60
|
+
setBusy(true);
|
|
61
|
+
const done = () => setBusy(false);
|
|
62
|
+
if (target.via === 'self') {
|
|
63
|
+
// The fork: the present→edit transition on our own source. Self-scoped by
|
|
64
|
+
// contract, which is exactly right when the corpus IS our repo.
|
|
65
|
+
requestEdit({ path: target.path }).catch(() => undefined).finally(done);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Dispatch: attenuate the corpus delegation down to this one file and hand it to
|
|
69
|
+
// the platform editor. Nothing new is minted — we already hold the directory, and
|
|
70
|
+
// `edit-file` is one hop further along a chain §5.7.1 bounds at depth 4. The host
|
|
71
|
+
// resolves the cap against OUR grants, so this can only ever narrow.
|
|
72
|
+
invokeTask('edit-file', {
|
|
73
|
+
file: capFile({ mountId: target.mountId, relPath: target.relPath }, { mode: 'rw' }),
|
|
74
|
+
})
|
|
75
|
+
.catch(() => undefined) // `cancelled` is how a reader closes the editor
|
|
76
|
+
.finally(done);
|
|
77
|
+
},
|
|
78
|
+
[corpus],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const editHint = corpus.dispatched
|
|
82
|
+
? 'Edits save to the mounted content. Proposing a change back to its repository is not wired yet.'
|
|
83
|
+
: 'Edit this entry';
|
|
84
|
+
|
|
85
|
+
return { writable, busy, openEditor, editHint };
|
|
86
|
+
}
|
|
@@ -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
|
package/src/lib/contentRoot.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const APP_CONTENT_ROOT = '/app/content/';
|
|
|
24
24
|
|
|
25
25
|
let root: string = APP_CONTENT_ROOT;
|
|
26
26
|
let readOnly = false;
|
|
27
|
+
let mountId: string | null = null;
|
|
27
28
|
|
|
28
29
|
/** Where this instance's corpus lives, with a trailing slash. Read at CALL time. */
|
|
29
30
|
export function getContentRoot(): string {
|
|
@@ -35,10 +36,23 @@ export function getContentRoot(): string {
|
|
|
35
36
|
* with the delegated directory, or not at all (the fork, which keeps the default).
|
|
36
37
|
* Normalizes the trailing slash so every `startsWith`/`slice` in the helpers holds.
|
|
37
38
|
*/
|
|
38
|
-
export function setContentRoot(dir: string, opts: { readOnly?: boolean } = {}): void {
|
|
39
|
+
export function setContentRoot(dir: string, opts: { readOnly?: boolean; mountId?: string | null } = {}): void {
|
|
39
40
|
if (!dir) return;
|
|
40
41
|
root = dir.endsWith('/') ? dir : `${dir}/`;
|
|
41
42
|
readOnly = opts.readOnly ?? false;
|
|
43
|
+
mountId = opts.mountId ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The mount id of the corpus, or null for a fork (whose corpus is its own repo, not a
|
|
48
|
+
* mount). R3-266: this is what an onward delegation NAMES — `capFile({ mountId, relPath })`
|
|
49
|
+
* — when Grove hands a content file to the platform editor. It lives here with the root
|
|
50
|
+
* for the same reason the read-only flag does: it is the same fact, decided once by the
|
|
51
|
+
* same delegation, and every consumer that asks "may I offer an edit, and of what?"
|
|
52
|
+
* already reads the root.
|
|
53
|
+
*/
|
|
54
|
+
export function getCorpusMountId(): string | null {
|
|
55
|
+
return mountId;
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
/** Whether the mounted corpus was delegated read-only. Lives here rather than in React
|
|
@@ -58,4 +72,5 @@ export function isDispatched(): boolean {
|
|
|
58
72
|
export function resetContentRoot(): void {
|
|
59
73
|
root = APP_CONTENT_ROOT;
|
|
60
74
|
readOnly = false;
|
|
75
|
+
mountId = null;
|
|
61
76
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// R3-266 — dispatched content is writable, and the MOUNT decides.
|
|
2
|
+
//
|
|
3
|
+
// The two things these tests pin are the two things that were wrong before: a dispatched
|
|
4
|
+
// viewer must send its edit to the CORPUS (never to Grove's own repo), and whether it may
|
|
5
|
+
// offer one at all must be the corpus mount's CURRENT mode rather than a property of the
|
|
6
|
+
// packaging or a flag latched at boot.
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { corpusWritable, editTarget, keyToSelfPath } from './editTarget';
|
|
9
|
+
import type { CorpusIdentity } from './editTarget';
|
|
10
|
+
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
11
|
+
|
|
12
|
+
const fork: CorpusIdentity = {
|
|
13
|
+
dispatched: false,
|
|
14
|
+
contentRoot: '/app/content/',
|
|
15
|
+
mountId: null,
|
|
16
|
+
};
|
|
17
|
+
const dispatched: CorpusIdentity = {
|
|
18
|
+
dispatched: true,
|
|
19
|
+
contentRoot: '/task/t1/dir/',
|
|
20
|
+
mountId: '/task/t1/dir',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const mount = (over: Partial<SandboxMount> = {}): SandboxMount =>
|
|
24
|
+
({ type: 'firestore', path: '/task/t1/dir', id: '/task/t1/dir', mode: 'rw', ...over }) as SandboxMount;
|
|
25
|
+
|
|
26
|
+
describe('editTarget — the verb follows the authority, not the packaging', () => {
|
|
27
|
+
it('a FORK edits its own source through the self-scoped present→edit transition', () => {
|
|
28
|
+
expect(editTarget('/app/content/handbook/onboarding.mdx', fork)).toEqual({
|
|
29
|
+
via: 'self',
|
|
30
|
+
path: 'content/handbook/onboarding.mdx',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('a DISPATCHED viewer delegates the CORPUS file, never a path in its own repo', () => {
|
|
35
|
+
expect(editTarget('/task/t1/dir/plot/the-rail.mdx', dispatched)).toEqual({
|
|
36
|
+
via: 'delegate',
|
|
37
|
+
mountId: '/task/t1/dir',
|
|
38
|
+
relPath: 'plot/the-rail.mdx',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is corpus-relative under dispatch — the mount root IS the corpus root', () => {
|
|
43
|
+
const t = editTarget('/task/t1/dir/home.mdx', dispatched);
|
|
44
|
+
expect(t).toMatchObject({ relPath: 'home.mdx' });
|
|
45
|
+
// The fork's `content/` segment must NOT leak into a corpus-relative path: the
|
|
46
|
+
// delegated chroot is minted AT the content directory.
|
|
47
|
+
expect((t as { relPath: string }).relPath.startsWith('content/')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('offers nothing for a key outside the mounted corpus (a leftover from the viewer)', () => {
|
|
51
|
+
expect(editTarget('/app/content/home.mdx', dispatched)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('offers nothing when a dispatched corpus has no mount id to delegate from', () => {
|
|
55
|
+
expect(editTarget('/task/t1/dir/home.mdx', { ...dispatched, mountId: null })).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('offers nothing for the corpus root itself (a directory is not an entry)', () => {
|
|
59
|
+
expect(editTarget('/task/t1/dir/', dispatched)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('never throws on a junk key', () => {
|
|
63
|
+
expect(editTarget('', dispatched)).toBeNull();
|
|
64
|
+
expect(editTarget(undefined as unknown as string, fork)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('keyToSelfPath strips the app anchor exactly as the fork URLs require', () => {
|
|
68
|
+
expect(keyToSelfPath('/app/content/x.mdx')).toBe('content/x.mdx');
|
|
69
|
+
expect(keyToSelfPath('/content/x.mdx')).toBe('content/x.mdx');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('corpusWritable — the mount decides, live', () => {
|
|
74
|
+
it('a fork asks about its working tree, as before', () => {
|
|
75
|
+
expect(corpusWritable([{ type: 'worktree', path: '/app', mode: 'rw' } as SandboxMount], fork)).toBe(true);
|
|
76
|
+
expect(corpusWritable([{ type: 'worktree', path: '/app', mode: 'ro' } as SandboxMount], fork)).toBe(false);
|
|
77
|
+
expect(corpusWritable([], fork)).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('a DISPATCHED viewer on an rw corpus is writable — packaging is not trust', () => {
|
|
81
|
+
expect(corpusWritable([mount()], dispatched)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('a ro corpus is not writable, so the affordance is hidden rather than EROFS-ing', () => {
|
|
85
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('follows a LIVE downgrade: the same mount re-announced ro flips the answer', () => {
|
|
89
|
+
expect(corpusWritable([mount({ mode: 'rw' })], dispatched)).toBe(true);
|
|
90
|
+
expect(corpusWritable([mount({ mode: 'ro' })], dispatched)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('a corpus mount that has vanished is not writable', () => {
|
|
94
|
+
expect(corpusWritable([mount({ id: 'space:other', path: '/mnt/x' })], dispatched)).toBe(false);
|
|
95
|
+
expect(corpusWritable([], dispatched)).toBe(false);
|
|
96
|
+
expect(corpusWritable(null, dispatched)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('matches a mount that carries no id by its path (what the host publishes)', () => {
|
|
100
|
+
expect(corpusWritable([{ type: 'firestore', path: '/task/t1/dir', mode: 'rw' } as SandboxMount], dispatched)).toBe(
|
|
101
|
+
true,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('never reports writable when there is no mount id at all', () => {
|
|
106
|
+
expect(corpusWritable([mount()], { ...dispatched, mountId: null })).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// R3-266 — WHERE an edit goes, and whether one may be offered at all.
|
|
2
|
+
//
|
|
3
|
+
// Grove ships in two packagings, and the edit verb differs between them because the
|
|
4
|
+
// AUTHORITY does, not because dispatched content is somehow less editable:
|
|
5
|
+
//
|
|
6
|
+
// • FORK — the corpus is this app's own repo, so "edit this entry" is the
|
|
7
|
+
// present→edit transition on our own source: `requestEdit({ path })`,
|
|
8
|
+
// which is **self-scoped by contract** ("v1 supports only a repo-relative
|
|
9
|
+
// path in the CURRENT repo").
|
|
10
|
+
// • DISPATCH — the corpus is a MOUNT somebody handed us. `requestEdit` there would
|
|
11
|
+
// name a path in GROVE's repo, so the same call would offer to edit the
|
|
12
|
+
// viewer instead of the wiki on screen. The right verb is the one for a
|
|
13
|
+
// file in a mount: `invokeTask('edit-file', { file: capFile(...) })`,
|
|
14
|
+
// attenuating the corpus delegation down to the single entry.
|
|
15
|
+
//
|
|
16
|
+
// The previous code withheld the affordance under dispatch and said so in a comment that
|
|
17
|
+
// was careful to call it temporary. It was still the wrong outcome: dispatch changes the
|
|
18
|
+
// PACKAGING, not the authority — the same corpus, forked, is editable — so a read-only
|
|
19
|
+
// dispatched viewer breaks packaging-is-not-trust exactly where a reader would notice.
|
|
20
|
+
//
|
|
21
|
+
// **The mount decides.** Writability is a property of the delegation's current mode, not
|
|
22
|
+
// of how the app was loaded. That is why `corpusWritable` takes the live mount list rather
|
|
23
|
+
// than the boot-time flag: a role downgrade re-announces the mount `ro`, and the
|
|
24
|
+
// affordance must disappear rather than surface `EROFS` when clicked.
|
|
25
|
+
//
|
|
26
|
+
// Pure — no SDK, no React — so all of the above is testable without a host.
|
|
27
|
+
|
|
28
|
+
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
29
|
+
|
|
30
|
+
/** How an edit of a content entry is delivered. */
|
|
31
|
+
export type EditTarget =
|
|
32
|
+
/** The fork: our own repo, via the self-scoped present→edit transition. */
|
|
33
|
+
| { via: 'self'; path: string }
|
|
34
|
+
/** Dispatch: one file of the delegated corpus, handed to the platform editor. */
|
|
35
|
+
| { via: 'delegate'; mountId: string; relPath: string };
|
|
36
|
+
|
|
37
|
+
export interface CorpusIdentity {
|
|
38
|
+
/** Whether the corpus is a mount rather than this app's own repo. */
|
|
39
|
+
dispatched: boolean;
|
|
40
|
+
/** The content root, with a trailing slash (`getContentRoot()`). */
|
|
41
|
+
contentRoot: string;
|
|
42
|
+
/** The corpus mount id, when dispatched (`getCorpusMountId()`). */
|
|
43
|
+
mountId: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `/app/content/x.mdx` → `content/x.mdx` — the fork's repo-relative path. */
|
|
47
|
+
export function keyToSelfPath(key: string): string {
|
|
48
|
+
return key.replace(/^\/app\//, '').replace(/^\//, '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where an edit of `entryKey` should go, or null when there is nowhere to send it.
|
|
53
|
+
*
|
|
54
|
+
* Null is not "read-only" — that is {@link corpusWritable}'s question. Null means the key
|
|
55
|
+
* does not name a file in this corpus at all, or a dispatched viewer has no mount id to
|
|
56
|
+
* delegate from (an older host that published the corpus without one). Either way there is
|
|
57
|
+
* nothing to offer, and offering it anyway would produce a refusal the reader must decode.
|
|
58
|
+
*/
|
|
59
|
+
export function editTarget(entryKey: string, corpus: CorpusIdentity): EditTarget | null {
|
|
60
|
+
if (typeof entryKey !== 'string' || entryKey === '') return null;
|
|
61
|
+
if (!corpus.dispatched) return { via: 'self', path: keyToSelfPath(entryKey) };
|
|
62
|
+
if (!corpus.mountId) return null;
|
|
63
|
+
if (!entryKey.startsWith(corpus.contentRoot)) return null;
|
|
64
|
+
const relPath = entryKey.slice(corpus.contentRoot.length);
|
|
65
|
+
return relPath ? { via: 'delegate', mountId: corpus.mountId, relPath } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* May this instance offer an edit at all, given the mounts it holds RIGHT NOW?
|
|
70
|
+
*
|
|
71
|
+
* A fork asks about its working tree, as before. A dispatched viewer asks about the corpus
|
|
72
|
+
* mount — and asks the LIVE mount list, not the boot-time flag, so a live `rw → ro`
|
|
73
|
+
* downgrade (a role change the host re-announces on the same mount id) hides the
|
|
74
|
+
* affordance on the next render. That is the whole difference between "hidden because you
|
|
75
|
+
* may not" and "shown, then `EROFS` when you try".
|
|
76
|
+
*
|
|
77
|
+
* A corpus mount that has vanished from the list answers `false`: no mount, no write.
|
|
78
|
+
*/
|
|
79
|
+
export function corpusWritable(
|
|
80
|
+
mounts: readonly SandboxMount[] | null | undefined,
|
|
81
|
+
corpus: CorpusIdentity,
|
|
82
|
+
): boolean {
|
|
83
|
+
const list = mounts ?? [];
|
|
84
|
+
if (!corpus.dispatched) {
|
|
85
|
+
return list.some((m) => m.type === 'worktree' && m.mode !== 'ro');
|
|
86
|
+
}
|
|
87
|
+
if (!corpus.mountId) return false;
|
|
88
|
+
const mount = list.find((m) => (m.id ?? m.path) === corpus.mountId);
|
|
89
|
+
// `mode` is absent on the primary repo mount and rw by default elsewhere; a corpus
|
|
90
|
+
// mount that reports nothing is treated as writable exactly as `resolveOpenWiki` reads
|
|
91
|
+
// it, so the two never disagree about the same mount.
|
|
92
|
+
return !!mount && mount.mode !== 'ro';
|
|
93
|
+
}
|
package/src/lib/openWiki.test.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
2
|
import { resolveOpenWiki, OPEN_WIKI_TASK, CONTENT_MOUNT_TYPE } from './openWiki';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getContentRoot,
|
|
5
|
+
getCorpusMountId,
|
|
6
|
+
setContentRoot,
|
|
7
|
+
resetContentRoot,
|
|
8
|
+
isDispatched,
|
|
9
|
+
APP_CONTENT_ROOT,
|
|
10
|
+
} from './contentRoot';
|
|
4
11
|
import { slugToKey, isContentEntry, homeKey, contentDir, keyToHref, sandboxPathToKey } from './content';
|
|
5
12
|
import { layoutChainForKey } from './layout';
|
|
6
13
|
import type { SandboxMount } from '@immediately-run/sdk/mounts';
|
|
@@ -13,14 +20,14 @@ afterEach(resetContentRoot);
|
|
|
13
20
|
describe('resolveOpenWiki — the delegated corpus', () => {
|
|
14
21
|
it('resolves the dir param mounted at the host-minted chroot', () => {
|
|
15
22
|
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/task/t1/dir')]);
|
|
16
|
-
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: false, via: 'task' });
|
|
23
|
+
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: false, via: 'task', mountId: '/task/t1/dir' });
|
|
17
24
|
});
|
|
18
25
|
|
|
19
26
|
it('reports a read-only delegation without refusing it', () => {
|
|
20
27
|
// Sharing a corpus read-only is legitimate — the reader still reads. Only the WRITE
|
|
21
28
|
// affordances may consult this; refusing the whole open would break the ordinary case.
|
|
22
29
|
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/task/t1/dir', { mode: 'ro' })]);
|
|
23
|
-
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: true, via: 'task' });
|
|
30
|
+
expect(r).toEqual({ ok: true, root: '/task/t1/dir', readOnly: true, via: 'task', mountId: '/task/t1/dir' });
|
|
24
31
|
});
|
|
25
32
|
|
|
26
33
|
it('is not a callee when there is no task input — the ordinary fork boot', () => {
|
|
@@ -48,7 +55,7 @@ describe('resolveOpenWiki — the delegated corpus', () => {
|
|
|
48
55
|
// The host owns the `/task/<slot>/<param>` grammar; if it ever renames the segment,
|
|
49
56
|
// suffix-matching alone would cancel a task the user really asked for.
|
|
50
57
|
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/app'), mount('/mnt/abc123')]);
|
|
51
|
-
expect(r).toEqual({ ok: true, root: '/mnt/abc123', readOnly: false, via: 'task' });
|
|
58
|
+
expect(r).toEqual({ ok: true, root: '/mnt/abc123', readOnly: false, via: 'task', mountId: '/mnt/abc123' });
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
it('does not guess between two foreign mounts', () => {
|
|
@@ -72,7 +79,7 @@ describe('repo-load dispatch — a cold URL load, with no task input at all', ()
|
|
|
72
79
|
mount('/app'),
|
|
73
80
|
mount('/mnt/deadbeef', { type: CONTENT_MOUNT_TYPE, name: 'neumark/book-nine-from-here' }),
|
|
74
81
|
]);
|
|
75
|
-
expect(r).toEqual({ ok: true, root: '/mnt/deadbeef', readOnly: false, via: 'repo-load' });
|
|
82
|
+
expect(r).toEqual({ ok: true, root: '/mnt/deadbeef', readOnly: false, via: 'repo-load', mountId: '/mnt/deadbeef' });
|
|
76
83
|
});
|
|
77
84
|
|
|
78
85
|
it('carries a read-only delegation through', () => {
|
|
@@ -214,3 +221,37 @@ describe('routing — the URL space follows the packaging', () => {
|
|
|
214
221
|
expect(key).toBe('/task/t1/dir/app/content/home.mdx');
|
|
215
222
|
});
|
|
216
223
|
});
|
|
224
|
+
|
|
225
|
+
// R3-266 — the corpus mount ID, which is what an onward delegation NAMES. Without it a
|
|
226
|
+
// dispatched viewer can locate the corpus and still not hand one of its files to the
|
|
227
|
+
// platform editor, which is the whole of the dispatched write path.
|
|
228
|
+
describe('resolveOpenWiki — the corpus mount id (the onward-delegation handle)', () => {
|
|
229
|
+
it('prefers the host-published id over the path', () => {
|
|
230
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [
|
|
231
|
+
mount('/task/t1/dir', { id: 'space:abc' }),
|
|
232
|
+
]);
|
|
233
|
+
expect(r).toMatchObject({ ok: true, mountId: 'space:abc' });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('falls back to the mount PATH, which is exactly what the host publishes for a chroot', () => {
|
|
237
|
+
// `mintDelegations` names the descriptor `{ path, type: 'task-delegation', id: path }`,
|
|
238
|
+
// so path and id coincide for a task delegation — the fallback is the same answer, not
|
|
239
|
+
// a guess, and it keeps working against a host that publishes no id at all.
|
|
240
|
+
const r = resolveOpenWiki({ task: OPEN_WIKI_TASK, params: {} }, [mount('/task/t1/dir')]);
|
|
241
|
+
expect(r).toMatchObject({ ok: true, mountId: '/task/t1/dir' });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('carries the id through the repo-load branch too', () => {
|
|
245
|
+
const r = resolveOpenWiki(null, [
|
|
246
|
+
mount('/mnt/deadbeef', { type: CONTENT_MOUNT_TYPE, id: 'github:neumark/book@main' }),
|
|
247
|
+
]);
|
|
248
|
+
expect(r).toMatchObject({ ok: true, via: 'repo-load', mountId: 'github:neumark/book@main' });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('reaches the contentRoot module, so the affordance can read it back', () => {
|
|
252
|
+
setContentRoot('/task/t1/dir', { readOnly: false, mountId: 'space:abc' });
|
|
253
|
+
expect(getCorpusMountId()).toBe('space:abc');
|
|
254
|
+
resetContentRoot();
|
|
255
|
+
expect(getCorpusMountId()).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
});
|
package/src/lib/openWiki.ts
CHANGED
|
@@ -25,7 +25,16 @@ export const DIR_PARAM = 'dir';
|
|
|
25
25
|
export const CONTENT_MOUNT_TYPE = 'content';
|
|
26
26
|
|
|
27
27
|
export type OpenWikiResolution =
|
|
28
|
-
| {
|
|
28
|
+
| {
|
|
29
|
+
ok: true;
|
|
30
|
+
root: string;
|
|
31
|
+
readOnly: boolean;
|
|
32
|
+
via: 'task' | 'repo-load';
|
|
33
|
+
/** The corpus mount's id — what an onward delegation names (R3-266). Falls back to
|
|
34
|
+
* the mount PATH, which is exactly what the host publishes as the id for a task
|
|
35
|
+
* chroot (`mintDelegations` uses the mount point as the descriptor id). */
|
|
36
|
+
mountId: string;
|
|
37
|
+
}
|
|
29
38
|
| { ok: false; reason: 'not-a-callee' | 'wrong-task' | 'no-mount' };
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -53,7 +62,13 @@ export function resolveOpenWiki(
|
|
|
53
62
|
// input and would otherwise fall out as `not-a-callee` and render our own corpus.
|
|
54
63
|
const marked = mounts.find((m) => m.type === CONTENT_MOUNT_TYPE);
|
|
55
64
|
if (marked) {
|
|
56
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
root: marked.path,
|
|
68
|
+
readOnly: marked.mode === 'ro',
|
|
69
|
+
via: 'repo-load',
|
|
70
|
+
mountId: marked.id ?? marked.path,
|
|
71
|
+
};
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
if (!input) return { ok: false, reason: 'not-a-callee' };
|
|
@@ -67,7 +82,7 @@ export function resolveOpenWiki(
|
|
|
67
82
|
// `mode` is absent on the primary repo mount and rw by default elsewhere. A read-only
|
|
68
83
|
// delegation is a legitimate way to share a corpus — the reader still reads — so it
|
|
69
84
|
// resolves normally and only the WRITE affordances consult this flag.
|
|
70
|
-
return { ok: true, root: hit.path, readOnly: hit.mode === 'ro', via: 'task' };
|
|
85
|
+
return { ok: true, root: hit.path, readOnly: hit.mode === 'ro', via: 'task', mountId: hit.id ?? hit.path };
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
/** The message a failed resolution should show, in the reader's terms rather than the
|
package/src/lib/shell.ts
CHANGED
|
@@ -33,7 +33,17 @@ export interface GroveShell {
|
|
|
33
33
|
// Environment
|
|
34
34
|
vw: 'mobile' | 'desktop';
|
|
35
35
|
navMode: 'top' | 'side';
|
|
36
|
+
/** Whether to render an edit affordance — the corpus MOUNT's answer, re-read live
|
|
37
|
+
* (R3-266), never a property of how this instance was packaged. */
|
|
36
38
|
writable: boolean;
|
|
39
|
+
/** Open a content entry in the platform editor. Which verb that takes differs by
|
|
40
|
+
* packaging (`lib/editTarget`); the chrome never has to know which. */
|
|
41
|
+
openEditor: (entryKey: string) => void;
|
|
42
|
+
/** True while an editor is being summoned, for a busy label. */
|
|
43
|
+
editBusy: boolean;
|
|
44
|
+
/** What a save actually does, for the affordance's title — under dispatch it says that
|
|
45
|
+
* proposing a change back to the content repo is not wired yet (R3-266's residual). */
|
|
46
|
+
editHint: string;
|
|
37
47
|
siteTitle: string;
|
|
38
48
|
/** Interpreter mode (TRUST_MODES §5): render this entry's body through the
|
|
39
49
|
* non-executable safe renderer (R3-213) instead of the compiled/executable `<Include>`
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The selection precedence (02-theme-contract §4), pinned as a decision table —
|
|
2
|
+
// pure, so the whole cross-product of sources is cheap to assert. R3-308.
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { resolvePalette, resolvePolarity } from './themeSelection';
|
|
5
|
+
import { preferredPolarity, THEMES } from '../data/themes';
|
|
6
|
+
|
|
7
|
+
describe('resolvePalette — reader, else author, else default', () => {
|
|
8
|
+
it('a reader override wins over the author declaration', () => {
|
|
9
|
+
expect(resolvePalette({ reader: 'pixies', author: 'lotr' })).toBe('pixies');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("a reader's explicit 'default' outranks a declaration — it is a choice, not an absence", () => {
|
|
13
|
+
expect(resolvePalette({ reader: 'default', author: 'lotr' })).toBe('default');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('the author declaration is the default a reader falls into', () => {
|
|
17
|
+
expect(resolvePalette({ reader: null, author: 'family' })).toBe('family');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('no reader, no author → default', () => {
|
|
21
|
+
expect(resolvePalette({})).toBe('default');
|
|
22
|
+
expect(resolvePalette({ reader: '', author: null })).toBe('default');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('resolvePolarity — reader, else host, else the palette’s own preference', () => {
|
|
27
|
+
it('a reader override wins over the host', () => {
|
|
28
|
+
expect(resolvePolarity({ reader: 'light', host: 'dark', preferred: 'dark' })).toBe('light');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('the host drives polarity when the reader is silent — palette untouched (theme:read is polarity-only)', () => {
|
|
32
|
+
expect(resolvePolarity({ reader: null, host: 'light', preferred: 'dark' })).toBe('light');
|
|
33
|
+
expect(resolvePolarity({ reader: null, host: 'dark', preferred: 'light' })).toBe('dark');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('no reader, no host → the palette’s preferred polarity', () => {
|
|
37
|
+
expect(resolvePolarity({ reader: null, host: null, preferred: 'light' })).toBe('light');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('an unparseable stored override is silence, not a crash', () => {
|
|
41
|
+
expect(resolvePolarity({ reader: null, host: 'light', preferred: 'dark' })).toBe('light');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('the catalogue carries a preferred polarity for every theme (R3-308)', () => {
|
|
46
|
+
it('every theme declares one, and unknown ids fall back to dark', () => {
|
|
47
|
+
for (const t of THEMES) expect(['light', 'dark']).toContain(t.preferred);
|
|
48
|
+
expect(preferredPolarity('pixies')).toBe('dark');
|
|
49
|
+
expect(preferredPolarity('family')).toBe('light');
|
|
50
|
+
expect(preferredPolarity('never-heard-of')).toBe('dark');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Theme selection — the ONE resolution of who picks what a reader sees
|
|
2
|
+
// (plans/grove-layouts-and-themes/02-theme-contract.mdx §4, R3-308).
|
|
3
|
+
//
|
|
4
|
+
// Two INDEPENDENT axes, three sources, stated once here so no surface re-derives
|
|
5
|
+
// them (ways_of_working §5: one resolution entry point per concern):
|
|
6
|
+
//
|
|
7
|
+
// palette = reader override, else author declaration, else 'default'
|
|
8
|
+
// polarity = reader override, else host theme, else the theme's own preferred
|
|
9
|
+
//
|
|
10
|
+
// The host has an opinion about POLARITY ONLY — it holds `theme:read` for exactly
|
|
11
|
+
// that and nothing else, so it never appears in the palette chain. A reader's
|
|
12
|
+
// choice outranks everyone and persists; an author's declaration is the default a
|
|
13
|
+
// reader falls into, not a wall.
|
|
14
|
+
//
|
|
15
|
+
// PURE: takes already-read inputs, returns a decision. Where each input comes from
|
|
16
|
+
// (localStorage, home-entry frontmatter, the host channel) is wiring, not policy,
|
|
17
|
+
// and lives in the components.
|
|
18
|
+
|
|
19
|
+
/** Light/dark — the axis that selects WITHIN a palette family. */
|
|
20
|
+
export type Polarity = 'light' | 'dark';
|
|
21
|
+
|
|
22
|
+
/** A palette family id — a `Theme['id']`, or any string a reader's override holds. */
|
|
23
|
+
export type PaletteId = string;
|
|
24
|
+
|
|
25
|
+
export interface PaletteInputs {
|
|
26
|
+
/** The reader's stored override (`grove:theme`), if any. */
|
|
27
|
+
reader?: PaletteId | null;
|
|
28
|
+
/** The author's `theme:` declaration on the home entry, if any. */
|
|
29
|
+
author?: PaletteId | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the palette. An empty/absent override is NOT an override — `''` would
|
|
34
|
+
* otherwise beat a real declaration while meaning nothing. `'default'` as a READER
|
|
35
|
+
* choice is meaningful ("the brand palette, even though this wiki declares another"),
|
|
36
|
+
* so any explicit reader value — `default` included — outranks the author.
|
|
37
|
+
*/
|
|
38
|
+
export function resolvePalette({ reader, author }: PaletteInputs): PaletteId {
|
|
39
|
+
if (reader) return reader;
|
|
40
|
+
if (author) return author;
|
|
41
|
+
return 'default';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PolarityInputs {
|
|
45
|
+
/** The reader's stored override (`grove:appearance`), if any. */
|
|
46
|
+
reader?: Polarity | null;
|
|
47
|
+
/** The host's current theme, when the host has one (standalone: the channel's
|
|
48
|
+
* initial — the host axis simply has no live source there). */
|
|
49
|
+
host?: Polarity | null;
|
|
50
|
+
/** The resolved palette's own preferred polarity. */
|
|
51
|
+
preferred: Polarity;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve the polarity. Reader, then host, then the palette's own preference. */
|
|
55
|
+
export function resolvePolarity({ reader, host, preferred }: PolarityInputs): Polarity {
|
|
56
|
+
if (reader === 'light' || reader === 'dark') return reader;
|
|
57
|
+
if (host === 'light' || host === 'dark') return host;
|
|
58
|
+
return preferred;
|
|
59
|
+
}
|