@ultimat3/cli 22.15.0 → 24.0.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.
Files changed (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { ENV_EXAMPLE_PATH } from '@ultimat3/core';
8
8
  import { REQUIRED_BUN } from '../app-root';
9
+ import type { CoverageExclude } from '../coverage-floor';
10
+ import { COVERAGE_BAR } from '../coverage-floor';
9
11
  import { VERIFY_FLOOR_FILE } from '../verify-floor';
10
12
  import type { VerifyStepName } from '../verify-step';
11
13
  import type { GeneratedFile, NameSet } from './naming';
@@ -39,6 +41,14 @@ const BIOME_VERSION = '2.5.8';
39
41
  */
40
42
  const TYPESCRIPT_VERSION = '^7.0.2';
41
43
 
44
+ /**
45
+ * The Solid runtime every island and `@ultimat3/ui` component renders with, exact for the reason
46
+ * `BIOME_VERSION` is: the CLI compiles islands with `babel-preset-solid ^1.9.15`, and a scaffold
47
+ * that installed an older runtime than the compiler targets drifted as a buried literal (1.9.14
48
+ * beside the repository's 1.9.15). `scaffold-repo.test.ts` holds it equal to the root manifest's.
49
+ */
50
+ const SOLID_VERSION = '1.9.15';
51
+
42
52
  // `engines.bun` is `REQUIRED_BUN`, the floor the SHIPPED `x` enforces
43
53
  // (`packages/cli/src/app-root.ts`), and not a second literal. It was one: `>=1.3.0`, a whole minor
44
54
  // BELOW the CLI the app then runs, so on a box with Bun 1.3.x `bun install` succeeded and the very
@@ -97,7 +107,7 @@ const rootPackage = (app: NameSet, version: string): string => `{
97
107
  "@ultimat3/schema": "^${version}",
98
108
  "@ultimat3/seo": "^${version}",
99
109
  "@ultimat3/ui": "^${version}",
100
- "solid-js": "1.9.14"
110
+ "solid-js": "${SOLID_VERSION}"
101
111
  },
102
112
  "engines": {
103
113
  "bun": ">=${REQUIRED_BUN}"
@@ -187,7 +197,7 @@ const appConfig = (
187
197
  // a missing value fails the boot with the exact command that fixes it, never at the first request.
188
198
  // A named export, never a default: the CLI and the runtime both import \`config\` by name.
189
199
  import type { EnvSchema } from '@ultimat3/core';
190
- import { defineConfig, defineEnv } from '@ultimat3/core';
200
+ import { defineConfig, defineEnv, defineMeasurementActor, userActor } from '@ultimat3/core';
191
201
 
192
202
  ${envDeclaration()}
193
203
 
@@ -227,6 +237,18 @@ export const config = defineConfig({
227
237
  theme: { defaultMode: 'dark' },
228
238
  ai: { mcp: { expose: true, path: '/mcp' } },
229
239
  });
240
+
241
+ /**
242
+ * Who \`x build\`'s budget pass renders \`app/\` as. A page that reads rows reads its actor's org
243
+ * through the typed handle, which refuses an actor with none (X_TENANCY_ACTOR_ORG_REQUIRED) — and
244
+ * the framework's default measurement actor is a \`service\` with no org, so such a route is never
245
+ * weighed (X_BUDGET_UNMEASURED). A member of the demo org is a declared viewer: no row has to
246
+ * exist. Loaded lazily, so this file's import graph stays the config's own.
247
+ */
248
+ defineMeasurementActor(async () => {
249
+ const { DEMO_ORG_ID } = await import('./apps/web/shared/demo-org');
250
+ return userActor({ id: 'x-build-measure', orgId: DEMO_ORG_ID, roles: ['member'] });
251
+ });
230
252
  `;
231
253
 
232
254
  // `biome.json` is strict JSON — Biome's own parser rejects a `//` comment in it, which made every
@@ -330,8 +352,44 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
330
352
  /** The two suites only the example slice writes a file for — a floor step with no file is red. */
331
353
  const EXAMPLE_FLOOR: readonly VerifyStepName[] = ['live', 'job'];
332
354
 
355
+ /**
356
+ * What a unit test cannot execute in a scaffolded app, each with its reason — `x doctor` prints
357
+ * them. Two kinds and no more: a file here is a file the floor does not see, so the list is not
358
+ * a place to put code that merely has no test yet.
359
+ */
360
+ export const SCAFFOLD_COVERAGE_EXCLUDE: readonly CoverageExclude[] = [
361
+ {
362
+ glob: 'apps/**/*.island.tsx',
363
+ why: 'an island runs in a browser: its test mounts a BUILT chunk (mountIsland), and Bun cannot map that chunk back to this source — its states are held by guards/island-without-states.ts and photographed by x shot --island',
364
+ },
365
+ {
366
+ glob: 'apps/*/server.ts',
367
+ why: 'the container entry point: it boots the roles and lives as long as the process — bin/dev and the image exercise it',
368
+ },
369
+ {
370
+ glob: 'apps/*/prerender.ts',
371
+ why: "the static build's entry point: x build --target static runs it, and the budgets step weighs what it writes",
372
+ },
373
+ ];
374
+
375
+ /**
376
+ * The coverage floor every scaffolded app starts on: the bar the framework holds its own packages
377
+ * to (`COVERAGE_BAR`), not a softer one for apps. `scaffold-smoke` runs the app's own `x verify`,
378
+ * so the scaffold is green at it on what it generates or the job is red.
379
+ */
333
380
  const verifyFloor = (example: boolean): string =>
334
- `${JSON.stringify({ steps: example ? [...SCAFFOLD_FLOOR, ...EXAMPLE_FLOOR] : SCAFFOLD_FLOOR }, null, 2)}\n`;
381
+ `${JSON.stringify(
382
+ {
383
+ steps: example ? [...SCAFFOLD_FLOOR, ...EXAMPLE_FLOOR] : SCAFFOLD_FLOOR,
384
+ coverage: {
385
+ lines: COVERAGE_BAR,
386
+ funcs: COVERAGE_BAR,
387
+ exclude: SCAFFOLD_COVERAGE_EXCLUDE,
388
+ },
389
+ },
390
+ null,
391
+ 2,
392
+ )}\n`;
335
393
 
336
394
  const bunfig = (): string => `[test]
337
395
  root = "."
@@ -6,18 +6,34 @@
6
6
  import type { GeneratedFile } from './naming';
7
7
  import { wrapList } from './wrap';
8
8
 
9
- /**
10
- * A role's grants as Biome prints them. The example slice's \`post:read\`/\`post:write\` ride along
11
- * with it: \`x g resource post\` would have granted them, and the scaffold writes that result.
12
- */
13
- const grants = (base: string, example: string | undefined): string =>
9
+ /** A role's grants as Biome prints them: one line when it fits, one grant per line when not. */
10
+ const grants = (list: readonly string[]): string =>
14
11
  wrapList(
15
12
  ' ',
16
13
  'grants: [',
17
- [base, ...(example === undefined ? [] : [example])].map((grant) => `'${grant}'`),
14
+ list.map((grant) => `'${grant}'`),
18
15
  '],',
19
16
  );
20
17
 
18
+ /** The three gates `@ultimat3/admin` asks before any per-table one — `ADMIN_OPERATION_RULES`. */
19
+ const ADMIN_GATES = ['admin:read', 'admin:write', 'admin:destroy'] as const;
20
+
21
+ /**
22
+ * What the two built-in screens with no table behind them ask beside `admin:read`: `/admin/jobs`
23
+ * and `/admin/audit`. Held by the role that runs the admin, or both links are a 403.
24
+ */
25
+ const ADMIN_SCREENS = ['job:read', 'audit:read'] as const;
26
+
27
+ /**
28
+ * What the admin asks for one table on the handle: `<table>:read|write|delete`. The example slice
29
+ * adds `posts`, so the scaffold writes the grants `x g resource post` would have — the same three
30
+ * `generate-grants.ts` adds for every entity generated later.
31
+ */
32
+ const tableGrants = (table: string): readonly string[] =>
33
+ ['read', 'write', 'delete'].map((verb) => `${table}:${verb}`);
34
+
35
+ const EXAMPLE_TABLE = 'posts';
36
+
21
37
  const rolesSource = (
22
38
  example: boolean,
23
39
  ): string => `// Who holds which permission, for the whole app. Roles are sugar: every one expands to a flat
@@ -33,6 +49,12 @@ const rolesSource = (
33
49
  // \`x g policy <feature>\` declares \`<feature>:read\` and \`<feature>:write\` and grants them below —
34
50
  // read to member, write to admin. A permission no role holds is one no actor can ever exercise,
35
51
  // and \`x verify\`'s policy step refuses it (X_PERMISSION_UNGRANTED).
52
+ //
53
+ // The admin at /admin asks TWO permissions per operation: its own gate (\`admin:read\` to look,
54
+ // \`admin:write\` to create or edit, \`admin:destroy\` to delete) and the table's
55
+ // (\`<table>:read|write|delete\`); its jobs and audit screens ask \`job:read\` and \`audit:read\`. \`x g entity\` and \`x g resource\` declare and grant a new table's
56
+ // three to \`admin\` here. A view-only operator is a role holding \`admin:read\` and each
57
+ // \`<table>:read\` and nothing else: every write is refused by the same decision that hid its button.
36
58
 
37
59
  import { definePermissions, defineRoles } from '@ultimat3/policy';
38
60
 
@@ -42,16 +64,30 @@ import { definePermissions, defineRoles } from '@ultimat3/policy';
42
64
  // in silence while nothing declared it, so every scaffolded app answered HTTP 500 on /dashboard
43
65
  // and /admin from its first \`x dev\`, under a green gate. A permission a role grants and a
44
66
  // permission a route requires both belong here.
45
- export const appPermissions = definePermissions(['admin:read', 'dashboard:read']);
67
+ ${wrapList(
68
+ '',
69
+ 'export const appPermissions = definePermissions([',
70
+ [
71
+ ...ADMIN_GATES,
72
+ ...ADMIN_SCREENS,
73
+ 'dashboard:read',
74
+ ...(example ? tableGrants(EXAMPLE_TABLE) : []),
75
+ ].map((permission) => `'${permission}'`),
76
+ ']);',
77
+ )}
46
78
 
47
79
  export const roles = defineRoles({
48
80
  member: {
49
81
  description: 'Signed in. Reads the app surface.',
50
- ${grants('dashboard:read', example ? 'post:read' : undefined)}
82
+ ${grants(['dashboard:read', ...(example ? ['post:read'] : [])])}
51
83
  },
52
84
  admin: {
53
85
  description: 'Runs the app: the /admin surface, plus everything a member may do.',
54
- ${grants('admin:read', example ? 'post:write' : undefined)}
86
+ ${grants([
87
+ ...ADMIN_GATES,
88
+ ...ADMIN_SCREENS,
89
+ ...(example ? ['post:write', ...tableGrants(EXAMPLE_TABLE)] : []),
90
+ ])}
55
91
  inherits: ['member'],
56
92
  },
57
93
  });
@@ -85,6 +121,17 @@ unitTest('admin inherits every member grant and adds its own', () => {
85
121
  for (const permission of member) expect(admin).toContain(permission);
86
122
  });
87
123
 
124
+ // \`admin:read\` alone looks; a write needs \`admin:write\` and a delete \`admin:destroy\`. The admin
125
+ // role holds all three, and a member none — so "view-only" is a role, never a mode.
126
+ unitTest('the admin role holds all three admin gates, and a member none of them', () => {
127
+ const admin = expandRoles(['admin'], roles);
128
+ const member = expandRoles(['member'], roles);
129
+ for (const gate of ['admin:read', 'admin:write', 'admin:destroy']) {
130
+ expect(admin).toContain(gate);
131
+ expect(member).not.toContain(gate);
132
+ }
133
+ });
134
+
88
135
  unitTest('a role nobody declared grants nothing', () => {
89
136
  expect(expandRoles(['visitor'], roles)).toEqual([]);
90
137
  });
@@ -183,7 +183,7 @@ const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
183
183
  block-size: tokens.space(2);
184
184
  border-radius: tokens.radius('full');
185
185
  background: tokens.role('success');
186
- animation: pulse 2.4s tokens.easing('in-out') infinite;
186
+ animation: pulse calc(#{tokens.duration('slower')} * 4) tokens.easing('in-out') infinite;
187
187
  }
188
188
 
189
189
  // Opacity only — the one property family the motion guard admits, and the global reduced-motion
@@ -245,45 +245,25 @@ const shellStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
245
245
  `;
246
246
 
247
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;
248
+ (): string => `// The frame, rendered the way a route renders it, with the app's own catalog registered by the
249
+ // import. What can go wrong is structural — the current page losing its \`aria-current\`, the
250
+ // page's actions landing outside the banner — and both are invisible to a typecheck.
251
+ import { expect, renderView, unitTest } from '@ultimat3/testing';
252
+ import { Shell } from './shell';
265
253
 
266
254
  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"');
255
+ const view = await renderView(Shell, { nav: 'dashboard', children: 'the page body' });
256
+ expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
272
257
  // 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>/);
258
+ expect(view.html.match(/<main\\b/g)).toHaveLength(1);
259
+ expect(view.html.match(/<nav\\b/g)).toHaveLength(1);
260
+ expect(view.html).toMatch(/<main[^>]*>the page body<\\/main>/);
276
261
  });
277
262
 
278
263
  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>/);
264
+ const view = await renderView(Shell, { actions: 'the page actions', children: 'body' });
265
+ expect(view.html).not.toContain('aria-current="page"');
266
+ expect(view.html).toMatch(/<header[^>]*>[\\s\\S]*the page actions[\\s\\S]*<\\/header>/);
287
267
  });
288
268
  `;
289
269
 
@@ -292,17 +272,11 @@ const toggleIsland =
292
272
  // browser downloads. Named by SPECIFIER from the page, never by import:
293
273
  // const ThemeSwitch = island({ src: '../../shared/theme-toggle.island.tsx', props: ['locale'] });
294
274
  //
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.
275
+ // The control itself is the catalog's \`ThemeToggle\`; this file is only the runtime it needs. It
276
+ // reads the browser on its own: the theme the boot stamped on \`<html>\`, then \`theme.defaultMode\`
277
+ // (stamped beside it), then the OS.
297
278
 
298
- import {
299
- browserThemeEnv,
300
- setSolidRuntime,
301
- THEME_ATTRIBUTE,
302
- type ThemeEnv,
303
- ThemeToggle,
304
- UiProvider,
305
- } from '@ultimat3/ui';
279
+ import { setSolidRuntime, ThemeToggle, UiProvider } from '@ultimat3/ui';
306
280
  import {
307
281
  createContext,
308
282
  createEffect,
@@ -318,17 +292,6 @@ export interface ThemeToggleIslandProps {
318
292
  readonly locale: string;
319
293
  }
320
294
 
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
295
  /**
333
296
  * The one export the hydration runtime calls. \`setSolidRuntime\` comes FIRST and is not optional:
334
297
  * \`@ultimat3/ui\` imports types from solid-js and never a runtime, so the reactive graph a
@@ -338,12 +301,11 @@ export const bootedEnv = (): ThemeEnv => {
338
301
  */
339
302
  export function mount(el: HTMLElement, props: ThemeToggleIslandProps): void {
340
303
  setSolidRuntime({ createContext, useContext, createSignal, createMemo, createEffect, onCleanup });
341
- const env = bootedEnv();
342
304
  el.textContent = '';
343
305
  render(
344
306
  () => (
345
307
  <UiProvider locale={props.locale}>
346
- <ThemeToggle mode="toggle" env={env} />
308
+ <ThemeToggle mode="toggle" />
347
309
  </UiProvider>
348
310
  ),
349
311
  el,
@@ -373,26 +335,14 @@ export const themeToggleStates = defineIslandStates({
373
335
 
374
336
  const toggleTest =
375
337
  (): 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.
338
+ // against a DOM small enough to read, and clicked. What it pins: the boot's verdict on \`<html>\` is
339
+ // what the control starts from, and a click writes the key the boot script reads back next load.
379
340
 
380
341
  import { join } from 'node:path';
381
342
  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';
343
+ import { describeIslandState, expect, test } from '@ultimat3/testing';
344
+ import { THEME_ATTRIBUTE, THEME_DEFAULT_ATTRIBUTE, THEME_STORAGE_KEY } from '@ultimat3/ui';
345
+ import { themeToggleStates } from './theme-toggle.island.states';
396
346
 
397
347
  /** What the island reads through \`browserThemeEnv\`: storage and the OS query, both fakes. */
398
348
  const stored = new Map<string, string>();
@@ -401,49 +351,68 @@ const localStorage = {
401
351
  setItem: (key: string, value: string): void => void stored.set(key, value),
402
352
  removeItem: (key: string): void => void stored.delete(key),
403
353
  };
354
+ /** One OS query, shared by every \`matchMedia\` call: \`flipOs\` changes it and tells the listeners. */
355
+ const os = { dark: false, listeners: new Set<() => void>() };
404
356
  const matchMedia = (): Record<string, unknown> => ({
405
- matches: false,
406
- addEventListener: () => {},
407
- removeEventListener: () => {},
357
+ get matches(): boolean {
358
+ return os.dark;
359
+ },
360
+ addEventListener: (_type: string, listener: () => void) => void os.listeners.add(listener),
361
+ removeEventListener: (_type: string, listener: () => void) => void os.listeners.delete(listener),
408
362
  });
363
+ const flipOs = (dark: boolean): void => {
364
+ os.dark = dark;
365
+ for (const listener of os.listeners) listener();
366
+ };
409
367
 
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
- });
368
+ // The state \`x shot --island theme-toggle\` photographs is the state this mounts: one manifest,
369
+ // so the picture and the assertion cannot be of two different components.
370
+ const island = {
371
+ build: buildIslands,
372
+ root: join(import.meta.dir, ${upToAppRoot(SHELL_DIR)}),
373
+ shell: '<button type="button">theme</button>',
374
+ globals: { localStorage, matchMedia },
375
+ };
426
376
 
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/);
377
+ describeIslandState(themeToggleStates, 'default', island, (mounted) => {
378
+ test('the default state renders the catalog control over the server shell', () => {
379
+ // \`aria-pressed\` is the live control's: the shell's own button carries none, so a mount that
380
+ // rendered nothing leaves this selector with no match.
381
+ expect(mounted().find('button[aria-pressed]')).not.toBeNull();
382
+ expect(mounted().code).not.toMatch(/\\bReact\\b/);
383
+ // The state's own prop reached the island: the provider stamps the locale it was handed.
384
+ expect(mounted().documentElement.getAttribute('lang')).toBe('en');
431
385
  });
432
386
 
433
387
  test('a click stores the opposite theme and applies it to <html>', () => {
434
388
  // No stamp on this document, so the island booted light; the click flips it. \`false\` means
435
389
  // no handler ran — an onClick that never reached the DOM looks identical to a selector typo.
436
- expect(mounted.fire('button', 'click')).toBe(true);
390
+ expect(mounted().fire('button', 'click')).toBe(true);
437
391
  expect(stored.get(THEME_STORAGE_KEY)).toBe('dark');
438
- expect(mounted.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
392
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
393
+ });
394
+
395
+ test('with nothing stored, a click flips the theme the boot stamped — not the OS', () => {
396
+ // The OS fake says light. The boot stamped this app's \`defaultMode\`, dark, so the visible
397
+ // theme is dark and the first click must go light; a toggle that asked the OS would write the
398
+ // dark already on screen and look dead.
399
+ stored.clear();
400
+ mounted().documentElement.setAttribute(THEME_DEFAULT_ATTRIBUTE, 'dark');
401
+ mounted().documentElement.setAttribute(THEME_ATTRIBUTE, 'dark');
402
+ expect(mounted().fire('button', 'click')).toBe(true);
403
+ expect(stored.get(THEME_STORAGE_KEY)).toBe('light');
404
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('light');
439
405
  });
440
406
 
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);
407
+ test("an OS flip is followed while nothing is stored and the app's default is 'system'", () => {
408
+ stored.clear();
409
+ mounted().documentElement.setAttribute(THEME_DEFAULT_ATTRIBUTE, 'system');
410
+ flipOs(true);
411
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
412
+ // A fixed default is the app's own opinion: the OS does not override it.
413
+ mounted().documentElement.setAttribute(THEME_DEFAULT_ATTRIBUTE, 'dark');
414
+ flipOs(false);
415
+ expect(mounted().documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('dark');
447
416
  });
448
417
  });
449
418
  `;
@@ -45,7 +45,7 @@ export const config = defineRoute({
45
45
  }),
46
46
  });
47
47
 
48
- export function HomePage() {
48
+ export function Page() {
49
49
  const t = useT();
50
50
 
51
51
  return (
@@ -259,23 +259,49 @@ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
259
259
  }
260
260
  `;
261
261
 
262
- const sitePageTest =
263
- (): string => `// The landing page ships zero JS and declares its metadata. Both are promises the file makes in
264
- // its config, and both are the kind that rot silently when someone adds one import.
265
- import { metaContextFor, routeDataFor } from '@ultimat3/render';
266
- import { expect, unitTest } from '@ultimat3/testing';
267
- import { config } from './page';
268
-
269
- // The same two objects a render builds: \`routeDataFor\` resolves the route's data once, and
270
- // \`metaContextFor\` wraps it the way every render mode wraps it before calling \`meta\`.
271
- const ctx = { params: {}, url: 'https://example.test/' };
272
-
273
- unitTest('the landing page ships zero JS and declares metadata', async () => {
274
- expect(config.render).toBe('static');
275
- expect(config.hydrate).toBe('never');
276
- expect(config.budget.js).toBe('0kb');
277
- const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
278
- expect(meta.title ?? '').not.toBe('');
262
+ const sitePageTest = (
263
+ app: NameSet,
264
+ ): string => `// The landing page, rendered: what a visitor reads, where its two calls to action lead, and that
265
+ // it ships zero JS. Each is a promise the file makes, and each rots silently on one import.
266
+ ${sortedImports([
267
+ `import { useT } from '@${app.kebab}/i18n';`,
268
+ "import { expect, renderRoute, unitTest } from '@ultimat3/testing';",
269
+ ])}
270
+ import * as page from './page';
271
+
272
+ const url = 'https://example.test/';
273
+
274
+ unitTest('the landing page says what the catalog says, under one h1', async () => {
275
+ const t = useT();
276
+ const view = await renderRoute(page, { url });
277
+ expect(view.html.match(/<h1\\b/g)).toHaveLength(1);
278
+ expect(view.text).toContain(t('site.home.headline'));
279
+ expect(view.text).toContain(t('site.home.lede'));
280
+ // Three facts, each a heading and a body.
281
+ expect(view.html.match(/<h2\\b/g)).toHaveLength(3);
282
+ for (const key of ['site.home.f1Title', 'site.home.f2Body', 'site.home.f3Title'] as const) {
283
+ expect(view.text).toContain(t(key));
284
+ }
285
+ // A key no catalog holds renders as \u27e6key\u27e7 — to a visitor, with every other check green.
286
+ expect(view.text).not.toContain('\u27e6');
287
+ });
288
+
289
+ unitTest('its two calls to action lead into the product', async () => {
290
+ const view = await renderRoute(page, { url });
291
+ const hrefs = [...view.html.matchAll(/<a\\b[^>]*href="([^"]*)"/g)].map((match) => match[1]);
292
+ expect(hrefs).toEqual(['/dashboard', '/admin']);
293
+ });
294
+
295
+ unitTest('it ships zero JS and declares the metadata a search result renders', async () => {
296
+ const t = useT();
297
+ const view = await renderRoute(page, { url });
298
+ expect(page.config.render).toBe('static');
299
+ expect(page.config.hydrate).toBe('never');
300
+ expect(page.config.budget.js).toBe('0kb');
301
+ // What the render DID, beside what the config declares: no island left the page.
302
+ expect(view.islands).toEqual([]);
303
+ expect(view.meta.title).toBe(t('site.home.title'));
304
+ expect(view.meta.description).toBe(t('site.home.description'));
279
305
  });
280
306
  `;
281
307
 
@@ -283,5 +309,5 @@ unitTest('the landing page ships zero JS and declares metadata', async () => {
283
309
  export const siteFiles = (app: NameSet): readonly GeneratedFile[] => [
284
310
  { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
285
311
  { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
286
- { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
312
+ { path: 'apps/web/site/page.test.ts', contents: sitePageTest(app) },
287
313
  ];
@@ -31,6 +31,23 @@ export function Card(props: CardProps) {
31
31
  }
32
32
  `;
33
33
 
34
+ const uiCardTest =
35
+ (): string => `// The card, rendered the way a page renders it. What a typecheck cannot see is where a prop
36
+ // lands: the title is the card's heading, and the children follow it inside the same section.
37
+ import { expect, renderView, unitTest } from '@ultimat3/testing';
38
+ import { Card } from './card';
39
+
40
+ unitTest('the card puts its title in a heading and its children after it', async () => {
41
+ const view = await renderView(Card, { title: 'Usage', children: 'body copy' });
42
+ expect(view.html).toMatch(/^<section[^>]*><h2[^>]*>Usage<\\/h2>body copy<\\/section>$/);
43
+ });
44
+
45
+ unitTest('a card with no children is its heading and nothing else', async () => {
46
+ const view = await renderView(Card, { title: 'Empty' });
47
+ expect(view.text).toBe('Empty');
48
+ });
49
+ `;
50
+
34
51
  const uiCardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
35
52
 
36
53
  .card {
@@ -52,5 +69,6 @@ export const uiPackageFiles = (app: NameSet): readonly GeneratedFile[] => [
52
69
  ...packageShapeFiles(app, 'ui', DESCRIPTION),
53
70
  { path: 'packages/ui/src/index.ts', contents: uiIndex() },
54
71
  { path: 'packages/ui/src/card.tsx', contents: uiCard() },
72
+ { path: 'packages/ui/src/card.test.ts', contents: uiCardTest() },
55
73
  { path: 'packages/ui/src/card.module.scss', contents: uiCardStyle() },
56
74
  ];