@ultimat3/cli 20.1.6 → 20.2.0
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/package.json +29 -29
- package/src/budgets.ts +17 -2
- package/src/cmd-dev.ts +14 -15
- package/src/cmd-mcp.ts +2 -2
- package/src/cmd-shot.ts +19 -5
- package/src/dev-render.ts +9 -1
- package/src/dev-roles.ts +6 -6
- package/src/error-codes.ts +20 -2
- package/src/error-page-csp.ts +50 -0
- package/src/mcp-errors.ts +20 -1
- package/src/mcp-ui-diff.ts +141 -0
- package/src/mcp-ui-inspect.ts +183 -0
- package/src/mcp-ui-interact.ts +304 -0
- package/src/mcp-ui.ts +53 -15
- package/src/output.ts +1 -1
- package/src/prerender.ts +3 -0
- package/src/scaffold-typecheck.ts +5 -0
- package/src/script-csp.ts +5 -2
- package/src/serve.ts +8 -0
- package/src/templates/index.ts +5 -1
- package/src/templates/scaffold-app.ts +14 -155
- package/src/templates/scaffold-dashboard-bare.ts +182 -0
- package/src/templates/scaffold-dashboard-example.ts +326 -0
- package/src/templates/scaffold-dashboard-shared.ts +74 -0
- package/src/templates/scaffold-dashboard.ts +27 -0
- package/src/templates/scaffold-errors.ts +128 -0
- package/src/templates/scaffold-i18n.ts +46 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +461 -0
- package/src/templates/scaffold-site.ts +287 -0
- package/src/theme-boot.ts +59 -0
- package/src/ui-diff.ts +90 -0
- package/src/ui-inspect-probe.ts +131 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
// The generated app's frame — `apps/web/shared/shell.tsx` over the catalog's `AppShell` — and the
|
|
2
|
+
// one island the scaffold ships, the theme toggle. Split out of `scaffold-app.ts` because that file
|
|
3
|
+
// is the three surfaces and this is the chrome every `app/` page sits inside. Before this the
|
|
4
|
+
// dashboard was an `<h1>` in a panel and the scaffold imported nothing from `@ultimat3/ui`; an app
|
|
5
|
+
// that starts from it now starts from a designed product, and deletes what it does not want.
|
|
6
|
+
|
|
7
|
+
import { sortedImports } from './imports';
|
|
8
|
+
import { upToAppRoot } from './island';
|
|
9
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
10
|
+
|
|
11
|
+
const SHELL_DIR = 'apps/web/shared';
|
|
12
|
+
|
|
13
|
+
/** `nav` on the shell names the destinations; `--no-example` has no `/posts` to point at. */
|
|
14
|
+
const navType = (example: boolean): string => (example ? "'dashboard' | 'posts'" : "'dashboard'");
|
|
15
|
+
|
|
16
|
+
const postsItem = (example: boolean): string =>
|
|
17
|
+
example ? `\n {item('posts', '/posts', t('shell.nav.posts'), iconList)}` : '';
|
|
18
|
+
|
|
19
|
+
// Plain strings for the framework lines, never template literals: the workspace-dependency scanner
|
|
20
|
+
// blanks a string's contents but not a nested template's, so a template here would bill the CLI
|
|
21
|
+
// for the imports of the app it writes.
|
|
22
|
+
const shell = (
|
|
23
|
+
app: NameSet,
|
|
24
|
+
example: boolean,
|
|
25
|
+
): string => `// The app's frame: brand mark, the sidebar with one link per destination, an environment pill,
|
|
26
|
+
// the page's own actions at the end of the bar, and a footer — composed over the catalog's
|
|
27
|
+
// \`AppShell\`, so the skip link, the landmarks and the phone layout are the framework's and not
|
|
28
|
+
// this file's. \`site/page.tsx\` does NOT use it: the landing page is a 0kb document and this is the
|
|
29
|
+
// signed-in product's chrome. Every page under \`app/\` does.
|
|
30
|
+
//
|
|
31
|
+
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
32
|
+
${sortedImports([
|
|
33
|
+
`import { useT } from '@${app.kebab}/i18n';`,
|
|
34
|
+
"import { tryResolveEnvironment } from '@ultimat3/core';",
|
|
35
|
+
"import { AppShell, Icon, type IconGlyph, Link } from '@ultimat3/ui';",
|
|
36
|
+
"import { iconHexagon } from '@ultimat3/ui/icons/hexagon';",
|
|
37
|
+
"import { iconLayoutDashboard } from '@ultimat3/ui/icons/layout-dashboard';",
|
|
38
|
+
...(example ? ["import { iconList } from '@ultimat3/ui/icons/list';"] : []),
|
|
39
|
+
"import type { JSX } from 'solid-js';",
|
|
40
|
+
])}
|
|
41
|
+
import styles from './shell.module.scss';
|
|
42
|
+
|
|
43
|
+
/** The routes the sidebar knows about. A page names itself so its link can carry aria-current. */
|
|
44
|
+
export type ShellNav = ${navType(example)};
|
|
45
|
+
|
|
46
|
+
export interface ShellProps {
|
|
47
|
+
readonly nav?: ShellNav | undefined;
|
|
48
|
+
/** Controls that belong to the page, rendered at the end of the header bar — a theme toggle. */
|
|
49
|
+
readonly actions?: JSX.Element | undefined;
|
|
50
|
+
readonly children: JSX.Element;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function Shell(props: ShellProps): JSX.Element {
|
|
54
|
+
const t = useT();
|
|
55
|
+
// Where this build runs — a fact read from the process, never a translated word. Absent when
|
|
56
|
+
// neither ULTIMATE_ENV nor NODE_ENV names one, and the pill is absent with it.
|
|
57
|
+
const environment = tryResolveEnvironment();
|
|
58
|
+
|
|
59
|
+
const item = (id: ShellNav, href: string, label: string, glyph: IconGlyph): JSX.Element => (
|
|
60
|
+
<li>
|
|
61
|
+
<Link
|
|
62
|
+
class={styles.navLink}
|
|
63
|
+
href={href}
|
|
64
|
+
tone="inherit"
|
|
65
|
+
underline="none"
|
|
66
|
+
aria-current={props.nav === id ? 'page' : false}
|
|
67
|
+
>
|
|
68
|
+
<Icon glyph={glyph} size="sm" class={styles.navIcon} />
|
|
69
|
+
<span>{label}</span>
|
|
70
|
+
</Link>
|
|
71
|
+
</li>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div class={styles.doc}>
|
|
76
|
+
<AppShell
|
|
77
|
+
sidebarWidth="13.5rem"
|
|
78
|
+
stickyHeader
|
|
79
|
+
header={
|
|
80
|
+
<div class={styles.bar}>
|
|
81
|
+
<Link class={styles.brand} href="/" tone="inherit" underline="none">
|
|
82
|
+
{/* The mark: one accent tile, one glyph. Decorative — the wordmark beside it is the name. */}
|
|
83
|
+
<span class={styles.mark} aria-hidden="true">
|
|
84
|
+
<Icon glyph={iconHexagon} size="sm" />
|
|
85
|
+
</span>
|
|
86
|
+
<span class={styles.wordmark}>{t('shell.brand')}</span>
|
|
87
|
+
</Link>
|
|
88
|
+
<div class={styles.end}>
|
|
89
|
+
{environment === undefined ? null : (
|
|
90
|
+
<span class={styles.env}>
|
|
91
|
+
{/* The pulse is a CSS opacity animation: alive without a byte of script. */}
|
|
92
|
+
<span class={styles.envDot} aria-hidden="true" />
|
|
93
|
+
{environment}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
{props.actions}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
}
|
|
100
|
+
sidebar={
|
|
101
|
+
<ul class={styles.nav}>
|
|
102
|
+
{item('dashboard', '/dashboard', t('shell.nav.dashboard'), iconLayoutDashboard)}${postsItem(example)}
|
|
103
|
+
</ul>
|
|
104
|
+
}
|
|
105
|
+
footer={<span>{t('shell.footer')}</span>}
|
|
106
|
+
>
|
|
107
|
+
{props.children}
|
|
108
|
+
</AppShell>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
115
|
+
|
|
116
|
+
// \`display: contents\` so the wrapper vanishes from the box tree: AppShell's own grid is the
|
|
117
|
+
// layout, and this div exists only to scope the rules below.
|
|
118
|
+
.doc {
|
|
119
|
+
display: contents;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.bar {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: space-between;
|
|
126
|
+
gap: tokens.space(4);
|
|
127
|
+
inline-size: 100%;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.end {
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: tokens.space(3);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.brand {
|
|
137
|
+
display: inline-flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: tokens.space(3);
|
|
140
|
+
color: tokens.role('fg-strong');
|
|
141
|
+
|
|
142
|
+
&:hover .mark {
|
|
143
|
+
background: tokens.role('accent', 0.22);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// One accent tile. The only saturated surface in the chrome, so the eye has exactly one anchor.
|
|
148
|
+
.mark {
|
|
149
|
+
display: inline-grid;
|
|
150
|
+
place-items: center;
|
|
151
|
+
inline-size: tokens.space(8);
|
|
152
|
+
block-size: tokens.space(8);
|
|
153
|
+
border: 1px solid tokens.role('accent', 0.35);
|
|
154
|
+
border-radius: tokens.radius('md');
|
|
155
|
+
background: tokens.role('accent', 0.12);
|
|
156
|
+
color: tokens.role('accent');
|
|
157
|
+
transition: background-color tokens.duration('fast') tokens.easing('out');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.wordmark {
|
|
161
|
+
@include tokens.data-text;
|
|
162
|
+
|
|
163
|
+
font-size: tokens.text('sm');
|
|
164
|
+
font-weight: tokens.weight('medium');
|
|
165
|
+
letter-spacing: tokens.tracking('tight');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.env {
|
|
169
|
+
@include tokens.data-text;
|
|
170
|
+
|
|
171
|
+
display: inline-flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
gap: tokens.space(2);
|
|
174
|
+
padding: tokens.space(1) tokens.space(3);
|
|
175
|
+
border: 1px solid tokens.role('line', 0.7);
|
|
176
|
+
border-radius: tokens.radius('pill');
|
|
177
|
+
color: tokens.role('fg-muted');
|
|
178
|
+
font-size: tokens.text('xs');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.envDot {
|
|
182
|
+
inline-size: tokens.space(2);
|
|
183
|
+
block-size: tokens.space(2);
|
|
184
|
+
border-radius: tokens.radius('full');
|
|
185
|
+
background: tokens.role('success');
|
|
186
|
+
animation: pulse 2.4s tokens.easing('in-out') infinite;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Opacity only — the one property family the motion guard admits, and the global reduced-motion
|
|
190
|
+
// rule switches it off.
|
|
191
|
+
@keyframes pulse {
|
|
192
|
+
50% {
|
|
193
|
+
opacity: 0.35;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.nav {
|
|
198
|
+
display: flex;
|
|
199
|
+
// A row on a phone — stacked links cost a fifth of the first screen — a rail from md up.
|
|
200
|
+
flex-direction: row;
|
|
201
|
+
gap: tokens.space(1);
|
|
202
|
+
margin: 0;
|
|
203
|
+
padding: 0;
|
|
204
|
+
overflow-x: auto;
|
|
205
|
+
list-style: none;
|
|
206
|
+
|
|
207
|
+
@include tokens.respond-to(md) {
|
|
208
|
+
flex-direction: column;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.navLink {
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: center;
|
|
215
|
+
gap: tokens.space(3);
|
|
216
|
+
padding: tokens.space(2) tokens.space(3);
|
|
217
|
+
border-radius: tokens.radius('md');
|
|
218
|
+
color: tokens.role('fg-muted');
|
|
219
|
+
font-size: tokens.text('sm');
|
|
220
|
+
transition:
|
|
221
|
+
color tokens.duration('fast') tokens.easing('out'),
|
|
222
|
+
background-color tokens.duration('fast') tokens.easing('out');
|
|
223
|
+
|
|
224
|
+
&:hover {
|
|
225
|
+
color: tokens.role('fg-strong');
|
|
226
|
+
background: tokens.role('surface');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
&[aria-current='page'] {
|
|
230
|
+
color: tokens.role('fg-strong');
|
|
231
|
+
background: tokens.role('surface-raised');
|
|
232
|
+
box-shadow: inset 0 0 0 1px tokens.role('line', 0.7);
|
|
233
|
+
|
|
234
|
+
.navIcon {
|
|
235
|
+
color: tokens.role('accent');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.navIcon {
|
|
241
|
+
flex: none;
|
|
242
|
+
color: tokens.role('fg-muted');
|
|
243
|
+
transition: color tokens.duration('fast') tokens.easing('out');
|
|
244
|
+
}
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
const shellTest =
|
|
248
|
+
(): string => `// The frame, rendered the way a route renders it: through the framework's server JSX factory,
|
|
249
|
+
// with the app's own catalog registered by the import. What can go wrong is structural — the
|
|
250
|
+
// current page losing its \`aria-current\`, the page's actions landing outside the banner — and
|
|
251
|
+
// both are invisible to a typecheck.
|
|
252
|
+
import { h, type JsxComponent } from '@ultimat3/render';
|
|
253
|
+
import { renderToHtml } from '@ultimat3/render/server';
|
|
254
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
255
|
+
|
|
256
|
+
// A DYNAMIC import, after the static ones above have run: importing \`@ultimat3/render\` is what
|
|
257
|
+
// installs the loader that compiles this app's \`.tsx\` to the framework's JSX factory, and a
|
|
258
|
+
// plugin only reaches modules loaded after it. Imported statically, \`shell.tsx\` is transpiled
|
|
259
|
+
// ahead of that install, to \`React.createElement\` against a global that does not exist — the
|
|
260
|
+
// route modules never meet this because \`x dev\` loads them after the framework.
|
|
261
|
+
//
|
|
262
|
+
// \`h\` is typed for the framework's own components; \`Shell\` carries Solid's JSX types. The cast
|
|
263
|
+
// is the seam itself — the assertions below are what check it at runtime.
|
|
264
|
+
const shell = (await import('./shell')).Shell as unknown as JsxComponent;
|
|
265
|
+
|
|
266
|
+
unitTest('the shell marks the current destination and keeps the page in <main>', async () => {
|
|
267
|
+
const html = await renderToHtml(
|
|
268
|
+
h(shell, { nav: 'dashboard', children: h('p', { 'data-role': 'page' }, 'body') }),
|
|
269
|
+
);
|
|
270
|
+
expect(html).toContain('aria-current="page"');
|
|
271
|
+
expect(html).toContain('href="/dashboard"');
|
|
272
|
+
// Landmarks come from AppShell: one banner, one navigation, one main, one contentinfo.
|
|
273
|
+
expect(html.match(/<main\\b/g)?.length).toBe(1);
|
|
274
|
+
expect(html.match(/<nav\\b/g)?.length).toBe(1);
|
|
275
|
+
expect(html).toMatch(/<main[^>]*>[\\s\\S]*data-role="page"[\\s\\S]*<\\/main>/);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
unitTest('no nav marks nothing current, and the actions land in the header', async () => {
|
|
279
|
+
const html = await renderToHtml(
|
|
280
|
+
h(shell, {
|
|
281
|
+
actions: h('button', { type: 'button', 'data-role': 'action' }, 'act'),
|
|
282
|
+
children: h('p', null, 'body'),
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
expect(html).not.toContain('aria-current="page"');
|
|
286
|
+
expect(html).toMatch(/<header[^>]*>[\\s\\S]*data-role="action"[\\s\\S]*<\\/header>/);
|
|
287
|
+
});
|
|
288
|
+
`;
|
|
289
|
+
|
|
290
|
+
const toggleIsland =
|
|
291
|
+
(): string => `// The theme toggle, as the one island the scaffold ships: the only module of the dashboard a
|
|
292
|
+
// browser downloads. Named by SPECIFIER from the page, never by import:
|
|
293
|
+
// const ThemeSwitch = island({ src: '../../shared/theme-toggle.island.tsx', props: ['locale'] });
|
|
294
|
+
//
|
|
295
|
+
// The control itself is the catalog's \`ThemeToggle\`; this file is the runtime it needs and the
|
|
296
|
+
// one decision the framework cannot make for it — see \`bootedEnv\` below.
|
|
297
|
+
|
|
298
|
+
import {
|
|
299
|
+
browserThemeEnv,
|
|
300
|
+
setSolidRuntime,
|
|
301
|
+
THEME_ATTRIBUTE,
|
|
302
|
+
type ThemeEnv,
|
|
303
|
+
ThemeToggle,
|
|
304
|
+
UiProvider,
|
|
305
|
+
} from '@ultimat3/ui';
|
|
306
|
+
import {
|
|
307
|
+
createContext,
|
|
308
|
+
createEffect,
|
|
309
|
+
createMemo,
|
|
310
|
+
createSignal,
|
|
311
|
+
onCleanup,
|
|
312
|
+
useContext,
|
|
313
|
+
} from 'solid-js';
|
|
314
|
+
import { render } from 'solid-js/web';
|
|
315
|
+
|
|
316
|
+
export interface ThemeToggleIslandProps {
|
|
317
|
+
/** The request's own locale, for the control's labels. A browser has no ambient one. */
|
|
318
|
+
readonly locale: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* With no stored choice the catalog's \`resolveTheme\` asks the OS — but the document already wears
|
|
323
|
+
* the theme the framework's boot script stamped from \`theme.defaultMode\` (dark, in this app), so
|
|
324
|
+
* a toggle that asked the OS would show one theme while the page wore another. The boot's verdict
|
|
325
|
+
* is on \`<html>\` before this runs: read it once, and let a stored choice win as it does there.
|
|
326
|
+
*/
|
|
327
|
+
export const bootedEnv = (): ThemeEnv => {
|
|
328
|
+
const booted = document.documentElement.getAttribute(THEME_ATTRIBUTE) === 'dark';
|
|
329
|
+
return { ...browserThemeEnv(), prefersDark: () => booted };
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The one export the hydration runtime calls. \`setSolidRuntime\` comes FIRST and is not optional:
|
|
334
|
+
* \`@ultimat3/ui\` imports types from solid-js and never a runtime, so the reactive graph a
|
|
335
|
+
* component reaches is the one an entry registers. Six NAMED imports, never a namespace: a
|
|
336
|
+
* namespace object keeps every export of solid-js alive in the chunk. The shell is cleared before
|
|
337
|
+
* \`render\`, which APPENDS — the server's copy would otherwise stay on screen beside the live one.
|
|
338
|
+
*/
|
|
339
|
+
export function mount(el: HTMLElement, props: ThemeToggleIslandProps): void {
|
|
340
|
+
setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup });
|
|
341
|
+
const env = bootedEnv();
|
|
342
|
+
el.textContent = '';
|
|
343
|
+
render(
|
|
344
|
+
() => (
|
|
345
|
+
<UiProvider locale={props.locale}>
|
|
346
|
+
<ThemeToggle mode="toggle" env={env} />
|
|
347
|
+
</UiProvider>
|
|
348
|
+
),
|
|
349
|
+
el,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
`;
|
|
353
|
+
|
|
354
|
+
const toggleStates =
|
|
355
|
+
(): string => `// The states the theme toggle can be photographed in. \`x shot --island theme-toggle --json\`
|
|
356
|
+
// takes one picture per state per theme into \`.x/shot/island/theme-toggle/\`. PURE DATA: no JSX,
|
|
357
|
+
// and the one import is \`import type\` — the command has to know the list before a browser exists.
|
|
358
|
+
|
|
359
|
+
import { defineIslandStates } from '@ultimat3/testing';
|
|
360
|
+
import type { ThemeToggleIslandProps } from './theme-toggle.island';
|
|
361
|
+
|
|
362
|
+
export const themeToggleStates = defineIslandStates({
|
|
363
|
+
island: '${SHELL_DIR}/theme-toggle.island.tsx',
|
|
364
|
+
states: [
|
|
365
|
+
{
|
|
366
|
+
id: 'default',
|
|
367
|
+
title: 'the toggle as the dashboard header renders it',
|
|
368
|
+
props: { locale: 'en' } satisfies ThemeToggleIslandProps,
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
});
|
|
372
|
+
`;
|
|
373
|
+
|
|
374
|
+
const toggleTest =
|
|
375
|
+
(): string => `// The toggle the browser actually runs: built with the same \`buildIslands\` as \`x build\`, mounted
|
|
376
|
+
// against a DOM small enough to read, and clicked. What it pins is the one decision this island
|
|
377
|
+
// makes — the boot's verdict on \`<html>\` is what the control starts from, and a click writes the
|
|
378
|
+
// key the boot script reads back on the next load.
|
|
379
|
+
|
|
380
|
+
import { join } from 'node:path';
|
|
381
|
+
import { buildIslands } from '@ultimat3/cli';
|
|
382
|
+
import {
|
|
383
|
+
afterAll,
|
|
384
|
+
beforeAll,
|
|
385
|
+
describe,
|
|
386
|
+
expect,
|
|
387
|
+
type MountedIsland,
|
|
388
|
+
mountIsland,
|
|
389
|
+
test,
|
|
390
|
+
} from '@ultimat3/testing';
|
|
391
|
+
import { THEME_ATTRIBUTE, THEME_STORAGE_KEY } from '@ultimat3/ui';
|
|
392
|
+
import { bootedEnv } from './theme-toggle.island';
|
|
393
|
+
|
|
394
|
+
const APP_ROOT = join(import.meta.dir, ${upToAppRoot(SHELL_DIR)});
|
|
395
|
+
const ISLAND = '${SHELL_DIR}/theme-toggle.island.tsx';
|
|
396
|
+
|
|
397
|
+
/** What the island reads through \`browserThemeEnv\`: storage and the OS query, both fakes. */
|
|
398
|
+
const stored = new Map<string, string>();
|
|
399
|
+
const localStorage = {
|
|
400
|
+
getItem: (key: string): string | null => stored.get(key) ?? null,
|
|
401
|
+
setItem: (key: string, value: string): void => void stored.set(key, value),
|
|
402
|
+
removeItem: (key: string): void => void stored.delete(key),
|
|
403
|
+
};
|
|
404
|
+
const matchMedia = (): Record<string, unknown> => ({
|
|
405
|
+
matches: false,
|
|
406
|
+
addEventListener: () => {},
|
|
407
|
+
removeEventListener: () => {},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
let mounted: MountedIsland;
|
|
411
|
+
|
|
412
|
+
beforeAll(async () => {
|
|
413
|
+
mounted = await mountIsland({
|
|
414
|
+
build: buildIslands,
|
|
415
|
+
root: APP_ROOT,
|
|
416
|
+
file: ISLAND,
|
|
417
|
+
props: { locale: 'en' },
|
|
418
|
+
shell: '<button type="button">theme</button>',
|
|
419
|
+
globals: { localStorage, matchMedia },
|
|
420
|
+
});
|
|
421
|
+
}, 60_000);
|
|
422
|
+
|
|
423
|
+
afterAll(() => {
|
|
424
|
+
mounted?.[Symbol.dispose]();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe('the theme toggle island', () => {
|
|
428
|
+
test('mount renders the catalog control over the server shell', () => {
|
|
429
|
+
expect(mounted.find('button')).not.toBeNull();
|
|
430
|
+
expect(mounted.code).not.toMatch(/\\bReact\\b/);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('a click stores the opposite theme and applies it to <html>', () => {
|
|
434
|
+
// No stamp on this document, so the island booted light; the click flips it. \`false\` means
|
|
435
|
+
// no handler ran — an onClick that never reached the DOM looks identical to a selector typo.
|
|
436
|
+
expect(mounted.fire('button', 'click')).toBe(true);
|
|
437
|
+
expect(stored.get(THEME_STORAGE_KEY)).toBe('dark');
|
|
438
|
+
expect(mounted.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('the env the island builds starts from the theme stamped on <html>', () => {
|
|
442
|
+
// The fake document is still installed here, so this reads exactly what \`mount\` read.
|
|
443
|
+
mounted.documentElement.setAttribute(THEME_ATTRIBUTE, 'dark');
|
|
444
|
+
expect(bootedEnv().prefersDark()).toBe(true);
|
|
445
|
+
mounted.documentElement.setAttribute(THEME_ATTRIBUTE, 'light');
|
|
446
|
+
expect(bootedEnv().prefersDark()).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
`;
|
|
450
|
+
|
|
451
|
+
/** The frame and the toggle island, with the states file the gate requires beside every island. */
|
|
452
|
+
export function shellFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
|
|
453
|
+
return [
|
|
454
|
+
{ path: `${SHELL_DIR}/shell.tsx`, contents: shell(app, example) },
|
|
455
|
+
{ path: `${SHELL_DIR}/shell.module.scss`, contents: shellStyle() },
|
|
456
|
+
{ path: `${SHELL_DIR}/shell.test.ts`, contents: shellTest() },
|
|
457
|
+
{ path: `${SHELL_DIR}/theme-toggle.island.tsx`, contents: toggleIsland() },
|
|
458
|
+
{ path: `${SHELL_DIR}/theme-toggle.island.states.ts`, contents: toggleStates() },
|
|
459
|
+
{ path: `${SHELL_DIR}/theme-toggle.island.test.ts`, contents: toggleTest() },
|
|
460
|
+
];
|
|
461
|
+
}
|