@ultimat3/cli 23.0.0 → 25.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -1,9 +1,11 @@
1
1
  // The `--example` dashboard: the seeded `post` slice from above. Real rows through the slice's own
2
- // repo, aggregated by a pure view module this file also emits, so the numbers are testable without
3
- // a database. The page's route declaration and its island come from `scaffold-dashboard-shared.ts`.
2
+ // query — never its repo, which the `boundaries` step refuses a route (X_BOUNDARY_ROUTE_TO_DB) —
3
+ // aggregated by a pure view module this file also emits, so the numbers are testable without a
4
+ // database. The page's route declaration and its island come from `scaffold-dashboard-shared.ts`.
4
5
 
5
6
  import { sortedImports } from './imports';
6
7
  import type { GeneratedFile, NameSet } from './naming';
8
+ import { examplePageTest, exampleViewTest } from './scaffold-dashboard-example-tests';
7
9
  import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffold-dashboard-shared';
8
10
 
9
11
  // Plain strings for the framework lines, never template literals: the workspace-dependency scanner
@@ -12,20 +14,25 @@ import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffol
12
14
  const examplePage = (
13
15
  app: NameSet,
14
16
  ): string => `// The authed dashboard: what the seeded \`post\` slice looks like from above. Real rows, read
15
- // through the slice's own repo and aggregated by \`dashboard-view.ts\`, which is pure so the numbers
16
- // are testable without a database.
17
+ // through the slice's own query and aggregated by \`dashboard-view.ts\`, which is pure so the
18
+ // numbers are testable without a database. Every chart is server markup — an svg and a hidden data table —
19
+ // so the page's only script is still the theme toggle.
17
20
  //
18
21
  // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
19
22
  ${sortedImports([
20
23
  `import { useT } from '@${app.kebab}/i18n';`,
21
24
  "import { isUltimateError } from '@ultimat3/core';",
22
25
  "import { currentLocale } from '@ultimat3/i18n';",
26
+ "import { formatMoney } from '@ultimat3/money';",
23
27
  "import { defineRoute, island } from '@ultimat3/render';",
24
28
  [
25
29
  'import {',
26
- ' BarChart,',
30
+ ' AreaChart,',
27
31
  ' DataTable,',
32
+ ' DonutChart,',
33
+ ' Gauge,',
28
34
  ' Grid,',
35
+ ' InlineBar,',
29
36
  ' PageHeader,',
30
37
  ' RelativeTime,',
31
38
  ' Section,',
@@ -35,13 +42,15 @@ ${sortedImports([
35
42
  ].join('\n'),
36
43
  ])}
37
44
  import { Shell } from '../../shared/shell';
38
- import * as repo from '../post/repo';
45
+ import { postList } from '../post/live/post-list';
39
46
  import {
40
47
  bucketByDay,
41
48
  CHART_DAYS,
42
49
  formatCount,
43
50
  type PostRow,
44
51
  postStats,
52
+ priceCeilings,
53
+ priceMix,
45
54
  toPostRow,
46
55
  } from './dashboard-view';
47
56
  import styles from './page.module.scss';
@@ -53,7 +62,7 @@ const ROW_LIMIT = 500;
53
62
  const TABLE_ROWS = 10;
54
63
 
55
64
  export interface DashboardData {
56
- /** Newest first, as the repo orders them. */
65
+ /** Newest first, as the query orders them. */
57
66
  readonly rows: readonly PostRow[];
58
67
  /** The instant the windows were cut at, so server and test agree on "the last 7 days". */
59
68
  readonly now: string;
@@ -78,11 +87,13 @@ export async function dashboardData(
78
87
  }
79
88
 
80
89
  /**
81
- * Whose posts: the ACTOR's org. The repo names none — the typed handle scopes every read to the
82
- * request's actor — so the viewer \`auth/dev-actor.ts\` resolves sees the rows
90
+ * Whose posts: the ACTOR's org, and only for an actor \`post:read\` admits. Through the query, never
91
+ * the repo: \`dashboard:read\` lets a viewer onto this page and says nothing about posts, and the
92
+ * query is where the read's policy, order and bound live. The typed handle under it scopes every
93
+ * read to the request's actor, so the viewer \`auth/dev-actor.ts\` resolves sees the rows
83
94
  * \`packages/db/src/seed.ts\` wrote, and a real session sees its own org's the day one exists.
84
95
  */
85
- const load = (): Promise<DashboardData> => dashboardData(() => repo.list(ROW_LIMIT));
96
+ const load = (): Promise<DashboardData> => dashboardData(() => postList({ limit: ROW_LIMIT }));
86
97
 
87
98
  ${themeIsland}
88
99
 
@@ -98,7 +109,10 @@ export function Page(props: PageProps) {
98
109
  const now = new Date(props.data.now);
99
110
  const stats = postStats(props.data.rows, now);
100
111
  const points = bucketByDay(props.data.rows, now, CHART_DAYS);
112
+ const mix = priceMix(props.data.rows);
101
113
  const latest = props.data.rows.slice(0, TABLE_ROWS);
114
+ // Per currency: a bar is a share of the dearest post IN ITS CURRENCY, never of another's minor units.
115
+ const ceilings = priceCeilings(latest);
102
116
 
103
117
  return (
104
118
  <Shell
@@ -109,8 +123,8 @@ export function Page(props: PageProps) {
109
123
  >
110
124
  <div class={styles.page}>
111
125
  <PageHeader title={t('app.dashboard.title')} description={t('app.dashboard.subtitle')} />
112
- {/* 10rem, not the catalog's 16rem default: three tiles fit across a tablet's main column
113
- instead of leaving one alone on a second row. */}
126
+ {/* 10rem, not the catalog's 16rem default: two tiles sit side by side on a phone and four
127
+ across a monitor, instead of one alone on a row. */}
114
128
  <Grid minColumn="10rem">
115
129
  <StatTile
116
130
  stat="posts-total"
@@ -131,10 +145,47 @@ export function Page(props: PageProps) {
131
145
  value={formatCount(stats.today, locale)}
132
146
  hint={t('app.dashboard.hintToday')}
133
147
  />
148
+ <StatTile
149
+ stat="posts-paid"
150
+ label={t('app.dashboard.statPaid')}
151
+ value={formatCount(mix.paid, locale)}
152
+ hint={t('app.dashboard.hintPaid')}
153
+ />
134
154
  </Grid>
135
- <Section title={t('app.dashboard.chartTitle')} description={t('app.dashboard.chartRange')}>
136
- <BarChart label={t('app.dashboard.chartLabel')} points={points} />
137
- </Section>
155
+ <div class={styles.charts}>
156
+ <Section
157
+ title={t('app.dashboard.chartTitle')}
158
+ description={t('app.dashboard.chartRange')}
159
+ >
160
+ <AreaChart
161
+ label={t('app.dashboard.chartLabel')}
162
+ keys={points.map((point) => point.key)}
163
+ series={[
164
+ { label: t('app.dashboard.chartSeries'), values: points.map((p) => p.value) },
165
+ ]}
166
+ keyLabel={t('app.dashboard.chartKey')}
167
+ />
168
+ </Section>
169
+ <Section title={t('app.dashboard.mixTitle')}>
170
+ <div class={styles.side}>
171
+ <DonutChart
172
+ label={t('app.dashboard.mixLabel')}
173
+ segments={[
174
+ { label: t('app.dashboard.mixFree'), value: mix.free },
175
+ { label: t('app.dashboard.mixPaid'), value: mix.paid },
176
+ ]}
177
+ centreLabel={t('app.dashboard.mixTotal')}
178
+ keyLabel={t('app.dashboard.mixKey')}
179
+ valueLabel={t('app.dashboard.statTotal')}
180
+ />
181
+ <Gauge
182
+ label={t('app.dashboard.gaugeLabel')}
183
+ value={stats.lastWeek}
184
+ max={stats.total}
185
+ />
186
+ </div>
187
+ </Section>
188
+ </div>
138
189
  <Section title={t('app.dashboard.tableTitle')}>
139
190
  <DataTable
140
191
  caption={t('app.dashboard.tableCaption')}
@@ -143,9 +194,24 @@ export function Page(props: PageProps) {
143
194
  emptyTitle={t('app.dashboard.emptyTitle')}
144
195
  columns={[
145
196
  { key: 'title', header: t('app.dashboard.columnTitle'), cell: (row) => row.title },
197
+ {
198
+ key: 'price',
199
+ header: t('app.dashboard.columnPrice'),
200
+ numeric: true,
201
+ cell: (row) => (
202
+ <InlineBar
203
+ value={row.price.minor}
204
+ max={ceilings.get(row.price.currency) ?? 0}
205
+ text={formatMoney(row.price, locale)}
206
+ />
207
+ ),
208
+ },
146
209
  {
147
210
  key: 'createdAt',
148
211
  header: t('app.dashboard.columnCreated'),
212
+ // Below \`md\` the date folds into the row's "more" disclosure: the title and the
213
+ // price are what a phone has room for, and nothing is lost.
214
+ priority: 2,
149
215
  cell: (row) => (
150
216
  <RelativeTime value={row.createdAt} now={props.data.now} locale={locale} />
151
217
  ),
@@ -163,6 +229,7 @@ const exampleView =
163
229
  (): string => `// The dashboard's numbers, apart from its markup: pure functions over the rows the page loaded,
164
230
  // so the server render and a test cannot disagree about a figure. No I/O, no \`t()\`, no JSX.
165
231
 
232
+ import type { Money } from '@ultimat3/money';
166
233
  import { type ChartPoint, deltaOf, type StatDelta } from '@ultimat3/ui';
167
234
 
168
235
  /** Days of history the chart shows, oldest first. */
@@ -174,26 +241,54 @@ const DAY_MS = 86_400_000;
174
241
  export interface PostRow {
175
242
  readonly id: string;
176
243
  readonly title: string;
244
+ /** Integer minor units plus an ISO code, never a float — the entity's \`money()\` column. */
245
+ readonly price: Money;
177
246
  readonly createdAt: string;
178
247
  }
179
248
 
180
249
  /**
181
- * Duck-typed rather than the entity's own row: the page only ever reads these three columns. The
182
- * repo reads through the typed handle, so \`createdAt\` arrives as the \`Date\` the entity declares.
250
+ * Duck-typed rather than the entity's own row: the page only ever reads these four columns. The
251
+ * query reads through the typed handle, so \`createdAt\` arrives as the \`Date\` the entity declares.
183
252
  */
184
253
  export function toPostRow(row: {
185
254
  readonly id: string;
186
255
  readonly title: string;
256
+ readonly price: Money;
187
257
  readonly createdAt: Date | string;
188
258
  }): PostRow {
189
259
  const at = row.createdAt;
190
260
  return {
191
261
  id: row.id,
192
262
  title: row.title,
263
+ price: row.price,
193
264
  createdAt: at instanceof Date ? at.toISOString() : at,
194
265
  };
195
266
  }
196
267
 
268
+ /** Free (a zero price) against paid, over every row loaded — the two halves of the ring. */
269
+ export interface PriceMix {
270
+ readonly free: number;
271
+ readonly paid: number;
272
+ }
273
+
274
+ export function priceMix(rows: readonly PostRow[]): PriceMix {
275
+ const paid = rows.filter((row) => row.price.minor > 0).length;
276
+ return { free: rows.length - paid, paid };
277
+ }
278
+
279
+ /**
280
+ * The dearest price per currency, in minor units — what each row's in-cell bar is a share of.
281
+ * Per currency because 1,900 cents and 1,900 yen are not the same height.
282
+ */
283
+ export function priceCeilings(rows: readonly PostRow[]): ReadonlyMap<string, number> {
284
+ const ceilings = new Map<string, number>();
285
+ for (const row of rows) {
286
+ const { currency, minor } = row.price;
287
+ ceilings.set(currency, Math.max(ceilings.get(currency) ?? 0, minor));
288
+ }
289
+ return ceilings;
290
+ }
291
+
197
292
  export interface PostStats {
198
293
  readonly total: number;
199
294
  /** Created in the seven days ending at \`now\`. */
@@ -255,161 +350,6 @@ export const formatCount = (value: number, locale: string): string =>
255
350
  new Intl.NumberFormat(locale).format(value);
256
351
  `;
257
352
 
258
- const exampleViewTest =
259
- (): string => `// The numbers the dashboard shows, pinned against a fixed clock. The interesting cases are the
260
- // window edges: a post from eight days ago is in the prior week, not this one, and a day with no
261
- // posts is still a bar.
262
- import { expect, unitTest } from '@ultimat3/testing';
263
- import { bucketByDay, formatCount, type PostRow, postStats, toPostRow } from './dashboard-view';
264
-
265
- const NOW = new Date('2026-03-15T12:00:00.000Z');
266
- const DAY_MS = 86_400_000;
267
-
268
- const row = (id: string, daysAgo: number): PostRow => ({
269
- id,
270
- title: \`post \${id}\`,
271
- createdAt: new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString(),
272
- });
273
-
274
- const rows = [row('a', 1), row('b', 3), row('c', 8)];
275
-
276
- unitTest('postStats counts the week, compares it with the prior one, and counts today', () => {
277
- const stats = postStats(rows, NOW);
278
- expect(stats.total).toBe(3);
279
- expect(stats.lastWeek).toBe(2);
280
- // Two this week against one the week before: +100%, and the chip says so.
281
- expect(stats.delta).toEqual({ text: '+100%', trend: 'up' });
282
- // 'a' is yesterday and nothing was created on 03-15 itself.
283
- expect(stats.today).toBe(0);
284
- expect(postStats([...rows, row('d', 0.25)], NOW).today).toBe(1);
285
- });
286
-
287
- unitTest('a zero baseline yields no delta, and no rows count nothing', () => {
288
- expect(postStats([row('a', 1)], NOW).delta).toBeUndefined();
289
- expect(postStats([], NOW)).toEqual({ total: 0, lastWeek: 0, delta: undefined, today: 0 });
290
- });
291
-
292
- unitTest('bucketByDay is one bar per day, oldest first, quiet days included', () => {
293
- const points = bucketByDay(rows, NOW, 14);
294
- expect(points).toHaveLength(14);
295
- expect(points.at(-1)?.key).toBe('03-15');
296
- expect(points[0]?.key).toBe('03-02');
297
- expect(points.reduce((sum, point) => sum + point.value, 0)).toBe(3);
298
- // Eight days ago is 03-07: inside a 14-day window, so the bar is there.
299
- expect(points.find((point) => point.key === '03-07')?.value).toBe(1);
300
- expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
301
- });
302
-
303
- unitTest('toPostRow serialises a Date and passes ISO text through', () => {
304
- const at = new Date('2026-01-02T03:04:05.000Z');
305
- expect(toPostRow({ id: 'x', title: 't', createdAt: at }).createdAt).toBe(at.toISOString());
306
- expect(toPostRow({ id: 'x', title: 't', createdAt: '2026-01-01' }).createdAt).toBe('2026-01-01');
307
- });
308
-
309
- unitTest('formatCount follows the locale', () => {
310
- expect(formatCount(1234, 'en')).toBe('1,234');
311
- expect(formatCount(1234, 'de')).toBe('1.234');
312
- });
313
- `;
314
-
315
- const examplePageTest = (
316
- app: NameSet,
317
- ): string => `// The dashboard, rendered as a request renders it: \`load\` reads the actor's org through the
318
- // slice's repo — the in-memory driver under test — and the page turns the rows into tiles, a chart
319
- // and a table. Losing the policy is the other regression worth a test: the page still renders, to
320
- // anyone.
321
- ${sortedImports([
322
- `import { driver } from '@${app.kebab}/db';`,
323
- `import { useT } from '@${app.kebab}/i18n';`,
324
- "import { createContext, frozenClock, runWithContext } from '@ultimat3/core';",
325
- "import { testActor } from '@ultimat3/policy';",
326
- "import { dbUnavailable } from '@ultimat3/db';",
327
- "import { afterEach, expect, renderRoute, unitTest } from '@ultimat3/testing';",
328
- ])}
329
- import { DEMO_ORG_ID } from '../../shared/demo-org';
330
- import * as repo from '../post/repo';
331
- import * as page from './page';
332
-
333
- const url = 'https://example.test/dashboard';
334
- const viewer = testActor('viewer', { orgId: DEMO_ORG_ID }).actor;
335
- const stranger = testActor('stranger', { orgId: '00000000-0000-4000-8000-000000000009' }).actor;
336
-
337
- const DAY_MS = 86_400_000;
338
-
339
- /**
340
- * One post in the viewer's org, written the way the app writes one — \`daysAgo\` days before now.
341
- * The request's clock is what stamps \`createdAt\`, so the test chooses the instant.
342
- */
343
- const post = (title: string, daysAgo: number) => {
344
- const clock = frozenClock(new Date(Date.now() - daysAgo * DAY_MS));
345
- const draft = { orgId: DEMO_ORG_ID, title, price: { minor: 0, currency: 'USD' } };
346
- return runWithContext(createContext({ actor: viewer, clock }), () => repo.insert(draft));
347
- };
348
-
349
- /** The figure a tile shows, read off the markup by the \`stat\` the page gave it. */
350
- const stat = (html: string, name: string): string | undefined =>
351
- html.match(new RegExp(\`data-stat="\${name}">([^<]*)<\`))?.[1];
352
-
353
- // One store per process: without this, one test's rows are the next test's fixtures.
354
- afterEach(() => {
355
- driver.reset?.();
356
- });
357
-
358
- unitTest('the dashboard is gated, renders per request, and hydrates one island', async () => {
359
- const view = await renderRoute(page, { url, actor: viewer });
360
- expect(page.config.render).toBe('ssr');
361
- expect(page.config.policy?.permission).toBe('dashboard:read');
362
- expect(page.config.offline).toBe('runtime');
363
- expect(page.config.budget.js).toBe('60kb');
364
- // What the render emitted, beside what the config declares: the theme toggle, and only it.
365
- expect(view.islands.map((island) => [island.moduleId, island.strategy])).toEqual([
366
- ['shared-theme-toggle', 'visible'],
367
- ]);
368
- });
369
-
370
- unitTest('it counts and lists the posts of the org that is looking', async () => {
371
- await post('Last month', 30);
372
- await post('This week', 3);
373
- await post('Today', 0);
374
- const view = await renderRoute(page, { url, actor: viewer });
375
- // Newest first, as the repo orders them — and each tile counts its own window.
376
- expect(view.data.rows.map((row) => row.title)).toEqual(['Today', 'This week', 'Last month']);
377
- expect(stat(view.html, 'posts-total')).toBe('3');
378
- expect(stat(view.html, 'posts-week')).toBe('2');
379
- expect(stat(view.html, 'posts-today')).toBe('1');
380
- // One table row per post, and one bar per day of the window whether or not it has posts.
381
- expect(view.html.match(/<tr data-row=/g)).toHaveLength(3);
382
- expect(view.html.match(/data-bar="true"/g)).toHaveLength(14);
383
- expect(view.text).toContain('This week');
384
- // The sidebar knows where it is.
385
- expect(view.html).toMatch(/<a href="\\/dashboard" aria-current="page"/);
386
- });
387
-
388
- unitTest('another org sees none of them: the page says so instead of an empty table', async () => {
389
- const t = useT();
390
- await post('Not theirs', 0);
391
- const view = await renderRoute(page, { url, actor: stranger });
392
- expect(view.data.rows).toEqual([]);
393
- expect(stat(view.html, 'posts-total')).toBe('0');
394
- expect(view.html).not.toContain('<tr data-row=');
395
- expect(view.text).toContain(t('app.dashboard.emptyTitle'));
396
- expect(view.meta.title).toBe(t('app.dashboard.title'));
397
- expect(view.meta.description).toBe(t('app.dashboard.description'));
398
- });
399
-
400
- unitTest('the build measures the empty page: no database is not a failure there', async () => {
401
- const measured = await page.dashboardData(() => Promise.reject(dbUnavailable('no database')));
402
- // The clock is frozen under test, so the instant the windows are cut at is this one.
403
- expect(measured).toEqual({ rows: [], now: new Date().toISOString() });
404
- });
405
-
406
- unitTest('any other failure of the read propagates', async () => {
407
- const cause = new TypeError('the read broke');
408
- const read = (): Promise<never> => Promise.reject(cause);
409
- expect(await page.dashboardData(read).catch((error: unknown) => error)).toBe(cause);
410
- });
411
- `;
412
-
413
353
  /** The example dashboard's page, view module and the tests beside both. */
414
354
  export const exampleDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
415
355
  { path: `${DASHBOARD_DIR}/page.tsx`, contents: examplePage(app) },
@@ -22,7 +22,7 @@ export const routeConfig = (load: string): string => `export const config = defi
22
22
  // toggle, provider, the ui runtime they share and the error registry are the rest. It was
23
23
  // 60.9kb before the framework stopped shipping solid-js twice and the i18n catalog with it
24
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\`.
25
+ // before that, kept rather than tightened so a Bun patch cannot red a first \`bun run check\`.
26
26
  budget: { js: '60kb' },${load}
27
27
  meta: ({ t }) => ({
28
28
  title: t('app.dashboard.title'),
@@ -55,4 +55,65 @@ export const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as token
55
55
  margin-inline: auto;
56
56
  color: tokens.role('fg');
57
57
  }
58
+
59
+ // The trend beside the ring and the dial: side by side once the main column is wide enough for the
60
+ // area chart to keep its 14 day labels legible, stacked before that.
61
+ .charts {
62
+ display: grid;
63
+ gap: tokens.space(6);
64
+ min-inline-size: 0;
65
+
66
+ @include tokens.respond-to('lg') {
67
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
68
+ align-items: start;
69
+ }
70
+ }
71
+
72
+ // The ring and the dial share a column: two across on a tablet, where one alone would leave half
73
+ // the row empty, one above the other on a phone and in the narrow side column of a monitor.
74
+ .side {
75
+ display: grid;
76
+ gap: tokens.space(4);
77
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
78
+ }
79
+ `;
80
+
81
+ /**
82
+ * `apps/web/shared/theme.ts`: the app's brand, which the boot reads by this path and inlines in every
83
+ * document (`theme-brand.ts`). Emitted as the default theme — `defineTheme({})` renders no CSS, so
84
+ * no tag and no CSP entry — with the preset one edit away: the look is the app's choice (axiom 8).
85
+ */
86
+ export const themeFile =
87
+ (): string => `// This app's brand: the boot reads \`brand\` from this file and inlines it in every document,
88
+ // after global.scss, with its hash in the CSP. \`defineTheme({})\` is @ultimat3/ui's default theme
89
+ // and renders nothing, so today no tag is emitted.
90
+ //
91
+ // \`defineTheme()\` is the one seam for restyling — colours, radii, fonts and shadows by role,
92
+ // validated at the call. The dashboard look (near-black panels, a cyan accent, glowing edges, its
93
+ // own eight chart series) is one edit:
94
+ //
95
+ // export const brand = defineTheme({ preset: 'scifi' });
96
+ //
97
+ // Every other slot layers onto the preset role by role:
98
+ // defineTheme({ preset: 'scifi', colors: { dark: { 'chart-1': '120 220 255' } } })
99
+ // A preset never forces a theme: to open dark whatever the OS says, set \`theme.defaultMode\` in
100
+ // app.config.ts.
101
+ import { defineTheme } from '@ultimat3/ui';
102
+
103
+ export const brand = defineTheme({});
104
+ `;
105
+
106
+ /** Its test: what the brand renders is a fact the app pins, so opting in is a visible diff. */
107
+ export const themeTest =
108
+ (): string => `// The brand the boot inlines. The default theme renders no CSS — no \`<style>\`, no CSP entry —
109
+ // and the preset is a whole palette; either way \`defineTheme()\` validated it at import.
110
+ import { expect, unitTest } from '@ultimat3/testing';
111
+ import { defineTheme } from '@ultimat3/ui';
112
+ import { brand } from './theme';
113
+
114
+ unitTest('the app ships the default theme until it opts into a preset', () => {
115
+ expect(brand.css).toBe('');
116
+ // The opt-in is one edit, and what it would ship is real CSS the boot inlines.
117
+ expect(defineTheme({ preset: 'scifi' }).css).toContain('--color-');
118
+ });
58
119
  `;
@@ -2,25 +2,29 @@
2
2
  // stopped being an `<h1>` in a panel. Two shapes, one per `x new` invocation, and both show only
3
3
  // what is true of the app that was just written:
4
4
  //
5
- // --example the seeded `post` slice: a stat row, a bar chart of posts per day, the latest
6
- // posts in a table. Real rows from the app's own repo, aggregated by a pure view
7
- // module this file also emits.
5
+ // --example the seeded `post` slice: a stat row, an area chart of posts per day, a ring of
6
+ // free against paid, a dial of this week's share, the latest posts in a table.
7
+ // Real rows from the app's own repo, aggregated by a pure view module this file
8
+ // also emits.
8
9
  // --no-example no entity, so no rows to count and nothing honest to chart. Framework facts
9
10
  // instead — routes, locales, roles, version — and the route table. A chart of
10
11
  // invented numbers would teach the wrong thing to every app that starts here.
11
12
  //
12
- // One island on the page, the theme toggle (`scaffold-shell.ts`); the tiles, chart and table are
13
+ // One island on the page, the theme toggle (`scaffold-shell.ts`); the tiles, charts and table are
13
14
  // server markup, which is what keeps the route inside its 60kb budget.
14
15
 
15
16
  import type { GeneratedFile, NameSet } from './naming';
16
17
  import { bareDashboardFiles } from './scaffold-dashboard-bare';
17
18
  import { exampleDashboardFiles } from './scaffold-dashboard-example';
18
- import { DASHBOARD_DIR, dashboardStyle } from './scaffold-dashboard-shared';
19
+ import { DASHBOARD_DIR, dashboardStyle, themeFile, themeTest } from './scaffold-dashboard-shared';
19
20
 
20
21
  /** `apps/web/app/dashboard/`, in the shape the invocation earns. */
21
22
  export function dashboardFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
22
23
  return [
23
24
  ...(example ? exampleDashboardFiles(app) : bareDashboardFiles(app)),
24
25
  { path: `${DASHBOARD_DIR}/page.module.scss`, contents: dashboardStyle() },
26
+ // Both shapes: the look is the app's to choose whether or not it has an entity yet.
27
+ { path: 'apps/web/shared/theme.ts', contents: themeFile() },
28
+ { path: 'apps/web/shared/theme.test.ts', contents: themeTest() },
25
29
  ];
26
30
  }
@@ -74,7 +74,7 @@ export const dbClientSource = (
74
74
  // same two lines: its import, and \`<tableName>: <entity>,\` in the set.
75
75
 
76
76
  ${sortedImports([
77
- "import { resolveEnvironment } from '@ultimat3/core';",
77
+ "import { storeMode } from '@ultimat3/core';",
78
78
  "import { type Driver, database, memoryDriver, postgresDriver } from '@ultimat3/entity';",
79
79
  ...entries.map(handleImportLine),
80
80
  ])}
@@ -86,9 +86,11 @@ ${sortedImports([
86
86
  * \`postgresDriver()\` takes no connection: it resolves \`@ultimat3/db\`'s process client, which is
87
87
  * the embedded PGlite under \`x dev\` and the \`DATABASE_URL\` pool in a container. \`bun test\`
88
88
  * installs no client, so a test gets the in-memory driver — the same contract, no database.
89
+ * \`storeMode\` is core's one answer to that question, shared with every other seam that has a
90
+ * memory and a database store.
89
91
  */
90
92
  export const selectDriver = (env: Readonly<Record<string, string | undefined>>): Driver =>
91
- resolveEnvironment({ env }) === 'test' ? memoryDriver() : postgresDriver();
93
+ storeMode(env) === 'memory' ? memoryDriver() : postgresDriver();
92
94
 
93
95
  export const driver = selectDriver(Bun.env);
94
96
 
@@ -165,7 +165,7 @@ const dbSeedTest = (app: NameSet, example: boolean): string =>
165
165
  // development viewer's tenant policy refuses, and the dashboard then counts zero.
166
166
  ${sortedImports([
167
167
  `import { DEMO_ORG_ID } from '@${app.kebab}/web/shared/demo-org';`,
168
- "import { createContext, runWithContext } from '@ultimat3/core';",
168
+ "import { ctxOf, runWithContext } from '@ultimat3/core';",
169
169
  "import { testActor } from '@ultimat3/policy';",
170
170
  "import { afterEach, expect, unitTest } from '@ultimat3/testing';",
171
171
  ])}
@@ -174,7 +174,7 @@ import { ${app.camel}Seed } from './seed';
174
174
 
175
175
  /** The development viewer's org: every read below runs as an actor inside it. */
176
176
  const asViewer = <T>(run: () => Promise<T>): Promise<T> =>
177
- runWithContext(createContext({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
177
+ runWithContext(ctxOf({ actor: testActor('viewer', { orgId: DEMO_ORG_ID }).actor }), run);
178
178
 
179
179
  // One store per process: without this, one test's rows are the next test's fixtures.
180
180
  afterEach(() => {