@immediately-run/grove 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -115
- package/llms.txt +5 -2
- package/package.json +12 -10
- package/src/App.tsx +11 -7
- package/src/GroveApp.css +484 -152
- package/src/GroveWiki.tsx +105 -30
- package/src/components/AssetImage.tsx +1 -12
- package/src/components/Backlinks.tsx +2 -2
- package/src/components/Catalogue.test.tsx +157 -0
- package/src/components/ContentTheme.test.tsx +146 -0
- package/src/components/ContentTheme.tsx +111 -0
- package/src/components/DocList.infinite.test.tsx +177 -0
- package/src/components/DocList.tsx +82 -9
- package/src/components/Drawer.tsx +14 -2
- package/src/components/EntryHeader.tsx +23 -12
- package/src/components/EntryImage.test.tsx +210 -0
- package/src/components/EntryImage.tsx +47 -0
- package/src/components/Galleries.test.tsx +98 -0
- package/src/components/GroveAgent.test.tsx +330 -0
- package/src/components/GroveAgent.tsx +269 -150
- package/src/components/GroveNav.test.tsx +145 -5
- package/src/components/GroveNav.tsx +51 -8
- package/src/components/Icon.tsx +0 -1
- package/src/components/InlineProse.tsx +37 -0
- package/src/components/LayoutGallery.tsx +65 -0
- package/src/components/PageView.tsx +18 -3
- package/src/components/Search.test.tsx +113 -0
- package/src/components/Search.tsx +53 -18
- package/src/components/Sidebar.test.tsx +135 -0
- package/src/components/Sidebar.tsx +114 -12
- package/src/components/TableOfContents.test.tsx +27 -16
- package/src/components/ThemeAssets.test.tsx +109 -0
- package/src/components/ThemeAssets.tsx +63 -0
- package/src/components/ThemeGallery.tsx +31 -0
- package/src/components/Timeline.tsx +5 -2
- package/src/components/WikiLink.tsx +2 -2
- package/src/data/catalogue.ts +54 -0
- package/src/data/themeFonts.ts +58 -0
- package/src/data/themes.ts +16 -7
- package/src/hooks/{useCorpusMetadata.ts → useBundleMetadata.ts} +6 -6
- package/src/hooks/useContentComponents.ts +1 -1
- package/src/hooks/useEditAffordance.ts +17 -4
- package/src/hooks/useOverlayFocusDismiss.test.tsx +139 -0
- package/src/hooks/useOverlayFocusDismiss.ts +118 -0
- package/src/hooks/useScrollReset.test.tsx +132 -0
- package/src/hooks/useScrollReset.ts +52 -0
- package/src/index.css +9 -2
- package/src/lib/agentPrompt.test.ts +96 -0
- package/src/lib/agentPrompt.ts +86 -0
- package/src/lib/agentTools.test.ts +115 -0
- package/src/lib/agentTools.ts +132 -0
- package/src/lib/agentTranscript.ts +50 -0
- package/src/lib/assetPath.test.ts +36 -0
- package/src/lib/assetPath.ts +42 -0
- package/src/lib/collectionCalls.test.ts +69 -0
- package/src/lib/content.test.ts +10 -2
- package/src/lib/content.ts +7 -0
- package/src/lib/contentStylesheet.test.ts +80 -0
- package/src/lib/contentStylesheet.ts +103 -0
- package/src/lib/corpusScan.test.ts +37 -3
- package/src/lib/corpusScan.ts +17 -2
- package/src/lib/inlineProse.parity.test.ts +52 -0
- package/src/lib/layout.ts +29 -0
- package/src/lib/pageVariants.test.tsx +87 -0
- package/src/lib/queries.test.ts +33 -1
- package/src/lib/queries.ts +33 -1
- package/src/lib/reachCard.test.ts +94 -0
- package/src/lib/reachCard.ts +112 -0
- package/src/lib/shell.ts +7 -0
- package/src/lib/starterSweep.test.tsx +160 -0
- package/src/lib/starterSweep.ts +97 -0
- package/src/lib/themeAssets.test.ts +135 -0
- package/src/lib/themeAssets.ts +143 -0
- package/src/lib/themeSelection.test.ts +2 -2
- package/src/mdxComponents.ts +4 -0
- package/viewer-manifest.schema.json +67 -0
- package/viewer.manifest.json +132 -6
|
@@ -21,10 +21,8 @@ const NAV = {
|
|
|
21
21
|
navigationState: { sandboxPath: '/app/x' },
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
document.body.appendChild(host);
|
|
27
|
-
const full: GroveShell = {
|
|
24
|
+
const fullShell = (shell: Partial<GroveShell>): GroveShell =>
|
|
25
|
+
({
|
|
28
26
|
theme: 'default',
|
|
29
27
|
setTheme: vi.fn(),
|
|
30
28
|
light: false,
|
|
@@ -52,7 +50,12 @@ const mount = async (shell: Partial<GroveShell>) => {
|
|
|
52
50
|
missing: false,
|
|
53
51
|
directory: { status: 'idle' },
|
|
54
52
|
...shell,
|
|
55
|
-
} as unknown as GroveShell;
|
|
53
|
+
}) as unknown as GroveShell;
|
|
54
|
+
|
|
55
|
+
const mount = async (shell: Partial<GroveShell>) => {
|
|
56
|
+
const host = document.createElement('div');
|
|
57
|
+
document.body.appendChild(host);
|
|
58
|
+
const full: GroveShell = fullShell(shell);
|
|
56
59
|
await act(async () => {
|
|
57
60
|
createRoot(host).render(
|
|
58
61
|
<TinkerableContext.Provider value={NAV as never}>
|
|
@@ -87,3 +90,140 @@ describe('the theme menu (R3-308 — two independent axes)', () => {
|
|
|
87
90
|
expect(host.querySelectorAll('.gtm__row').length).toBeGreaterThan(1);
|
|
88
91
|
});
|
|
89
92
|
});
|
|
93
|
+
|
|
94
|
+
describe('the theme menu contract (R3-608)', () => {
|
|
95
|
+
const openMenu = async (over: Partial<GroveShell>) => {
|
|
96
|
+
const host = await mount({ menuOpen: true, theme: 'default', light: false, ...over });
|
|
97
|
+
return host;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
it('Escape returns focus to the theme button (and closes, via the shell state it drives)', async () => {
|
|
101
|
+
// The shell's setMenuOpen drives the re-render like the real one would:
|
|
102
|
+
// focus-return rides the hook's cleanup when the menu actually closes.
|
|
103
|
+
const host = document.createElement('div');
|
|
104
|
+
document.body.appendChild(host);
|
|
105
|
+
const root = createRoot(host);
|
|
106
|
+
const shell = { menuOpen: false, theme: 'default', light: false, setMenuOpen: vi.fn() } as Parameters<typeof fullShell>[0];
|
|
107
|
+
await act(async () => {
|
|
108
|
+
root.render(
|
|
109
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
110
|
+
<GroveShellContext.Provider value={fullShell(shell)}>
|
|
111
|
+
<GroveNav />
|
|
112
|
+
</GroveShellContext.Provider>
|
|
113
|
+
</TinkerableContext.Provider>,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
const themeButton = host.querySelector('.grove-theme-control') as HTMLButtonElement;
|
|
117
|
+
themeButton.focus();
|
|
118
|
+
const renderWith = (menuOpen: boolean) =>
|
|
119
|
+
act(async () => {
|
|
120
|
+
root.render(
|
|
121
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
122
|
+
<GroveShellContext.Provider value={fullShell({ ...shell, menuOpen })}>
|
|
123
|
+
<GroveNav />
|
|
124
|
+
</GroveShellContext.Provider>
|
|
125
|
+
</TinkerableContext.Provider>,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
shell.setMenuOpen = vi.fn(((v: boolean) => {
|
|
129
|
+
if (v === false) void renderWith(false);
|
|
130
|
+
}) as GroveShell['setMenuOpen']);
|
|
131
|
+
await renderWith(true);
|
|
132
|
+
// focus-in: the menu's first control holds focus.
|
|
133
|
+
const first = host.querySelector('[role="menuitemradio"]') as HTMLElement;
|
|
134
|
+
expect(document.activeElement).toBe(first);
|
|
135
|
+
await act(async () => {
|
|
136
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
137
|
+
});
|
|
138
|
+
expect(shell.setMenuOpen).toHaveBeenCalledWith(false);
|
|
139
|
+
expect(host.querySelector('.grove-theme-menu')).toBeNull(); // the menu closed
|
|
140
|
+
expect(document.activeElement).toBe(themeButton);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('arrows move among the menuitems and SELECT the radio they land on', async () => {
|
|
144
|
+
const setTheme = vi.fn();
|
|
145
|
+
const host = await openMenu({ setTheme });
|
|
146
|
+
const items = [...host.querySelectorAll('[role^="menuitem"]')] as HTMLElement[];
|
|
147
|
+
expect(items.length).toBeGreaterThanOrEqual(3); // themes + dark/light
|
|
148
|
+
items[0].focus();
|
|
149
|
+
await act(async () => {
|
|
150
|
+
items[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
|
151
|
+
});
|
|
152
|
+
expect(document.activeElement).toBe(items[1]);
|
|
153
|
+
// The landed radio is selected when it was not already checked.
|
|
154
|
+
expect(setTheme).toHaveBeenCalledTimes(items[1].getAttribute('aria-checked') === 'true' ? 0 : 1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('the theme menu Tab contract (R3-608 round 2)', () => {
|
|
159
|
+
it('Tab LEAVES the menu and closes it — never stranded over the scrim', async () => {
|
|
160
|
+
let renderWith: (menuOpen: boolean) => Promise<void> = async () => undefined;
|
|
161
|
+
const shell = { menuOpen: false, theme: 'default', light: false, setMenuOpen: vi.fn() } as Parameters<typeof fullShell>[0];
|
|
162
|
+
const host = document.createElement('div');
|
|
163
|
+
document.body.appendChild(host);
|
|
164
|
+
const root = createRoot(host);
|
|
165
|
+
renderWith = (menuOpen: boolean) =>
|
|
166
|
+
act(async () => {
|
|
167
|
+
root.render(
|
|
168
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
169
|
+
<GroveShellContext.Provider value={fullShell({ ...shell, menuOpen })}>
|
|
170
|
+
<GroveNav />
|
|
171
|
+
</GroveShellContext.Provider>
|
|
172
|
+
</TinkerableContext.Provider>,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
shell.setMenuOpen = vi.fn(((v: boolean) => {
|
|
176
|
+
if (v === false) void renderWith(false);
|
|
177
|
+
}) as GroveShell['setMenuOpen']);
|
|
178
|
+
await renderWith(true);
|
|
179
|
+
expect(host.querySelector('.grove-theme-menu')).toBeTruthy();
|
|
180
|
+
const first = host.querySelector('[role="menuitemradio"]') as HTMLElement;
|
|
181
|
+
await act(async () => {
|
|
182
|
+
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
|
183
|
+
});
|
|
184
|
+
expect(shell.setMenuOpen).toHaveBeenCalledWith(false);
|
|
185
|
+
expect(host.querySelector('.grove-theme-menu')).toBeNull(); // closed, not stranded
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('the theme menu Tab contract (R3-608 round 3)', () => {
|
|
190
|
+
it('Tab-close leaves focus where the browser moved it — never yanked back to the trigger', async () => {
|
|
191
|
+
let renderWith: (menuOpen: boolean) => Promise<void> = async () => undefined;
|
|
192
|
+
const shell = { menuOpen: false, theme: 'default', light: false, setMenuOpen: vi.fn() } as Parameters<typeof fullShell>[0];
|
|
193
|
+
const host = document.createElement('div');
|
|
194
|
+
document.body.appendChild(host);
|
|
195
|
+
const root = createRoot(host);
|
|
196
|
+
renderWith = (menuOpen: boolean) =>
|
|
197
|
+
act(async () => {
|
|
198
|
+
root.render(
|
|
199
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
200
|
+
<GroveShellContext.Provider value={fullShell({ ...shell, menuOpen })}>
|
|
201
|
+
<GroveNav />
|
|
202
|
+
</GroveShellContext.Provider>
|
|
203
|
+
</TinkerableContext.Provider>,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
shell.setMenuOpen = vi.fn(((v: boolean) => {
|
|
207
|
+
if (v === false) void renderWith(false);
|
|
208
|
+
}) as GroveShell['setMenuOpen']);
|
|
209
|
+
await renderWith(true);
|
|
210
|
+
// The page's next tab stop AFTER the menu (the real contract's landing
|
|
211
|
+
// spot for a Tab-leave).
|
|
212
|
+
const after = document.createElement('button');
|
|
213
|
+
after.textContent = 'next stop';
|
|
214
|
+
host.appendChild(after);
|
|
215
|
+
const first = host.querySelector('[role="menuitemradio"]') as HTMLElement;
|
|
216
|
+
first.focus();
|
|
217
|
+
// Focus the next stop the way the browser's default Tab move would land.
|
|
218
|
+
await act(async () => {
|
|
219
|
+
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
|
220
|
+
after.focus(); // the default move this key authorizes
|
|
221
|
+
});
|
|
222
|
+
expect(shell.setMenuOpen).toHaveBeenCalledWith(false);
|
|
223
|
+
expect(host.querySelector('.grove-theme-menu')).toBeNull();
|
|
224
|
+
// The move stands: focus is NOT pulled back to the theme trigger.
|
|
225
|
+
const themeButton = host.querySelector('.grove-theme-control') as HTMLButtonElement;
|
|
226
|
+
expect(document.activeElement).toBe(after);
|
|
227
|
+
expect(document.activeElement).not.toBe(themeButton);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Link } from '@immediately-run/sdk';
|
|
2
|
-
import { useShell } from '../lib/shell';
|
|
2
|
+
import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
|
|
3
3
|
import { getContentRoot } from '../lib/contentRoot';
|
|
4
|
+
import { useOverlayFocusDismiss } from '../hooks/useOverlayFocusDismiss';
|
|
4
5
|
import { THEMES } from '../data/themes';
|
|
5
6
|
import Icon from './Icon';
|
|
6
7
|
|
|
@@ -14,6 +15,9 @@ export default function GroveNav() {
|
|
|
14
15
|
entryKey,
|
|
15
16
|
writable,
|
|
16
17
|
openEditor,
|
|
18
|
+
editBusy,
|
|
19
|
+
editRefused,
|
|
20
|
+
editHint,
|
|
17
21
|
theme,
|
|
18
22
|
setTheme,
|
|
19
23
|
light,
|
|
@@ -33,6 +37,28 @@ export default function GroveNav() {
|
|
|
33
37
|
// would create a file in Grove's own repo (R3-266).
|
|
34
38
|
const newEntry = () => openEditor(`${getContentRoot()}untitled.mdx`);
|
|
35
39
|
|
|
40
|
+
// The theme menu's contract (R3-608): the shared overlay hook with Tab-trap
|
|
41
|
+
// OFF (menus do not trap Tab), plus the menu keydown map — arrows walk the
|
|
42
|
+
// menuitems, Escape closes (the hook), focus returns to the theme button.
|
|
43
|
+
const menuRef = useOverlayFocusDismiss(menuOpen, () => setMenuOpen(false), { trapTab: false });
|
|
44
|
+
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
45
|
+
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [])];
|
|
46
|
+
if (!items.length) return;
|
|
47
|
+
const idx = items.indexOf(document.activeElement as HTMLElement);
|
|
48
|
+
const move = (i: number) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
const next = items[(i + items.length) % items.length];
|
|
51
|
+
next.focus();
|
|
52
|
+
// APG menu radios: arrows MOVE focus AND activate — the radio the arrow
|
|
53
|
+
// lands on is selected, so the keyboard alone can change the theme.
|
|
54
|
+
if (next.getAttribute('role') === 'menuitemradio' && next.getAttribute('aria-checked') !== 'true') next.click();
|
|
55
|
+
};
|
|
56
|
+
if (e.key === 'ArrowDown') move(idx + 1);
|
|
57
|
+
else if (e.key === 'ArrowUp') move(idx - 1);
|
|
58
|
+
else if (e.key === 'Home') move(0);
|
|
59
|
+
else if (e.key === 'End') move(items.length - 1);
|
|
60
|
+
};
|
|
61
|
+
|
|
36
62
|
return (
|
|
37
63
|
<nav className="grove-nav">
|
|
38
64
|
<button className="grove-hamburger icbtn" aria-label="Menu" onClick={() => setDrawerOpen(true)}>
|
|
@@ -54,21 +80,35 @@ export default function GroveNav() {
|
|
|
54
80
|
<Icon name="search" />
|
|
55
81
|
</button>
|
|
56
82
|
{writable && (
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
83
|
+
<>
|
|
84
|
+
<button
|
|
85
|
+
className="icbtn"
|
|
86
|
+
aria-label={editBusy ? 'Opening editor…' : 'New entry'}
|
|
87
|
+
title={editHint}
|
|
88
|
+
data-busy={editBusy ? '1' : '0'}
|
|
89
|
+
disabled={editBusy}
|
|
90
|
+
onClick={newEntry}
|
|
91
|
+
>
|
|
92
|
+
<Icon name="plus" />
|
|
93
|
+
</button>
|
|
94
|
+
{editRefused && (
|
|
95
|
+
<span className="grove-edit-refused" role="status" title={EDIT_REFUSED_NOTICE}>
|
|
96
|
+
{EDIT_REFUSED_NOTICE}
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
</>
|
|
60
100
|
)}
|
|
61
101
|
<button className="icbtn" aria-label="Ask Grove" onClick={askGrove}>
|
|
62
102
|
<Icon name="message" />
|
|
63
103
|
</button>
|
|
64
104
|
<div className="grove-nav__more">
|
|
65
|
-
<button className="icbtn" title="Theme" aria-expanded={menuOpen} onClick={() => setMenuOpen((o) => !o)}>
|
|
105
|
+
<button className="icbtn grove-theme-control" title="Theme" aria-expanded={menuOpen} aria-haspopup="menu" onClick={() => setMenuOpen((o) => !o)}>
|
|
66
106
|
☀
|
|
67
107
|
</button>
|
|
68
108
|
{menuOpen ? (
|
|
69
109
|
<>
|
|
70
110
|
<div className="gtm__scrim" onClick={() => setMenuOpen(false)} />
|
|
71
|
-
<div className="grove-theme-menu" role="menu">
|
|
111
|
+
<div className="grove-theme-menu" role="menu" aria-label="Theme" ref={menuRef} tabIndex={-1} onKeyDown={onMenuKeyDown}>
|
|
72
112
|
<div className="gtm__h">Theme</div>
|
|
73
113
|
<div className="gtm__list">
|
|
74
114
|
{THEMES.map((t) => (
|
|
@@ -76,6 +116,9 @@ export default function GroveNav() {
|
|
|
76
116
|
key={t.id}
|
|
77
117
|
className="gtm__row"
|
|
78
118
|
data-cur={theme === t.id ? '1' : '0'}
|
|
119
|
+
role="menuitemradio"
|
|
120
|
+
aria-checked={theme === t.id}
|
|
121
|
+
tabIndex={-1}
|
|
79
122
|
onClick={() => setTheme(t.id)}
|
|
80
123
|
>
|
|
81
124
|
<span className="gtm__sw" style={{ background: t.swatch }} />
|
|
@@ -90,10 +133,10 @@ export default function GroveNav() {
|
|
|
90
133
|
<div className="gtm__appearance">
|
|
91
134
|
<div className="gtm__sub">Appearance</div>
|
|
92
135
|
<div className="gtm__seg">
|
|
93
|
-
<button data-on={!light ? '1' : '0'} onClick={() => setLight(false)}>
|
|
136
|
+
<button data-on={!light ? '1' : '0'} role="menuitemradio" aria-checked={!light} tabIndex={-1} onClick={() => setLight(false)}>
|
|
94
137
|
<Icon name="moon" /> Dark
|
|
95
138
|
</button>
|
|
96
|
-
<button data-on={light ? '1' : '0'} onClick={() => setLight(true)}>
|
|
139
|
+
<button data-on={light ? '1' : '0'} role="menuitemradio" aria-checked={light} tabIndex={-1} onClick={() => setLight(true)}>
|
|
97
140
|
<Icon name="sun" /> Light
|
|
98
141
|
</button>
|
|
99
142
|
</div>
|
package/src/components/Icon.tsx
CHANGED
|
@@ -28,7 +28,6 @@ const PATHS: Record<string, string> = {
|
|
|
28
28
|
message: 'M21 11.5a8.5 8.5 0 01-12.3 7.6L3 21l1.9-5.7A8.5 8.5 0 1121 11.5z',
|
|
29
29
|
shield: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z',
|
|
30
30
|
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
|
|
31
|
-
stop: 'M6 6h12v12H6z',
|
|
32
31
|
};
|
|
33
32
|
|
|
34
33
|
interface IconProps extends SVGProps<SVGSVGElement> {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// R3-531 — the one component that renders a frontmatter prose field: a
|
|
2
|
+
// `title`/`description`/`scope` parsed by `@immediately-run/mdx-plugins`'s
|
|
3
|
+
// `parseInlineProse` into `code`/`strong`/`em` and text. Every list row, card,
|
|
4
|
+
// header, search hit and sidebar label renders its metadata field through this,
|
|
5
|
+
// so a backtick in the corpus prints as a code span and never as body-font
|
|
6
|
+
// punctuation. The docs fork copies this file VERBATIM (its convention for
|
|
7
|
+
// engine components) — which is why it imports nothing grove-local.
|
|
8
|
+
//
|
|
9
|
+
// Links, images, HTML and JSX are not inline prose and never will be: a title
|
|
10
|
+
// is not a body. Fields that become attributes, labels or prompt strings use
|
|
11
|
+
// the parser's `plainProse` directly instead of this component.
|
|
12
|
+
|
|
13
|
+
import { Fragment } from 'react';
|
|
14
|
+
import { parseInlineProse, type InlineProseNode } from '@immediately-run/mdx-plugins';
|
|
15
|
+
|
|
16
|
+
function renderNodes(nodes: InlineProseNode[]): React.ReactNode[] {
|
|
17
|
+
return nodes.map((n, i) => {
|
|
18
|
+
if (n.type === 'code') return <code key={i}>{n.value}</code>;
|
|
19
|
+
if (n.type === 'strong') return <strong key={i}>{renderNodes(n.children)}</strong>;
|
|
20
|
+
if (n.type === 'emphasis') return <em key={i}>{renderNodes(n.children)}</em>;
|
|
21
|
+
return <Fragment key={i}>{n.value}</Fragment>;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function InlineProse({
|
|
26
|
+
text,
|
|
27
|
+
trimPeriod = false,
|
|
28
|
+
}: {
|
|
29
|
+
text: string;
|
|
30
|
+
/** Trim one trailing period, as the list components always have. */
|
|
31
|
+
trimPeriod?: boolean;
|
|
32
|
+
}) {
|
|
33
|
+
// Trim before parsing: the corpus ends titles on a period, and the period
|
|
34
|
+
// sits OUTSIDE any code span, so a raw-string slice is the whole job.
|
|
35
|
+
const src = trimPeriod && text.endsWith('.') ? text.slice(0, -1) : text;
|
|
36
|
+
return <Fragment>{renderNodes(parseInlineProse(src))}</Fragment>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { manifestLayouts, manifestPageVariants, manifestCollections } from '../data/catalogue';
|
|
2
|
+
|
|
3
|
+
// The layouts gallery (R3-311): the twelve shapes, grouped by the three buckets
|
|
4
|
+
// the catalogue exists as three of — because the MECHANISM differs per bucket (a
|
|
5
|
+
// copyable starter file · a frontmatter key · a component call), and one flat
|
|
6
|
+
// list would promise what a starter cannot deliver. Enumerated from
|
|
7
|
+
// viewer.manifest.json: adding a shape adds a row with no edit to the entry.
|
|
8
|
+
|
|
9
|
+
function Section({ title, note, children }: { title: string; note: string; children: React.ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<section className="gg-section">
|
|
12
|
+
<h3>{title}</h3>
|
|
13
|
+
<p className="gg-note">{note}</p>
|
|
14
|
+
{children}
|
|
15
|
+
</section>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function LayoutGallery() {
|
|
20
|
+
return (
|
|
21
|
+
<div className="gg-gallery">
|
|
22
|
+
<Section title="Layout starters" note="An `_layout.mdx` file a contributor copies — chrome is content.">
|
|
23
|
+
{manifestLayouts().map(([id, l]) => (
|
|
24
|
+
<div className="gg-row" role="listitem" key={id} data-gallery-id={id}>
|
|
25
|
+
<div className="gg-body">
|
|
26
|
+
<div className="gg-title">
|
|
27
|
+
<code>{id}</code>
|
|
28
|
+
<span className="gg-meta">{l.arranges}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="gg-summary">{l.summary}</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
))}
|
|
34
|
+
</Section>
|
|
35
|
+
<Section title="Page variants" note="A frontmatter key on one entry — the page, not the chrome.">
|
|
36
|
+
{manifestPageVariants().map(([id, v]) => (
|
|
37
|
+
<div className="gg-row" role="listitem" key={id} data-gallery-id={id}>
|
|
38
|
+
<div className="gg-body">
|
|
39
|
+
<div className="gg-title">
|
|
40
|
+
<code>
|
|
41
|
+
{v.key}: {v.value}
|
|
42
|
+
</code>
|
|
43
|
+
<span className="gg-meta">{id}</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="gg-summary">{v.summary}</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</Section>
|
|
50
|
+
<Section title="Collection shapes" note="What a list of entries looks like — a component call.">
|
|
51
|
+
{manifestCollections().map(([id, c]) => (
|
|
52
|
+
<div className="gg-row" role="listitem" key={id} data-gallery-id={id}>
|
|
53
|
+
<div className="gg-body">
|
|
54
|
+
<div className="gg-title">
|
|
55
|
+
<code>{id}</code>
|
|
56
|
+
{c.component && <span className="gg-meta"><{c.component}></span>}
|
|
57
|
+
</div>
|
|
58
|
+
<div className="gg-summary">{c.summary}</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</Section>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { Include, Link } from '@immediately-run/sdk';
|
|
3
|
-
import { useShell } from '../lib/shell';
|
|
3
|
+
import { useShell, EDIT_REFUSED_NOTICE } from '../lib/shell';
|
|
4
4
|
import { keyToHref, keyToRepoRel } from '../lib/content';
|
|
5
5
|
import { crumb } from '../lib/wiki';
|
|
6
6
|
import DirectoryView from './DirectoryView';
|
|
@@ -19,7 +19,7 @@ declare const module: any;
|
|
|
19
19
|
// site chrome (nav / sidebar / footer) — that's the layout's job — so the page
|
|
20
20
|
// stays free of shell concerns.
|
|
21
21
|
export default function PageView() {
|
|
22
|
-
const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, openEditor, vw, safe, directory } =
|
|
22
|
+
const { entryKey, includePath, layout, showRails, mins, missing, suggestion, writable, openEditor, editBusy, editRefused, editHint, vw, safe, directory } =
|
|
23
23
|
useShell();
|
|
24
24
|
|
|
25
25
|
// A folder URL. `checking` renders nothing rather than the 404: the readdir that
|
|
@@ -39,7 +39,22 @@ 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 ?
|
|
42
|
+
{writable ? (
|
|
43
|
+
<>
|
|
44
|
+
<button
|
|
45
|
+
className="btn-primary"
|
|
46
|
+
data-busy={editBusy ? '1' : '0'}
|
|
47
|
+
title={editHint}
|
|
48
|
+
disabled={editBusy}
|
|
49
|
+
onClick={() => openEditor(entryKey)}
|
|
50
|
+
>
|
|
51
|
+
<Icon name="file-plus" />{editBusy ? 'Opening editor…' : 'Create it'}
|
|
52
|
+
</button>
|
|
53
|
+
{editRefused && (
|
|
54
|
+
<span className="grove-edit-refused" role="status">{EDIT_REFUSED_NOTICE}</span>
|
|
55
|
+
)}
|
|
56
|
+
</>
|
|
57
|
+
) : null}
|
|
43
58
|
</div>
|
|
44
59
|
</div>
|
|
45
60
|
);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// The ⌘K palette (R3-608): Escape returns focus to the REAL trigger, and
|
|
3
|
+
// `aria-activedescendant` tracks the highlight through the REAL fuzzy filter
|
|
4
|
+
// over a real entry list — only the attributes are new, so the assertions ride
|
|
5
|
+
// the same matcher the production query uses.
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { act } from 'react';
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
10
|
+
import { TinkerableContext } from '@immediately-run/sdk/TinkerableContext';
|
|
11
|
+
|
|
12
|
+
const { default: Search } = await import('./Search');
|
|
13
|
+
|
|
14
|
+
const FILES = {
|
|
15
|
+
'/app/content/home.mdx': { title: 'Home.', desc: '', tags: [] },
|
|
16
|
+
'/app/content/wiki/security.mdx': { title: 'Security.', desc: 'threats', tags: ['ops'] },
|
|
17
|
+
'/app/content/wiki/guide/api.mdx': { title: 'API guide.', desc: 'endpoints', tags: ['dev'] },
|
|
18
|
+
'/app/content/wiki/guide/deploy.mdx': { title: 'Deploying.', desc: 'shipping', tags: ['dev', 'ops'] },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const NAV = {
|
|
22
|
+
outerHref: 'https://example.immediately.run/app/x',
|
|
23
|
+
navigationState: { sandboxPath: '/app/x', provider: 'github', namespace: 'immediately-run', repository: 'docs', ref: 'main', hash: '', search: '' },
|
|
24
|
+
routingSpec: {} as never,
|
|
25
|
+
filesMetadata: FILES,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Search mounted open; closing UNMOUNTS it (the parent's job — focus return
|
|
29
|
+
* rides the unmount cleanup). */
|
|
30
|
+
function SearchHost({ onClose }: { onClose: () => void }) {
|
|
31
|
+
const [show, setShow] = useState(true);
|
|
32
|
+
return (
|
|
33
|
+
<div>
|
|
34
|
+
<button onClick={onClose}>trigger</button>
|
|
35
|
+
{show ? (
|
|
36
|
+
<Search
|
|
37
|
+
onClose={() => {
|
|
38
|
+
setShow(false);
|
|
39
|
+
onClose();
|
|
40
|
+
}}
|
|
41
|
+
/>
|
|
42
|
+
) : null}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function mountSearch(onClose: () => void, preFocus?: HTMLElement): Promise<{ root: Root; container: HTMLElement }> {
|
|
48
|
+
// The trigger must hold focus BEFORE the palette mounts — the hook captures
|
|
49
|
+
// it at open as the focus-return target.
|
|
50
|
+
preFocus?.focus();
|
|
51
|
+
const container = document.createElement('div');
|
|
52
|
+
document.body.appendChild(container);
|
|
53
|
+
const root = createRoot(container);
|
|
54
|
+
await act(async () => {
|
|
55
|
+
root.render(
|
|
56
|
+
<TinkerableContext.Provider value={NAV as never}>
|
|
57
|
+
<SearchHost onClose={onClose} />
|
|
58
|
+
</TinkerableContext.Provider>,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
return { root, container };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('the ⌘K palette (R3-608)', () => {
|
|
65
|
+
it('Escape closes and focus RETURNS to the real trigger', async () => {
|
|
66
|
+
const onClose = vi.fn();
|
|
67
|
+
const trigger = document.createElement('button');
|
|
68
|
+
trigger.textContent = 'trigger';
|
|
69
|
+
document.body.appendChild(trigger);
|
|
70
|
+
const { container } = await mountSearch(onClose, trigger);
|
|
71
|
+
// The palette moved focus INTO its input on open (the hook's focus-in).
|
|
72
|
+
const input = container.querySelector('input[role="combobox"]') as HTMLInputElement;
|
|
73
|
+
expect(document.activeElement).toBe(input);
|
|
74
|
+
await act(async () => {
|
|
75
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
|
|
76
|
+
});
|
|
77
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
78
|
+
expect(document.activeElement).toBe(trigger);
|
|
79
|
+
trigger.remove();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('aria-activedescendant tracks the highlight through the real fuzzy filter', async () => {
|
|
83
|
+
const onClose = vi.fn();
|
|
84
|
+
const { container } = await mountSearch(onClose);
|
|
85
|
+
const input = container.querySelector('input[role="combobox"]') as HTMLInputElement;
|
|
86
|
+
// Combobox semantics present.
|
|
87
|
+
expect(input.getAttribute('aria-controls')).toBeTruthy();
|
|
88
|
+
const list = container.querySelector(`#${input.getAttribute('aria-controls')}`) as HTMLElement;
|
|
89
|
+
expect(list.getAttribute('role')).toBe('listbox');
|
|
90
|
+
// Unfiltered: the recent list's first entry is highlighted.
|
|
91
|
+
const first = input.getAttribute('aria-activedescendant');
|
|
92
|
+
expect(first).toBeTruthy();
|
|
93
|
+
expect(document.getElementById(first!)).toBeTruthy();
|
|
94
|
+
// ArrowDown moves the highlight; the attribute names the NEW option.
|
|
95
|
+
await act(async () => {
|
|
96
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
|
97
|
+
});
|
|
98
|
+
const second = input.getAttribute('aria-activedescendant');
|
|
99
|
+
expect(second).not.toBe(first);
|
|
100
|
+
expect(document.getElementById(second!)?.getAttribute('aria-selected')).toBe('true');
|
|
101
|
+
// The real filter: typing narrows the list and resets the highlight to the
|
|
102
|
+
// top — and the referenced option is one the matcher kept.
|
|
103
|
+
await act(async () => {
|
|
104
|
+
// The native value setter: React's tracker ignores a bare .value assign.
|
|
105
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
|
|
106
|
+
setter.call(input, 'api');
|
|
107
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
108
|
+
});
|
|
109
|
+
const after = input.getAttribute('aria-activedescendant');
|
|
110
|
+
expect(after).toBeTruthy();
|
|
111
|
+
expect(document.getElementById(after!)?.textContent).toContain('API guide');
|
|
112
|
+
});
|
|
113
|
+
});
|