@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.
package/src/mcp-ui.ts CHANGED
@@ -1,6 +1,9 @@
1
- // The dev MCP server's two eyes: `ui.shot` (a route) and `ui.island` (a component's states), as
2
- // the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a browser is the CLI's
3
- // to launch. Both are `x shot` under another name: the same server lookup (a running `x dev` is
1
+ // The dev MCP server's eyes and hand: `ui.shot` (a route), `ui.island` (a component's states),
2
+ // `ui.inspect` (DOM facts for a set of selectors, in `mcp-ui-inspect.ts`), `ui.interact` (steps
3
+ // first, then the picture, in `mcp-ui-interact.ts`) and `ui.diff` (two captures compared, in
4
+ // `mcp-ui-diff.ts`), as the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a
5
+ // browser is the CLI's to launch, and the files are the CLI's to read. The four that look
6
+ // are `x shot` under another name: the same server lookup (a running `x dev` is
4
7
  // reused through its lock, otherwise a scratch one boots), the same driver, the same verdict.
5
8
  // Nothing here is a new capability; it is the existing one made reachable from inside the loop
6
9
  // an agent already works in, so "does it look right" stops needing a hand-written script.
@@ -8,7 +11,18 @@
8
11
  // why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
9
12
  import { join } from 'node:path';
10
13
  import { UltimateError } from '@ultimat3/core';
11
- import type { UiIslandInput, UiIslandResult, UiShotInput, UiShotResult } from '@ultimat3/mcp';
14
+ import type {
15
+ UiDiffInput,
16
+ UiDiffResult,
17
+ UiInspectInput,
18
+ UiInspectResult,
19
+ UiInteractInput,
20
+ UiInteractResult,
21
+ UiIslandInput,
22
+ UiIslandResult,
23
+ UiShotInput,
24
+ UiShotResult,
25
+ } from '@ultimat3/mcp';
12
26
  import { describeRoutes } from '@ultimat3/render';
13
27
  import type { ScrapeDriver } from '@ultimat3/scraping';
14
28
  import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
@@ -17,6 +31,9 @@ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
17
31
  import { islandShot } from './cmd-shot-island';
18
32
  import type { Env } from './dev-services';
19
33
  import { islandVerdictJson } from './island-verdict';
34
+ import { diffShots } from './mcp-ui-diff';
35
+ import { inspectRoute } from './mcp-ui-inspect';
36
+ import { interactRoute } from './mcp-ui-interact';
20
37
  import { retryMemo } from './retry-memo';
21
38
  import { shotBrowserChoice } from './shot-browser';
22
39
  import { devServerFor, type ShotServer } from './shot-server';
@@ -44,7 +61,7 @@ export function assertBudgetedRoute(route: string, declared: readonly DeclaredRo
44
61
  throw new UltimateError({
45
62
  code: 'X_UI_SHOT_ROUTE_UNKNOWN',
46
63
  cause: `no route in this app answers ${route}`,
47
- fix: 'x routes --json # then ui.shot with one of its `path` values',
64
+ fix: 'x routes --json # then call the ui.* tool with one of its `path` values',
48
65
  });
49
66
  }
50
67
  if (hit.budgetJs === null) {
@@ -78,6 +95,10 @@ export interface UiHostInput {
78
95
  export interface UiCapabilities {
79
96
  shotRoute(shot: UiShotInput): Promise<UiShotResult>;
80
97
  shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
98
+ inspectRoute(inspect: UiInspectInput): Promise<UiInspectResult>;
99
+ interactRoute(interact: UiInteractInput): Promise<UiInteractResult>;
100
+ /** Two captures under `.x/shot/` compared without a browser; never boots the scratch server. */
101
+ diffShots(diff: UiDiffInput): Promise<UiDiffResult>;
81
102
  /** Stops the scratch server, if one was booted. Never boots one in order to stop it. */
82
103
  close(): Promise<void>;
83
104
  }
@@ -100,6 +121,17 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
100
121
  // provider's CDP URL, or the launcher's own discovery.
101
122
  const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
102
123
  const routes = input.routes ?? describeRoutes;
124
+ // One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
125
+ const driverFor = async (viewport: UiShotInput['viewport']): Promise<ScrapeDriver> => {
126
+ if (input.driver !== undefined) return input.driver(viewport);
127
+ const { cdpUrl, executablePath } = browser();
128
+ return appBrowser({
129
+ root,
130
+ viewport,
131
+ ...(executablePath === undefined ? {} : { executablePath }),
132
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
133
+ });
134
+ };
103
135
  let closed = false;
104
136
 
105
137
  return {
@@ -113,16 +145,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
113
145
 
114
146
  async shotRoute(shot) {
115
147
  assertBudgetedRoute(shot.route, routes());
116
- const { cdpUrl, executablePath } = browser();
117
- const driver =
118
- input.driver === undefined
119
- ? await appBrowser({
120
- root,
121
- viewport: shot.viewport,
122
- ...(executablePath === undefined ? {} : { executablePath }),
123
- ...(cdpUrl === undefined ? {} : { cdpUrl }),
124
- })
125
- : await input.driver(shot.viewport);
148
+ const driver = await driverFor(shot.viewport);
126
149
  // One directory per (route, viewport, scheme), so two pictures of one route at two widths
127
150
  // never overwrite each other and an agent can hold both.
128
151
  const outDir = join(
@@ -149,6 +172,21 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
149
172
  };
150
173
  },
151
174
 
175
+ async inspectRoute(inspect) {
176
+ // The same gate as `ui.shot`, for the same reason: facts about a draft are facts about the
177
+ // wrong thing.
178
+ assertBudgetedRoute(inspect.route, routes());
179
+ return inspectRoute({ root, boot, driver: driverFor }, inspect);
180
+ },
181
+
182
+ async interactRoute(interact) {
183
+ assertBudgetedRoute(interact.route, routes());
184
+ return interactRoute({ root, boot, driver: driverFor }, interact);
185
+ },
186
+ // No route gate and no boot: the captures were gated when they were taken, and a diff of two
187
+ // files needs neither a server nor a browser.
188
+ diffShots: (diff) => diffShots({ root }, diff),
189
+
152
190
  async shotIsland(island) {
153
191
  const { cdpUrl, executablePath } = browser();
154
192
  const artifacts = await islandShot({
package/src/output.ts CHANGED
@@ -64,7 +64,7 @@ export interface CommandResult {
64
64
  * Which fd this result is written to. `stdout` for every command, absent included — and
65
65
  * `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
66
66
  * --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
67
- * 15 tools` line printed after the loop exits is a malformed frame to whatever is reading.
67
+ * 18 tools` line printed after the loop exits is a malformed frame to whatever is reading.
68
68
  *
69
69
  * Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
70
70
  * where a rendered line goes, and a payload that also claimed it would be a second answer to a
package/src/prerender.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  serviceWorkerHead,
31
31
  serviceWorkerRegistration,
32
32
  } from './sw-artifacts';
33
+ import { loadThemeMode, themeBoot } from './theme-boot';
33
34
 
34
35
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
35
36
  // carries it, and this file already imports that module.
@@ -186,6 +187,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
186
187
  // wiring exists to close. `undefined` when the app is not installable, and then no document
187
188
  // names it either.
188
189
  const pwa = await loadPwaArtifacts(options.root);
190
+ const theme = themeBoot(await loadThemeMode(options.root));
189
191
  // The registration TAG now, the worker itself after the render loop — the two halves are wanted
190
192
  // at different moments and used to be taken at the same one. Every document below has to name
191
193
  // `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
@@ -238,6 +240,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
238
240
  runWithContext(as, () =>
239
241
  routeDocument(entry, data, {
240
242
  resolveIsland: (file: string) => islands.resolverFor(file),
243
+ themeHead: theme.head,
241
244
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
242
245
  }),
243
246
  );
@@ -146,6 +146,11 @@ const overlay = (root: string, app: string): string =>
146
146
  compilerOptions: {
147
147
  noEmit: true,
148
148
  paths: {
149
+ // The two subpath exports a generated app reaches for, spelled the way the packages'
150
+ // own `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a
151
+ // package name. Longest prefix wins, so these are consulted first.
152
+ '@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
153
+ '@ultimat3/render/server': [`${root}/packages/render/src/server`],
149
154
  '@ultimat3/*': [`${root}/packages/*/src`],
150
155
  [`@${app}/web/*`]: ['./apps/web/*'],
151
156
  [`@${app}/admin/*`]: ['./apps/admin/*'],
package/src/script-csp.ts CHANGED
@@ -11,7 +11,10 @@ import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
11
11
  * Hashes, never a nonce: a `render: 'static'` page is a file on disk, so no per-response value can
12
12
  * reach it. Read from `@ultimat3/render`'s own enumeration rather than restated here — the body
13
13
  * the document carries and the body the policy hashes have to be one string.
14
+ *
15
+ * `extra` is for sources the caller already hashed from a body it emits — the theme boot's,
16
+ * which `theme-boot.ts` derives from the same string it inlines.
14
17
  */
15
- export function inlineScriptSources(): readonly string[] {
16
- return [...new Set(HYDRATE_RUNTIME_BODIES.map(cspHashSource))].sort();
18
+ export function inlineScriptSources(extra: readonly string[] = []): readonly string[] {
19
+ return [...new Set([...HYDRATE_RUNTIME_BODIES.map(cspHashSource), ...extra])].sort();
17
20
  }
package/src/serve.ts CHANGED
@@ -40,6 +40,7 @@ import { startServices } from './dev-runtime';
40
40
  import type { Env } from './dev-services';
41
41
  import { resolveServices } from './dev-services';
42
42
  import { storageRoutes } from './dev-storage';
43
+ import { errorPageStyleSources } from './error-page-csp';
43
44
  import { PortInvalidError, RoleUnknownError } from './errors';
44
45
  import { holdUntilShutdown } from './hold';
45
46
  import { buildIslands } from './island-bundle';
@@ -53,6 +54,7 @@ import { styleBundle } from './style-bundle';
53
54
  import { styleRoutes } from './style-routes';
54
55
  import { serviceWorkerArtifacts } from './sw-artifacts';
55
56
  import { serviceWorkerRoutes } from './sw-routes';
57
+ import { loadThemeMode, themeBoot } from './theme-boot';
56
58
 
57
59
  export const DEFAULT_PORT = 3000;
58
60
 
@@ -360,6 +362,7 @@ async function bootRoles(boot: {
360
362
  // on a laptop and absent in the image is exactly the dev/prod difference this file exists to
361
363
  // prevent, and it is the one an operator cannot see without installing the app.
362
364
  const pwa = await loadPwaArtifacts(options.root);
365
+ const theme = themeBoot(await loadThemeMode(options.root));
363
366
  // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
364
367
  // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
365
368
  // route added here cannot be missing from the precache manifest.
@@ -393,6 +396,7 @@ async function bootRoles(boot: {
393
396
  ...appRoutes({
394
397
  buildId,
395
398
  resolveIsland: (file) => islands.resolverFor(file),
399
+ themeHead: theme.head,
396
400
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
397
401
  // Only when a store was supplied. `createIsrController` defaults to a per-process memory
398
402
  // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
@@ -419,6 +423,10 @@ async function bootRoles(boot: {
419
423
  // Same declaration `x dev` reads. Without it a container answers a browser that opened a
420
424
  // guarded page with the problem document, rendered as raw JSON in the viewport.
421
425
  signInPath: await loadSignInPath(options.root),
426
+ // The enforced policy this process sends must admit the app's own error pages' `<style>` and
427
+ // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
428
+ inlineStyles: await errorPageStyleSources(options.root),
429
+ inlineScripts: [theme.cspSource],
422
430
  // The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
423
431
  // process and `x dev` cannot answer a browser differently.
424
432
  root: options.root,
@@ -0,0 +1,52 @@
1
+ // The script a capture runs ahead of the document when a theme was ASKED for. Since 20.2.0 the
2
+ // boot inlines a theme script (`@ultimat3/render`'s `themeScriptBody`) whose fallback is the app's
3
+ // `theme.defaultMode`, and only a stored choice under `THEME_STORAGE_KEY` beats it — so emulating
4
+ // `prefers-color-scheme` alone photographs a `defaultMode: 'dark'` app dark whatever was requested
5
+ // (issue #489). A requested theme is therefore stored as the visitor's CHOICE, on the page's origin,
6
+ // before the boot reads it: the picture is then what a visitor who chose that theme sees.
7
+
8
+ import { THEME_STORAGE_KEY } from '@ultimat3/render';
9
+ import type { ColorScheme } from '@ultimat3/scraping';
10
+ import { BadFlagError } from './errors';
11
+
12
+ /** What `x shot --theme` accepts: the two the boot honours from storage, and nothing else. */
13
+ const SHOT_THEMES = ['light', 'dark'] as const;
14
+ export type ShotTheme = (typeof SHOT_THEMES)[number];
15
+
16
+ const isShotTheme = (value: string): value is ShotTheme =>
17
+ (SHOT_THEMES as readonly string[]).includes(value);
18
+
19
+ /**
20
+ * `--theme light|dark` on a ROUTE shot, or nothing — the box's own preference and the app's own
21
+ * default, which is what `x shot` has always photographed. Refused by name for any other value:
22
+ * `no-preference` is the scraping vocabulary's clear, not a theme a reader can ask for.
23
+ */
24
+ export function readThemeFlag(value: string | undefined): ShotTheme | undefined {
25
+ if (value === undefined) return undefined;
26
+ if (isShotTheme(value)) return value;
27
+ throw new BadFlagError({
28
+ flag: 'theme',
29
+ command: 'shot',
30
+ reason: `"${value}" is not a theme; it is light or dark`,
31
+ fix: 'x shot / --theme light --json',
32
+ });
33
+ }
34
+
35
+ /**
36
+ * The seeding expression, DETERMINISTIC per scheme: the offline drivers key recordings on the exact
37
+ * string, and a test asserts on it by value. `'no-preference'` answers `undefined` — the boot
38
+ * honours only `"light"` and `"dark"` from storage, so there is no choice to store, and storing
39
+ * anything else would be a value the tokens have no block for.
40
+ *
41
+ * `JSON.stringify` on both halves, never a value pasted between quotes: the key and the scheme land
42
+ * inside a JS string, where one `"` ends it. The `try` is for the origin the page starts on:
43
+ * `about:blank` is opaque and its `localStorage` throws, and a throw from a new-document script
44
+ * would surface as a page error on a capture that has not navigated yet.
45
+ */
46
+ export function themeChoiceExpression(scheme: ColorScheme): string | undefined {
47
+ if (scheme === 'no-preference') return undefined;
48
+ return (
49
+ `try{localStorage.setItem(${JSON.stringify(THEME_STORAGE_KEY)},${JSON.stringify(scheme)})}` +
50
+ 'catch(e){}'
51
+ );
52
+ }
@@ -39,11 +39,15 @@ export { claudeFiles } from './scaffold-claude';
39
39
  export { claudeAgentFiles } from './scaffold-claude-agents';
40
40
  export { claudeCommandFiles } from './scaffold-claude-commands';
41
41
  export { containerFiles } from './scaffold-container';
42
+ export { dashboardFiles } from './scaffold-dashboard';
42
43
  export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
43
44
  export { entryFiles } from './scaffold-entries';
44
- // The four guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
45
+ export { errorPageFiles, PWA_COLORS } from './scaffold-errors';
46
+ // The nine guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
45
47
  export { scaffoldGuardFiles } from './scaffold-guards';
46
48
  export { i18nIndex } from './scaffold-i18n';
47
49
  export { repoFiles } from './scaffold-repo';
50
+ export { shellFiles } from './scaffold-shell';
51
+ export { siteFiles } from './scaffold-site';
48
52
  export type { SliceModule } from './slice-foundation';
49
53
  export { sliceFoundation } from './slice-foundation';
@@ -6,10 +6,14 @@ import { sortedImports } from './imports';
6
6
  import type { GeneratedFile, NameSet } from './naming';
7
7
  import { apiFiles } from './scaffold-api';
8
8
  import { authFiles } from './scaffold-auth';
9
+ import { dashboardFiles } from './scaffold-dashboard';
9
10
  import { entryFiles } from './scaffold-entries';
11
+ import { errorPageFiles } from './scaffold-errors';
10
12
  import { httpFiles } from './scaffold-http';
11
13
  import { icon } from './scaffold-icon';
12
14
  import { rolesFiles } from './scaffold-roles';
15
+ import { shellFiles } from './scaffold-shell';
16
+ import { siteFiles } from './scaffold-site';
13
17
 
14
18
  // The one dependency this manifest names, and it is not decoration: every page below reads its
15
19
  // strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
@@ -42,154 +46,6 @@ const tsconfig = (): string => `{
42
46
  }
43
47
  `;
44
48
 
45
- const sitePage = (
46
- app: NameSet,
47
- ): string => `// The landing page. site/ is 0kb JS: static render, hydrate never, no framework script tag.
48
- //
49
- // Strings come from \`useT()\` — this app's own catalog module — and never from
50
- // \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
51
- // this page's graph, so rendering a string is what registers the catalogs. A page that reached
52
- // past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
53
- ${sortedImports([
54
- `import { useT } from '@${app.kebab}/i18n';`,
55
- `import { defineRoute } from '@ultimat3/render';`,
56
- ])}
57
- import styles from './page.module.scss';
58
-
59
- export const config = defineRoute({
60
- render: 'static',
61
- hydrate: 'never',
62
- offline: 'precache',
63
- budget: { js: '0kb' },
64
- // \`t\` is handed to \`meta\` by the router — one translator per render, resolved against the
65
- // request's locale before the head is built.
66
- meta: ({ t }) => ({
67
- title: t('site.home.title'),
68
- description: t('site.home.description'),
69
- }),
70
- });
71
-
72
- export function HomePage() {
73
- const t = useT();
74
-
75
- return (
76
- <main class={styles.hero}>
77
- <h1>{t('site.home.title')}</h1>
78
- <p>{t('site.home.description')}</p>
79
- <a class={styles.cta} href="/dashboard">
80
- {t('site.home.cta')}
81
- </a>
82
- </main>
83
- );
84
- }
85
-
86
- export const appName = '${app.kebab}';
87
- `;
88
-
89
- const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
90
-
91
- .hero {
92
- display: grid;
93
- gap: tokens.space(4);
94
- padding: tokens.space(8);
95
- background: tokens.role('bg');
96
- color: tokens.role('fg');
97
- }
98
-
99
- .cta {
100
- justify-self: start;
101
- padding: tokens.space(2) tokens.space(4);
102
- border-radius: tokens.radius('md');
103
- background: tokens.role('accent');
104
- color: tokens.role('accent-fg');
105
- }
106
- `;
107
-
108
- const sitePageTest =
109
- (): string => `// The landing page ships zero JS and declares its metadata. Both are promises the file makes in
110
- // its config, and both are the kind that rot silently when someone adds one import.
111
- import { metaContextFor, routeDataFor } from '@ultimat3/render';
112
- import { expect, unitTest } from '@ultimat3/testing';
113
- import { config } from './page';
114
-
115
- // The same two objects a render builds: \`routeDataFor\` resolves the route's data once, and
116
- // \`metaContextFor\` wraps it the way every render mode wraps it before calling \`meta\`.
117
- const ctx = { params: {}, url: 'https://example.test/' };
118
-
119
- unitTest('the landing page ships zero JS and declares metadata', async () => {
120
- expect(config.render).toBe('static');
121
- expect(config.hydrate).toBe('never');
122
- expect(config.budget.js).toBe('0kb');
123
- const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
124
- expect(meta.title ?? '').not.toBe('');
125
- });
126
- `;
127
-
128
- const dashboardPage = (
129
- app: NameSet,
130
- ): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
131
- // as their data resolves.
132
-
133
- // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
134
- ${sortedImports([
135
- `import { useT } from '@${app.kebab}/i18n';`,
136
- `import { defineRoute } from '@ultimat3/render';`,
137
- ])}
138
- import styles from './page.module.scss';
139
-
140
- export const config = defineRoute({
141
- // 'ssr', not 'stream', and this is not a downgrade: 'stream' needs a boundary to stream into,
142
- // and the framework has no hole marker yet. Solid's <Suspense> is not it — it throws outside a
143
- // Solid renderer, and the server JSX factory is inert on purpose. A scaffolded 'stream' route
144
- // therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
145
- // could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
146
- render: 'ssr',
147
- // Stated with no island on the page, deliberately and for free — \`apps/admin/app/admin/page.tsx\`
148
- // carries the reason.
149
- hydrate: 'visible',
150
- offline: 'runtime',
151
- // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
152
- policy: { permission: 'dashboard:read' },
153
- budget: { js: '60kb' },
154
- meta: ({ t }) => ({
155
- title: t('app.dashboard.title'),
156
- description: t('app.dashboard.description'),
157
- }),
158
- });
159
-
160
- export function DashboardPage() {
161
- const t = useT();
162
-
163
- return (
164
- <section class={styles.panel}>
165
- <h1>{t('app.dashboard.title')}</h1>
166
- </section>
167
- );
168
- }
169
- `;
170
-
171
- const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
172
-
173
- .panel {
174
- padding: tokens.space(6);
175
- background: tokens.role('surface-raised');
176
- color: tokens.role('fg');
177
- }
178
- `;
179
-
180
- const dashboardTest =
181
- (): string => `// The dashboard renders per request, is gated by a policy, and has an offline strategy. Losing
182
- // the policy is the interesting regression: the page still renders, to anyone.
183
- import { expect, unitTest } from '@ultimat3/testing';
184
- import { config } from './page';
185
-
186
- unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
187
- expect(config.render).toBe('ssr');
188
- expect(config.policy?.permission).toBe('dashboard:read');
189
- expect(config.offline).toBe('runtime');
190
- });
191
- `;
192
-
193
49
  const offlineTest =
194
50
  (): string => `// The offline fallback has to render with nothing: no network, no session, no database, and no
195
51
  // JavaScript. Every one of those is a config field here, and every one of them rots the moment
@@ -419,7 +275,8 @@ restructure.
419
275
  | Start | \`x new ${app.kebab}-${surface}\` inside this directory, or wire it by hand |
420
276
  `;
421
277
 
422
- /** `example` reaches only `apps/web/api/index.ts`: the slice it registers is written elsewhere. */
278
+ /** `example` decides the API registration, the shell's nav and which dashboard is written; the
279
+ * slice itself is written elsewhere. */
423
280
  export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
424
281
  return [
425
282
  { path: 'apps/web/package.json', contents: webPackage(app) },
@@ -427,12 +284,14 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
427
284
  // The process a container starts and the artifact a CDN is handed — `scaffold-entries.ts`.
428
285
  ...entryFiles(),
429
286
  { path: 'apps/web/site/icon.png', contents: icon() },
430
- { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
431
- { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
432
- { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
433
- { path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage(app) },
434
- { path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
435
- { path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
287
+ // The landing page: hero, two calls to action, three feature cards — `scaffold-site.ts`.
288
+ ...siteFiles(app),
289
+ // The signed-in product: its frame and the one island it ships (`scaffold-shell.ts`), then the
290
+ // dashboard in the shape the invocation earns (`scaffold-dashboard.ts`).
291
+ ...shellFiles(app, example),
292
+ ...dashboardFiles(app, example),
293
+ // Served verbatim for those statuses and carried into the static export — `scaffold-errors.ts`.
294
+ ...errorPageFiles(app),
436
295
  // The third piece of the authz story the scaffold already tells twice: the routes declare a
437
296
  // policy and `shared/roles.ts` declares the grants, and until this file existed nothing
438
297
  // answered "who is this?" — so every one of those routes refused every request.