@ultimat3/cli 20.1.6 → 20.2.1

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.
@@ -0,0 +1,128 @@
1
+ // The two error pages `x new` writes — `apps/web/site/errors/404.html` and `500.html`, the files
2
+ // `packages/cli/src/error-pages.ts` serves verbatim for those statuses and the static export
3
+ // carries as `404.html`. Self-contained documents by construction: a 500 is answered when the app's
4
+ // own stylesheet may be the thing that failed, so nothing here links a bundle, loads a font or runs
5
+ // a script. The `<style>` body is hashed into `style-src` at boot (`error-page-csp.ts`), which is
6
+ // what lets the enforced policy a container sends admit it.
7
+
8
+ import type { GeneratedFile, NameSet } from './naming';
9
+ import { titleCase } from './naming';
10
+
11
+ /**
12
+ * The same two hex values `app.config.ts`'s `pwa.colors` declares — one source, read by both
13
+ * templates, so the splash a browser paints before any stylesheet and the page it shows when the
14
+ * stylesheet never arrives agree. Raw hex is legal in these two places and nowhere else in an app:
15
+ * the raw-colour guard scans `.scss` (a page with no stylesheet has no token to read), and an
16
+ * install prompt has no stylesheet either.
17
+ */
18
+ export const PWA_COLORS = {
19
+ themeColor: '#1b1f3b',
20
+ lightBackground: '#ffffff',
21
+ darkBackground: '#0b0d1a',
22
+ } as const;
23
+
24
+ /** One page per status, English only: the file is served as bytes, so no translator runs. */
25
+ interface ErrorCopy {
26
+ readonly status: number;
27
+ readonly title: string;
28
+ readonly body: string;
29
+ }
30
+
31
+ const COPY: readonly ErrorCopy[] = [
32
+ {
33
+ status: 404,
34
+ title: 'Page not found',
35
+ body: 'There is nothing at this address. The link may be old, or the page may have moved.',
36
+ },
37
+ {
38
+ status: 500,
39
+ title: 'Something went wrong',
40
+ body: 'The server could not finish this request. It has been recorded; try again in a moment.',
41
+ },
42
+ ];
43
+
44
+ /**
45
+ * `prefers-color-scheme` rather than `data-theme`: the theme boot script is inlined by the
46
+ * framework into documents it RENDERS, and this file is served as-is, so the OS preference is the
47
+ * only signal it has. The dark palette is the default and the light one is the media override,
48
+ * matching the `theme.defaultMode` the scaffold sets.
49
+ */
50
+ const style = (): string => `
51
+ :root {
52
+ color-scheme: dark light;
53
+ --bg: ${PWA_COLORS.darkBackground};
54
+ --fg: #e6e8f0;
55
+ --muted: #9aa0b5;
56
+ --accent: #8b93ff;
57
+ --line: #262a45;
58
+ }
59
+ @media (prefers-color-scheme: light) {
60
+ :root {
61
+ --bg: ${PWA_COLORS.lightBackground};
62
+ --fg: ${PWA_COLORS.themeColor};
63
+ --muted: #5d6280;
64
+ --accent: #3b46d6;
65
+ --line: #dfe2ee;
66
+ }
67
+ }
68
+ * { box-sizing: border-box; margin: 0; }
69
+ html, body { min-height: 100%; }
70
+ body {
71
+ display: grid;
72
+ place-items: center;
73
+ padding: 2rem;
74
+ background: var(--bg);
75
+ color: var(--fg);
76
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
77
+ line-height: 1.5;
78
+ }
79
+ main { max-width: 32rem; }
80
+ .status {
81
+ font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
82
+ font-size: 0.75rem;
83
+ letter-spacing: 0.12em;
84
+ text-transform: uppercase;
85
+ color: var(--accent);
86
+ }
87
+ h1 { margin-top: 0.5rem; font-size: 2rem; font-weight: 600; letter-spacing: -0.02em; }
88
+ p { margin-top: 0.75rem; color: var(--muted); }
89
+ a {
90
+ display: inline-block;
91
+ margin-top: 1.5rem;
92
+ padding: 0.6rem 1rem;
93
+ border: 1px solid var(--line);
94
+ border-radius: 0.5rem;
95
+ color: var(--fg);
96
+ text-decoration: none;
97
+ }
98
+ a:hover { border-color: var(--accent); }
99
+ a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
100
+ `;
101
+
102
+ const page = (app: NameSet, copy: ErrorCopy): string => `<!doctype html>
103
+ <html lang="en">
104
+ <head>
105
+ <meta charset="utf-8">
106
+ <meta name="viewport" content="width=device-width, initial-scale=1">
107
+ <meta name="robots" content="noindex">
108
+ <meta name="theme-color" content="${PWA_COLORS.themeColor}">
109
+ <title>${copy.title} · ${titleCase(app.raw)}</title>
110
+ <style>${style()}</style>
111
+ </head>
112
+ <body>
113
+ <main>
114
+ <p class="status">${String(copy.status)}</p>
115
+ <h1>${copy.title}</h1>
116
+ <p>${copy.body}</p>
117
+ <a href="/">Back to ${titleCase(app.raw)}</a>
118
+ </main>
119
+ </body>
120
+ </html>
121
+ `;
122
+
123
+ /** `apps/web/site/errors/404.html` and `500.html`, in status order. */
124
+ export const errorPageFiles = (app: NameSet): readonly GeneratedFile[] =>
125
+ COPY.map((copy) => ({
126
+ path: `apps/web/site/errors/${String(copy.status)}.html`,
127
+ contents: page(app, copy),
128
+ }));
@@ -108,9 +108,55 @@ const i18nCatalog = (app: NameSet): string =>
108
108
  catalogJson({
109
109
  'site.home.title': app.pascal,
110
110
  'site.home.description': 'Everything you need, one command from shippable.',
111
+ 'site.home.eyebrow': 'Built on Ultimate',
112
+ 'site.home.headline': 'Ship the whole product, not a prototype.',
113
+ 'site.home.lede':
114
+ 'Typed routes, actions and queries, one authz system, budgets that fail the build, and a dark, designed shell — all from one command.',
111
115
  'site.home.cta': 'Open the dashboard',
116
+ 'site.home.secondary': 'Open the admin',
117
+ 'site.home.f1Title': 'One command to a running app',
118
+ 'site.home.f1Body':
119
+ 'bin/setup installs, migrates and seeds; bin/dev runs every role in one process.',
120
+ 'site.home.f2Title': 'Policies, not flags',
121
+ 'site.home.f2Body':
122
+ 'Every route, action and query declares who may call it, and one system decides.',
123
+ 'site.home.f3Title': 'Budgets that fail the build',
124
+ 'site.home.f3Body':
125
+ 'This page ships zero JavaScript, and the gate refuses the first byte over it.',
126
+ 'shell.brand': app.pascal,
127
+ 'shell.nav.dashboard': 'Dashboard',
128
+ 'shell.nav.posts': 'Posts',
129
+ 'shell.footer': 'Built with Ultimate',
112
130
  'app.dashboard.title': 'Dashboard',
113
131
  'app.dashboard.description': 'Your workspace.',
132
+ 'app.dashboard.subtitle': 'What this app looks like from above.',
133
+ 'app.dashboard.statTotal': 'Posts',
134
+ 'app.dashboard.hintTotal': 'in the seeded org',
135
+ 'app.dashboard.statWeek': 'Created this week',
136
+ 'app.dashboard.hintVsPriorWeek': 'vs the week before',
137
+ 'app.dashboard.statToday': 'Created today',
138
+ 'app.dashboard.hintToday': 'so far, UTC',
139
+ 'app.dashboard.chartTitle': 'Posts per day',
140
+ 'app.dashboard.chartRange': 'Last 14 days, UTC',
141
+ 'app.dashboard.chartLabel': 'Posts created per day over the last 14 days',
142
+ 'app.dashboard.tableTitle': 'Latest posts',
143
+ 'app.dashboard.tableCaption': 'The ten newest posts',
144
+ 'app.dashboard.columnTitle': 'Title',
145
+ 'app.dashboard.columnCreated': 'Created',
146
+ 'app.dashboard.emptyTitle': 'No posts yet',
147
+ 'app.dashboard.statRoutes': 'Routes',
148
+ 'app.dashboard.hintRoutes': 'registered at boot',
149
+ 'app.dashboard.statLocales': 'Locales',
150
+ 'app.dashboard.hintLocales': 'in the catalog',
151
+ 'app.dashboard.statRoles': 'Roles',
152
+ 'app.dashboard.hintRoles': 'declared in shared/roles.ts',
153
+ 'app.dashboard.statVersion': 'Ultimate',
154
+ 'app.dashboard.hintVersion': 'framework version',
155
+ 'app.dashboard.routesTitle': 'Route table',
156
+ 'app.dashboard.routesCaption': 'Every route this app registers',
157
+ 'app.dashboard.columnPath': 'Path',
158
+ 'app.dashboard.columnSurface': 'Surface',
159
+ 'app.dashboard.columnRender': 'Render',
114
160
  'app.offline.title': 'You are offline',
115
161
  'app.offline.description': 'This page will refresh itself when the connection returns.',
116
162
  'admin.home.title': 'Admin',
@@ -14,6 +14,7 @@ import { dbPackageFiles } from './scaffold-db-package';
14
14
  import { docsFiles } from './scaffold-docs';
15
15
  import { domainPackageFiles } from './scaffold-domain-package';
16
16
  import { envExampleSource, envSchemaSource } from './scaffold-env';
17
+ import { PWA_COLORS } from './scaffold-errors';
17
18
  import { scaffoldGuardFiles } from './scaffold-guards';
18
19
  import { i18nFiles } from './scaffold-i18n';
19
20
  import { mcpPackageFiles } from './scaffold-mcp-package';
@@ -213,10 +214,14 @@ export const config = defineConfig({
213
214
  offline: { fallback: '/offline' },
214
215
  name: '${titleCase(app.raw)}',
215
216
  colors: {
216
- light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' },
217
- dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' },
217
+ light: { themeColor: '${PWA_COLORS.themeColor}', backgroundColor: '${PWA_COLORS.lightBackground}' },
218
+ dark: { themeColor: '${PWA_COLORS.themeColor}', backgroundColor: '${PWA_COLORS.darkBackground}' },
218
219
  },
219
220
  },
221
+ // The theme a first visit opens in; the framework inlines the no-flash boot script that reads
222
+ // it, and a choice stored by the theme toggle wins over it on every later visit; 'system' follows
223
+ // the OS.
224
+ theme: { defaultMode: 'dark' },
220
225
  ai: { mcp: { expose: true, path: '/mcp' } },
221
226
  });
222
227
  `;
@@ -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
+ }