@ultimat3/cli 20.2.0 → 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/CLAUDE.md +1 -0
- package/package.json +29 -29
- package/src/cmd-shot.ts +30 -4
- package/src/island-bundle.ts +6 -1
- package/src/island-solid-dedupe.ts +108 -0
- package/src/shot-theme.ts +52 -0
- package/src/templates/scaffold-dashboard-shared.ts +8 -5
package/CLAUDE.md
CHANGED
|
@@ -1261,6 +1261,7 @@ missing.
|
|
|
1261
1261
|
| File | Job |
|
|
1262
1262
|
|---|---|
|
|
1263
1263
|
| `island-bundle.ts` | discover `*.island.tsx`, build each as its own entry point, hash it, resolve a page's specifier to its URL |
|
|
1264
|
+
| `island-solid-dedupe.ts` | the plugin installed FIRST: every `solid-js` specifier in an island's graph resolves to the APP's copy. `Bun.build` resolves from a module's real path, so a package reached through a symlink (`file:` overrides, `bun link`) brought its own `solid-js` — the scaffold's theme toggle shipped two runtimes under CI's own links, 62,463 B against 50,042 B with one (issue #490), and two runtimes are two reactive graphs |
|
|
1264
1265
|
| `island-routes.ts` | serve those chunks, at `ISLAND_BASE_PATH`, immutable |
|
|
1265
1266
|
| `dev-render.ts` | one collector **per render**, and `hydrateRuntime` after the body |
|
|
1266
1267
|
| `prerender.ts` | build first, write the chunks into the export, then measure |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "20.2.
|
|
3
|
+
"version": "20.2.1",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "20.2.
|
|
41
|
-
"@ultimat3/admin": "20.2.
|
|
42
|
-
"@ultimat3/ai": "20.2.
|
|
43
|
-
"@ultimat3/auth": "20.2.
|
|
44
|
-
"@ultimat3/cache": "20.2.
|
|
45
|
-
"@ultimat3/core": "20.2.
|
|
46
|
-
"@ultimat3/db": "20.2.
|
|
47
|
-
"@ultimat3/entity": "20.2.
|
|
48
|
-
"@ultimat3/flags": "20.2.
|
|
49
|
-
"@ultimat3/http": "20.2.
|
|
50
|
-
"@ultimat3/i18n": "20.2.
|
|
51
|
-
"@ultimat3/jobs": "20.2.
|
|
52
|
-
"@ultimat3/mail": "20.2.
|
|
53
|
-
"@ultimat3/manifest": "20.2.
|
|
54
|
-
"@ultimat3/mcp": "20.2.
|
|
55
|
-
"@ultimat3/money": "20.2.
|
|
56
|
-
"@ultimat3/notify": "20.2.
|
|
57
|
-
"@ultimat3/policy": "20.2.
|
|
58
|
-
"@ultimat3/pwa": "20.2.
|
|
59
|
-
"@ultimat3/query": "20.2.
|
|
60
|
-
"@ultimat3/realtime": "20.2.
|
|
61
|
-
"@ultimat3/render": "20.2.
|
|
62
|
-
"@ultimat3/schema": "20.2.
|
|
63
|
-
"@ultimat3/scraping": "20.2.
|
|
64
|
-
"@ultimat3/seo": "20.2.
|
|
65
|
-
"@ultimat3/storage": "20.2.
|
|
66
|
-
"@ultimat3/testing": "20.2.
|
|
67
|
-
"@ultimat3/time": "20.2.
|
|
40
|
+
"@ultimat3/action": "20.2.1",
|
|
41
|
+
"@ultimat3/admin": "20.2.1",
|
|
42
|
+
"@ultimat3/ai": "20.2.1",
|
|
43
|
+
"@ultimat3/auth": "20.2.1",
|
|
44
|
+
"@ultimat3/cache": "20.2.1",
|
|
45
|
+
"@ultimat3/core": "20.2.1",
|
|
46
|
+
"@ultimat3/db": "20.2.1",
|
|
47
|
+
"@ultimat3/entity": "20.2.1",
|
|
48
|
+
"@ultimat3/flags": "20.2.1",
|
|
49
|
+
"@ultimat3/http": "20.2.1",
|
|
50
|
+
"@ultimat3/i18n": "20.2.1",
|
|
51
|
+
"@ultimat3/jobs": "20.2.1",
|
|
52
|
+
"@ultimat3/mail": "20.2.1",
|
|
53
|
+
"@ultimat3/manifest": "20.2.1",
|
|
54
|
+
"@ultimat3/mcp": "20.2.1",
|
|
55
|
+
"@ultimat3/money": "20.2.1",
|
|
56
|
+
"@ultimat3/notify": "20.2.1",
|
|
57
|
+
"@ultimat3/policy": "20.2.1",
|
|
58
|
+
"@ultimat3/pwa": "20.2.1",
|
|
59
|
+
"@ultimat3/query": "20.2.1",
|
|
60
|
+
"@ultimat3/realtime": "20.2.1",
|
|
61
|
+
"@ultimat3/render": "20.2.1",
|
|
62
|
+
"@ultimat3/schema": "20.2.1",
|
|
63
|
+
"@ultimat3/scraping": "20.2.1",
|
|
64
|
+
"@ultimat3/seo": "20.2.1",
|
|
65
|
+
"@ultimat3/storage": "20.2.1",
|
|
66
|
+
"@ultimat3/testing": "20.2.1",
|
|
67
|
+
"@ultimat3/time": "20.2.1",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/cmd-shot.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { shotBrowserChoice } from './shot-browser';
|
|
|
32
32
|
import type { BootDevServer, ShotServer } from './shot-server';
|
|
33
33
|
import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
|
|
34
34
|
import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
|
|
35
|
+
import { readThemeFlag, themeChoiceExpression } from './shot-theme';
|
|
35
36
|
import type { IslandCount, ShotArtifacts } from './shot-verdict';
|
|
36
37
|
import {
|
|
37
38
|
buildVerdict,
|
|
@@ -191,9 +192,13 @@ export interface ShotRun {
|
|
|
191
192
|
*/
|
|
192
193
|
readonly extraHosts?: string | undefined;
|
|
193
194
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
195
|
+
* The theme the picture is of. Two things happen BEFORE navigation, because the boot script runs
|
|
196
|
+
* inline and nothing after `goto` can reach it: `prefers-color-scheme` is emulated so the boot's
|
|
197
|
+
* "system" branch answers the same on every box, AND the scheme is stored as the visitor's
|
|
198
|
+
* choice under `THEME_STORAGE_KEY` (`shot-theme.ts`), because an app with `theme.defaultMode`
|
|
199
|
+
* set answers that before the OS and only a stored choice beats it (issue #489). Absent means
|
|
200
|
+
* neither: the box's own preference and the app's own default — what `x shot` has always done,
|
|
201
|
+
* and the point of `defaultMode` — and `ui.shot` names one explicitly for exactly that reason.
|
|
197
202
|
*/
|
|
198
203
|
readonly colorScheme?: ColorScheme | undefined;
|
|
199
204
|
readonly now?: (() => Date) | undefined;
|
|
@@ -234,7 +239,11 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
234
239
|
timeoutMs: options.timeoutMs,
|
|
235
240
|
});
|
|
236
241
|
const page = session.page;
|
|
237
|
-
if (options.colorScheme !== undefined)
|
|
242
|
+
if (options.colorScheme !== undefined) {
|
|
243
|
+
await page.colorScheme(options.colorScheme);
|
|
244
|
+
const choice = themeChoiceExpression(options.colorScheme);
|
|
245
|
+
if (choice !== undefined) await page.prepare(choice);
|
|
246
|
+
}
|
|
238
247
|
await page.goto(requestedUrl, { timeout: options.timeoutMs });
|
|
239
248
|
if (options.settleMs > 0) await Bun.sleep(options.settleMs);
|
|
240
249
|
// The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
|
|
@@ -322,6 +331,11 @@ export const shotCommand: CliCommand = {
|
|
|
322
331
|
summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
|
|
323
332
|
},
|
|
324
333
|
{ name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
|
|
334
|
+
{
|
|
335
|
+
name: 'theme',
|
|
336
|
+
type: 'string',
|
|
337
|
+
summary: "light or dark, stored as the visitor's choice; absent is the app's own default",
|
|
338
|
+
},
|
|
325
339
|
// A FLAG on `x shot` and never a second command: photographing a route and photographing a
|
|
326
340
|
// component are one job with two subjects, and a parallel command would be the second path
|
|
327
341
|
// axiom 1 refuses.
|
|
@@ -352,6 +366,7 @@ export const shotCommand: CliCommand = {
|
|
|
352
366
|
// report, which is the rule `x routes` and `x mcp` already follow.
|
|
353
367
|
const island = flagString(ctx.args, 'island');
|
|
354
368
|
const state = flagString(ctx.args, 'state');
|
|
369
|
+
const theme = readThemeFlag(flagString(ctx.args, 'theme'));
|
|
355
370
|
const positional = ctx.args.positionals[0];
|
|
356
371
|
const sweep = flagBool(ctx.args, 'all-islands');
|
|
357
372
|
// Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
|
|
@@ -364,6 +379,16 @@ export const shotCommand: CliCommand = {
|
|
|
364
379
|
refuseRouteWithIsland(positional, island);
|
|
365
380
|
}
|
|
366
381
|
const component = sweep || (island !== undefined && island !== '');
|
|
382
|
+
// An island is photographed in BOTH themes by the harness, which owns its `data-theme` and
|
|
383
|
+
// carries no boot script — so a theme asked for beside one is a request nothing could honour.
|
|
384
|
+
if (component && theme !== undefined) {
|
|
385
|
+
throw new BadFlagError({
|
|
386
|
+
flag: 'theme',
|
|
387
|
+
command: 'shot',
|
|
388
|
+
reason: 'photographs a route; an island is photographed in both themes',
|
|
389
|
+
fix: 'x shot / --theme light --json',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
367
392
|
const route = component ? '' : readRoute(positional);
|
|
368
393
|
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
369
394
|
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
@@ -415,6 +440,7 @@ export const shotCommand: CliCommand = {
|
|
|
415
440
|
timeoutMs,
|
|
416
441
|
fullPage: flagBool(ctx.args, 'full'),
|
|
417
442
|
extraHosts: flagString(ctx.args, 'allow-hosts'),
|
|
443
|
+
...(theme === undefined ? {} : { colorScheme: theme }),
|
|
418
444
|
}),
|
|
419
445
|
);
|
|
420
446
|
},
|
package/src/island-bundle.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { frameworkVersion, renderThrowable } from '@ultimat3/core';
|
|
|
11
11
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
12
12
|
import { contentHash } from '@ultimat3/render/server';
|
|
13
13
|
import { IslandBuildFailedError } from './errors';
|
|
14
|
+
import { solidDedupePlugin } from './island-solid-dedupe';
|
|
14
15
|
import { islandStylesPlugin } from './island-styles';
|
|
15
16
|
import { hasPathSegment } from './path-segments';
|
|
16
17
|
import { solidJsxPlugin } from './solid-loader';
|
|
@@ -91,7 +92,11 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
91
92
|
// The second closes the same shape of failure — a wrong answer `Bun.build` reports as
|
|
92
93
|
// `success: true`: without it, Bun's file loader resolves a `.module.scss` to its asset
|
|
93
94
|
// PATH, so `styles['x']` is `undefined` and every element renders unclassed.
|
|
94
|
-
|
|
95
|
+
//
|
|
96
|
+
// The dedupe goes FIRST: it answers `solid-js` specifiers before either plugin loads a file,
|
|
97
|
+
// so the `solid-js/web` helpers the JSX transform writes into a symlinked package resolve
|
|
98
|
+
// to the app's one copy. See `island-solid-dedupe.ts` for the measurement.
|
|
99
|
+
plugins: [solidDedupePlugin(root), solidJsxPlugin, islandStylesPlugin],
|
|
95
100
|
// The third one, and it is a `define` rather than the plugin this used to be: Bun selects
|
|
96
101
|
// the `development`/`production` export condition from the BUILD PROCESS's own `NODE_ENV`,
|
|
97
102
|
// and a defined `process.env.NODE_ENV` overrides it. Measured on 1.4.0, `solid-js` plus
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// One Solid runtime per island chunk: the app's. Every `solid-js` specifier in the island's graph
|
|
2
|
+
// — the entry's own `render` from `solid-js/web`, and the `solid-js/web` helpers the JSX
|
|
3
|
+
// transform writes into every `@ultimat3/ui` component — resolves to the copy the APP installed.
|
|
4
|
+
//
|
|
5
|
+
// Without this, `Bun.build` resolves each import from the importing file's REAL path. A package
|
|
6
|
+
// reached through a symlink (`file:` overrides, `bun link`, a workspace with its own install)
|
|
7
|
+
// resolves `solid-js` from its own `node_modules`, and the chunk ships two runtimes: measured on
|
|
8
|
+
// the scaffold's theme-toggle island under CI's own `file:` links, 62,463 B against 50,042 B with
|
|
9
|
+
// one (issue #490). Two copies are also two reactive graphs — a context created by one is never
|
|
10
|
+
// found by the other's `useContext` — so the dedupe is a correctness rule that happens to be the
|
|
11
|
+
// biggest single cut in the chunk, not a size trick.
|
|
12
|
+
|
|
13
|
+
// why: Bun ships no path API; `dirname` recovers the package directory from the manifest path
|
|
14
|
+
// `Bun.resolveSync` answers, and `join` puts an export target under it.
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import type { BunPlugin } from 'bun';
|
|
17
|
+
|
|
18
|
+
const SOLID_PACKAGE = 'solid-js';
|
|
19
|
+
|
|
20
|
+
/** `solid-js` and every subpath of it — `solid-js/web`, `solid-js/store`, `solid-js/h`. */
|
|
21
|
+
export const SOLID_SPECIFIER = /^solid-js(?:\/.*)?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The conditions an island is built under, in the order they are tried: `browser` because the
|
|
25
|
+
* chunk runs there, `import` because it is ESM, `default` as the map's own fallback. NOT
|
|
26
|
+
* `development` — `island-bundle.ts` defines `process.env.NODE_ENV` as `"production"` for the
|
|
27
|
+
* same reason, and solid nests `development` INSIDE `browser`, so walking without it is what
|
|
28
|
+
* selects `dist/solid.js` over `dist/dev.js`.
|
|
29
|
+
*/
|
|
30
|
+
const BROWSER_CONDITIONS: readonly string[] = ['browser', 'import', 'default'];
|
|
31
|
+
|
|
32
|
+
/** `solid-js` → `.`, `solid-js/web` → `./web`: the key the package's `exports` map uses. */
|
|
33
|
+
export function solidSubpath(specifier: string): string {
|
|
34
|
+
return specifier === SOLID_PACKAGE ? '.' : `.${specifier.slice(SOLID_PACKAGE.length)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The target one `exports` entry names under the conditions above — depth-first, in the map's
|
|
39
|
+
* own key order, which is how Node and Bun read a conditions object. A string is a target; a
|
|
40
|
+
* nested object is walked; a condition the island does not build under (`node`, `worker`,
|
|
41
|
+
* `require`, `types`, `development`) is skipped. `undefined` when nothing matched, and the
|
|
42
|
+
* caller then leaves the specifier to Bun's own resolver rather than guessing.
|
|
43
|
+
*/
|
|
44
|
+
export function conditionTarget(entry: unknown): string | undefined {
|
|
45
|
+
if (typeof entry === 'string') return entry;
|
|
46
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
|
|
47
|
+
for (const [condition, nested] of Object.entries(entry)) {
|
|
48
|
+
if (!BROWSER_CONDITIONS.includes(condition)) continue;
|
|
49
|
+
const target = conditionTarget(nested);
|
|
50
|
+
if (target !== undefined) return target;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AppSolid {
|
|
56
|
+
/** The installed package's directory — where every export target is joined onto. */
|
|
57
|
+
readonly dir: string;
|
|
58
|
+
/** Its `exports` map, verbatim. */
|
|
59
|
+
readonly exports: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The `solid-js` the app installed, found the way the app's own entry would find it: from the app
|
|
64
|
+
* root upward. `null` when there is none — an app with no islands never gets here, and an island
|
|
65
|
+
* importing solid where none is installed fails the build in Bun's own words either way.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveAppSolid(root: string): Promise<AppSolid | null> {
|
|
68
|
+
let manifest: string;
|
|
69
|
+
try {
|
|
70
|
+
manifest = Bun.resolveSync(`${SOLID_PACKAGE}/package.json`, root);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const parsed = (await Bun.file(manifest).json()) as { readonly exports?: unknown };
|
|
75
|
+
return { dir: dirname(manifest), exports: parsed.exports };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where `specifier` lands in the app's own copy, or `undefined` to leave it to Bun. */
|
|
79
|
+
export function appSolidPath(solid: AppSolid, specifier: string): string | undefined {
|
|
80
|
+
const exports = solid.exports;
|
|
81
|
+
if (exports === null || typeof exports !== 'object') return undefined;
|
|
82
|
+
const entry = Object.hasOwn(exports, solidSubpath(specifier))
|
|
83
|
+
? (exports as Record<string, unknown>)[solidSubpath(specifier)]
|
|
84
|
+
: undefined;
|
|
85
|
+
const target = conditionTarget(entry);
|
|
86
|
+
return target === undefined ? undefined : join(solid.dir, target);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The plugin `island-bundle.ts` installs FIRST, so it sees every `solid-js` specifier before the
|
|
91
|
+
* JSX and style plugins do. Resolved once per build and cached: the map is read from disk at
|
|
92
|
+
* most one time however many modules import solid.
|
|
93
|
+
*/
|
|
94
|
+
export function solidDedupePlugin(root: string): BunPlugin {
|
|
95
|
+
let app: Promise<AppSolid | null> | null = null;
|
|
96
|
+
return {
|
|
97
|
+
name: 'ultimate-solid-dedupe',
|
|
98
|
+
setup(build): void {
|
|
99
|
+
build.onResolve({ filter: SOLID_SPECIFIER }, async (args) => {
|
|
100
|
+
app ??= resolveAppSolid(root);
|
|
101
|
+
const solid = await app;
|
|
102
|
+
if (solid === null) return undefined;
|
|
103
|
+
const path = appSolidPath(solid, args.path);
|
|
104
|
+
return path === undefined ? undefined : { path };
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -17,10 +17,13 @@ export const routeConfig = (load: string): string => `export const config = defi
|
|
|
17
17
|
// Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
|
|
18
18
|
policy: { permission: 'dashboard:read' },
|
|
19
19
|
// Only the toggle island hydrates; the tiles, the chart and the table are server markup. The
|
|
20
|
-
// island measured
|
|
21
|
-
//
|
|
22
|
-
// the
|
|
23
|
-
|
|
20
|
+
// island measured 34.0kb minified under Bun 1.4.0 (37.0kb under 1.4.2, which honours
|
|
21
|
+
// \`sideEffects\` and keeps core's declared modules) — solid-js is 15.1kb of it, the catalog's
|
|
22
|
+
// toggle, provider, the ui runtime they share and the error registry are the rest. It was
|
|
23
|
+
// 60.9kb before the framework stopped shipping solid-js twice and the i18n catalog with it
|
|
24
|
+
// (issue #490), which is what put this at 64kb; 60kb is the figure the scaffold budgets held
|
|
25
|
+
// before that, kept rather than tightened so a Bun patch cannot red a first \`bin/check\`.
|
|
26
|
+
budget: { js: '60kb' },${load}
|
|
24
27
|
meta: ({ t }) => ({
|
|
25
28
|
title: t('app.dashboard.title'),
|
|
26
29
|
description: t('app.dashboard.description'),
|
|
@@ -69,6 +72,6 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
|
|
|
69
72
|
|
|
70
73
|
unitTest('the dashboard hydrates its one island inside a stated budget', () => {
|
|
71
74
|
expect(config.hydrate).toBe('visible');
|
|
72
|
-
expect(config.budget.js).toBe('
|
|
75
|
+
expect(config.budget.js).toBe('60kb');
|
|
73
76
|
});
|
|
74
77
|
`;
|