@ultimat3/cli 20.1.6 → 20.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ // The `--no-example` dashboard: framework facts and the route table, and no chart — there is no
2
+ // series to draw until the first `x g resource`, and a chart of invented numbers would be the first
3
+ // lie in the app. The route declaration and the island come from `scaffold-dashboard-shared.ts`.
4
+
5
+ import { sortedImports } from './imports';
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+ import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffold-dashboard-shared';
8
+
9
+ // Plain strings for the framework lines, never template literals: the workspace-dependency scanner
10
+ // blanks a string's contents but not a nested template's, so a template here would bill the CLI
11
+ // for the imports of the app it writes.
12
+ const barePage = (
13
+ app: NameSet,
14
+ ): string => `// The authed dashboard of an app with no entity yet. Nothing here is invented: the tiles are the
15
+ // framework's own registries — routes, locales, roles, version — and the table is the route table
16
+ // \`x routes\` prints. NO CHART, deliberately: there is no series to draw until the first
17
+ // \`x g resource\`, and a chart of made-up numbers would be the first lie in the app.
18
+ //
19
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
20
+ ${sortedImports([
21
+ `import { catalogs, useT } from '@${app.kebab}/i18n';`,
22
+ "import { frameworkVersion } from '@ultimat3/core';",
23
+ "import { currentLocale } from '@ultimat3/i18n';",
24
+ "import { defineRoute, island, routeEntries } from '@ultimat3/render';",
25
+ "import { DataTable, Grid, PageHeader, Section, StatTile, ThemeToggle } from '@ultimat3/ui';",
26
+ ])}
27
+ import { roles } from '../../shared/roles';
28
+ import { Shell } from '../../shared/shell';
29
+ import { factsOf, formatCount, type RouteRow, routeRows } from './dashboard-view';
30
+ import styles from './page.module.scss';
31
+
32
+ ${themeIsland}
33
+
34
+ ${routeConfig('')}
35
+
36
+ export function DashboardPage() {
37
+ const t = useT();
38
+ const locale = currentLocale();
39
+ // Read at render, not at import: the registries are filled by the boot scan, after this module.
40
+ const entries = routeEntries();
41
+ const facts = factsOf({
42
+ routes: entries.length,
43
+ locales: catalogs.locales.length,
44
+ roles: Object.keys(roles).length,
45
+ version: frameworkVersion(),
46
+ });
47
+ const rows = routeRows(entries);
48
+
49
+ return (
50
+ <Shell
51
+ nav="dashboard"
52
+ actions={
53
+ ${themeActions}
54
+ }
55
+ >
56
+ <div class={styles.page}>
57
+ <PageHeader title={t('app.dashboard.title')} description={t('app.dashboard.subtitle')} />
58
+ {/* 12rem, not the catalog's 16rem default: four tiles pair up two-by-two on a tablet's main
59
+ column and sit in one row on a desktop, never three and an orphan. */}
60
+ <Grid minColumn="12rem">
61
+ <StatTile
62
+ stat="routes"
63
+ label={t('app.dashboard.statRoutes')}
64
+ value={formatCount(facts.routes, locale)}
65
+ hint={t('app.dashboard.hintRoutes')}
66
+ />
67
+ <StatTile
68
+ stat="locales"
69
+ label={t('app.dashboard.statLocales')}
70
+ value={formatCount(facts.locales, locale)}
71
+ hint={t('app.dashboard.hintLocales')}
72
+ />
73
+ <StatTile
74
+ stat="roles"
75
+ label={t('app.dashboard.statRoles')}
76
+ value={formatCount(facts.roles, locale)}
77
+ hint={t('app.dashboard.hintRoles')}
78
+ />
79
+ <StatTile
80
+ stat="version"
81
+ label={t('app.dashboard.statVersion')}
82
+ value={facts.version}
83
+ hint={t('app.dashboard.hintVersion')}
84
+ />
85
+ </Grid>
86
+ <Section title={t('app.dashboard.routesTitle')}>
87
+ <DataTable
88
+ caption={t('app.dashboard.routesCaption')}
89
+ rowKey={(row: RouteRow) => row.path}
90
+ rows={rows}
91
+ columns={[
92
+ { key: 'path', header: t('app.dashboard.columnPath'), cell: (row) => row.path },
93
+ {
94
+ key: 'surface',
95
+ header: t('app.dashboard.columnSurface'),
96
+ cell: (row) => row.surface,
97
+ },
98
+ { key: 'render', header: t('app.dashboard.columnRender'), cell: (row) => row.render },
99
+ ]}
100
+ />
101
+ </Section>
102
+ </div>
103
+ </Shell>
104
+ );
105
+ }
106
+ `;
107
+
108
+ const bareView =
109
+ (): string => `// The dashboard's facts, apart from its markup: pure projections of what the framework registered,
110
+ // so the page and a test read the same shape. No I/O, no \`t()\`, no JSX.
111
+
112
+ /** The three columns of the route table, as \`x routes\` prints them. */
113
+ export interface RouteRow {
114
+ readonly path: string;
115
+ readonly surface: string;
116
+ readonly render: string;
117
+ }
118
+
119
+ /** What \`routeEntries()\` hands over, narrowed to the fields the table reads. */
120
+ export interface RouteLike {
121
+ readonly path: string;
122
+ readonly surface: string;
123
+ readonly config: { readonly render: string };
124
+ }
125
+
126
+ /** Sorted by path in code-unit order — the order \`x routes\` and the manifest both use. */
127
+ export const routeRows = (entries: readonly RouteLike[]): readonly RouteRow[] =>
128
+ entries
129
+ .map((entry) => ({ path: entry.path, surface: entry.surface, render: entry.config.render }))
130
+ .toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
131
+
132
+ export interface FrameworkFacts {
133
+ readonly routes: number;
134
+ readonly locales: number;
135
+ readonly roles: number;
136
+ readonly version: string;
137
+ }
138
+
139
+ /** Counts are never negative, and a blank version is reported as unknown rather than as nothing. */
140
+ export const factsOf = (input: FrameworkFacts): FrameworkFacts => ({
141
+ routes: Math.max(0, input.routes),
142
+ locales: Math.max(0, input.locales),
143
+ roles: Math.max(0, input.roles),
144
+ version: input.version === '' ? 'unknown' : input.version,
145
+ });
146
+
147
+ /** A figure for a tile, in the page's locale. \`Intl.NumberFormat\` needs no time zone. */
148
+ export const formatCount = (value: number, locale: string): string =>
149
+ new Intl.NumberFormat(locale).format(value);
150
+ `;
151
+
152
+ const bareViewTest = (): string => `// The route table and the fact tiles, pinned on fixed input.
153
+ import { expect, unitTest } from '@ultimat3/testing';
154
+ import { factsOf, formatCount, routeRows } from './dashboard-view';
155
+
156
+ unitTest('routeRows projects the three columns and sorts by path', () => {
157
+ const rows = routeRows([
158
+ { path: '/dashboard', surface: 'app', config: { render: 'ssr' } },
159
+ { path: '/', surface: 'site', config: { render: 'static' } },
160
+ ]);
161
+ expect(rows.map((row) => row.path)).toEqual(['/', '/dashboard']);
162
+ expect(rows[1]).toEqual({ path: '/dashboard', surface: 'app', render: 'ssr' });
163
+ });
164
+
165
+ unitTest('factsOf clamps counts and names an unknown version', () => {
166
+ const facts = factsOf({ routes: -1, locales: 1, roles: 2, version: '' });
167
+ expect(facts).toEqual({ routes: 0, locales: 1, roles: 2, version: 'unknown' });
168
+ expect(factsOf({ routes: 3, locales: 1, roles: 2, version: '1.2.3' }).version).toBe('1.2.3');
169
+ });
170
+
171
+ unitTest('formatCount follows the locale', () => {
172
+ expect(formatCount(1234, 'en')).toBe('1,234');
173
+ expect(formatCount(1234, 'de')).toBe('1.234');
174
+ });
175
+ `;
176
+
177
+ /** The bare dashboard's page, view module and view test. */
178
+ export const bareDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
179
+ { path: `${DASHBOARD_DIR}/page.tsx`, contents: barePage(app) },
180
+ { path: `${DASHBOARD_DIR}/dashboard-view.ts`, contents: bareView() },
181
+ { path: `${DASHBOARD_DIR}/dashboard-view.test.ts`, contents: bareViewTest() },
182
+ ];
@@ -0,0 +1,326 @@
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`.
4
+
5
+ import { sortedImports } from './imports';
6
+ import type { GeneratedFile, NameSet } from './naming';
7
+ import { DASHBOARD_DIR, routeConfig, themeActions, themeIsland } from './scaffold-dashboard-shared';
8
+
9
+ // Plain strings for the framework lines, never template literals: the workspace-dependency scanner
10
+ // blanks a string's contents but not a nested template's, so a template here would bill the CLI
11
+ // for the imports of the app it writes.
12
+ const examplePage = (
13
+ app: NameSet,
14
+ ): 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
+ //
18
+ // \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
19
+ ${sortedImports([
20
+ `import { useT } from '@${app.kebab}/i18n';`,
21
+ "import { isUltimateError } from '@ultimat3/core';",
22
+ "import { seedId } from '@ultimat3/entity';",
23
+ "import { currentLocale } from '@ultimat3/i18n';",
24
+ "import { defineRoute, island } from '@ultimat3/render';",
25
+ [
26
+ 'import {',
27
+ ' BarChart,',
28
+ ' DataTable,',
29
+ ' Grid,',
30
+ ' PageHeader,',
31
+ ' RelativeTime,',
32
+ ' Section,',
33
+ ' StatTile,',
34
+ ' ThemeToggle,',
35
+ "} from '@ultimat3/ui';",
36
+ ].join('\n'),
37
+ ])}
38
+ import { Shell } from '../../shared/shell';
39
+ import * as repo from '../post/repo';
40
+ import {
41
+ bucketByDay,
42
+ CHART_DAYS,
43
+ formatCount,
44
+ type PostRow,
45
+ postStats,
46
+ toPostRow,
47
+ } from './dashboard-view';
48
+ import styles from './page.module.scss';
49
+
50
+ /**
51
+ * Whose posts. The route's policy decides who may open this page; which org's rows it aggregates
52
+ * is a decision the load makes, and until this app issues sessions the only org with rows is the
53
+ * one \`packages/db/src/seed.ts\` writes. The day sessions exist, this becomes the actor's org and
54
+ * the read becomes \`postList.as(actor, …)\` — the query already declares the tenancy rule.
55
+ */
56
+ const DEMO_ORG = seedId('org:demo');
57
+
58
+ /** The stat row counts what it can see. Past this many posts, write an aggregate query. */
59
+ const ROW_LIMIT = 500;
60
+
61
+ /** How many of the newest posts the table shows. */
62
+ const TABLE_ROWS = 10;
63
+
64
+ export interface DashboardData {
65
+ /** Newest first, as the repo orders them. */
66
+ readonly rows: readonly PostRow[];
67
+ /** The instant the windows were cut at, so server and test agree on "the last 7 days". */
68
+ readonly now: string;
69
+ }
70
+
71
+ async function load(): Promise<DashboardData> {
72
+ const now = new Date().toISOString();
73
+ try {
74
+ const rows = await repo.listByOrg(DEMO_ORG, ROW_LIMIT);
75
+ return { rows: rows.map(toPostRow), now };
76
+ } catch (error) {
77
+ // \`x build --target static\` renders every route once to measure its JS budget, with no
78
+ // database wired — so this is that measurement pass, not a request. Empty rows render the
79
+ // real empty branches, which measure the same markup. Any other failure still propagates.
80
+ if (isUltimateError(error) && error.code === 'X_DB_UNAVAILABLE') return { rows: [], now };
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ ${themeIsland}
86
+
87
+ ${routeConfig('\n load,')}
88
+
89
+ export interface DashboardPageProps {
90
+ readonly data: DashboardData;
91
+ }
92
+
93
+ export function DashboardPage(props: DashboardPageProps) {
94
+ const t = useT();
95
+ const locale = currentLocale();
96
+ const now = new Date(props.data.now);
97
+ const stats = postStats(props.data.rows, now);
98
+ const points = bucketByDay(props.data.rows, now, CHART_DAYS);
99
+ const latest = props.data.rows.slice(0, TABLE_ROWS);
100
+
101
+ return (
102
+ <Shell
103
+ nav="dashboard"
104
+ actions={
105
+ ${themeActions}
106
+ }
107
+ >
108
+ <div class={styles.page}>
109
+ <PageHeader title={t('app.dashboard.title')} description={t('app.dashboard.subtitle')} />
110
+ {/* 10rem, not the catalog's 16rem default: three tiles fit across a tablet's main column
111
+ instead of leaving one alone on a second row. */}
112
+ <Grid minColumn="10rem">
113
+ <StatTile
114
+ stat="posts-total"
115
+ label={t('app.dashboard.statTotal')}
116
+ value={formatCount(stats.total, locale)}
117
+ hint={t('app.dashboard.hintTotal')}
118
+ />
119
+ <StatTile
120
+ stat="posts-week"
121
+ label={t('app.dashboard.statWeek')}
122
+ value={formatCount(stats.lastWeek, locale)}
123
+ delta={stats.delta}
124
+ hint={t('app.dashboard.hintVsPriorWeek')}
125
+ />
126
+ <StatTile
127
+ stat="posts-today"
128
+ label={t('app.dashboard.statToday')}
129
+ value={formatCount(stats.today, locale)}
130
+ hint={t('app.dashboard.hintToday')}
131
+ />
132
+ </Grid>
133
+ <Section title={t('app.dashboard.chartTitle')} description={t('app.dashboard.chartRange')}>
134
+ <BarChart label={t('app.dashboard.chartLabel')} points={points} />
135
+ </Section>
136
+ <Section title={t('app.dashboard.tableTitle')}>
137
+ <DataTable
138
+ caption={t('app.dashboard.tableCaption')}
139
+ rowKey={(row: PostRow) => row.id}
140
+ rows={latest}
141
+ emptyTitle={t('app.dashboard.emptyTitle')}
142
+ columns={[
143
+ { key: 'title', header: t('app.dashboard.columnTitle'), cell: (row) => row.title },
144
+ {
145
+ key: 'createdAt',
146
+ header: t('app.dashboard.columnCreated'),
147
+ cell: (row) => (
148
+ <RelativeTime value={row.createdAt} now={props.data.now} locale={locale} />
149
+ ),
150
+ },
151
+ ]}
152
+ />
153
+ </Section>
154
+ </div>
155
+ </Shell>
156
+ );
157
+ }
158
+ `;
159
+
160
+ const exampleView =
161
+ (): string => `// The dashboard's numbers, apart from its markup: pure functions over the rows the page loaded,
162
+ // so the server render and a test cannot disagree about a figure. No I/O, no \`t()\`, no JSX.
163
+
164
+ import { type ChartPoint, deltaOf, type StatDelta } from '@ultimat3/ui';
165
+
166
+ /** Days of history the chart shows, oldest first. */
167
+ export const CHART_DAYS = 14;
168
+
169
+ const DAY_MS = 86_400_000;
170
+
171
+ /** One post as the page reads it. \`createdAt\` is ISO text: a \`Date\` cannot cross a JSON seam. */
172
+ export interface PostRow {
173
+ readonly id: string;
174
+ readonly title: string;
175
+ readonly createdAt: string;
176
+ }
177
+
178
+ /**
179
+ * Duck-typed rather than the entity's own row: the page only ever reads these three columns.
180
+ *
181
+ * BOTH spellings of the timestamp, and that is a measured fact rather than caution: the repo's
182
+ * \`select *\` hands back column names as Postgres has them (\`created_at\`) while its row type
183
+ * says \`createdAt\`, so the typed property is \`undefined\` on a real row. Until the repo aliases
184
+ * its columns — dz-showcase's does — the page reads whichever one arrived.
185
+ */
186
+ export function toPostRow(row: {
187
+ readonly id: string;
188
+ readonly title: string;
189
+ readonly createdAt?: Date | string | undefined;
190
+ readonly created_at?: Date | string | undefined;
191
+ }): PostRow {
192
+ const at = row.createdAt ?? row.created_at ?? '';
193
+ return {
194
+ id: row.id,
195
+ title: row.title,
196
+ createdAt: at instanceof Date ? at.toISOString() : at,
197
+ };
198
+ }
199
+
200
+ export interface PostStats {
201
+ readonly total: number;
202
+ /** Created in the seven days ending at \`now\`. */
203
+ readonly lastWeek: number;
204
+ /** \`lastWeek\` against the seven days before it; absent when that baseline is zero. */
205
+ readonly delta: StatDelta | undefined;
206
+ /** Created in the UTC day \`now\` falls in — a partial day, so it carries no delta. */
207
+ readonly today: number;
208
+ }
209
+
210
+ const createdAtOf = (row: PostRow): number => Date.parse(row.createdAt);
211
+
212
+ const within = (rows: readonly PostRow[], from: number, to: number): number =>
213
+ rows.filter((row) => {
214
+ const at = createdAtOf(row);
215
+ return Number.isFinite(at) && at > from && at <= to;
216
+ }).length;
217
+
218
+ export function postStats(rows: readonly PostRow[], now: Date): PostStats {
219
+ const end = now.getTime();
220
+ const weekAgo = end - 7 * DAY_MS;
221
+ const dayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
222
+ const lastWeek = within(rows, weekAgo, end);
223
+ return {
224
+ total: rows.length,
225
+ lastWeek,
226
+ delta: deltaOf(lastWeek, within(rows, weekAgo - 7 * DAY_MS, weekAgo)),
227
+ today: within(rows, dayStart - 1, end),
228
+ };
229
+ }
230
+
231
+ /** \`MM-DD\` of a UTC day. ISO, never \`toLocaleDateString\`: the chart's axis is data, not prose. */
232
+ const dayKey = (at: number): string => new Date(at).toISOString().slice(5, 10);
233
+
234
+ /**
235
+ * One bucket per UTC day, the last one being the day \`now\` falls in, oldest first — the shape
236
+ * \`BarChart\` draws. Every day is present even when nothing was created on it: a chart with the
237
+ * quiet days removed reads as busier than the app is.
238
+ */
239
+ export function bucketByDay(
240
+ rows: readonly PostRow[],
241
+ now: Date,
242
+ days: number,
243
+ ): readonly ChartPoint[] {
244
+ const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
245
+ const start = today - (days - 1) * DAY_MS;
246
+ const counts: number[] = Array.from({ length: days }, () => 0);
247
+ for (const row of rows) {
248
+ const at = createdAtOf(row);
249
+ if (!Number.isFinite(at)) continue;
250
+ const index = Math.floor((at - start) / DAY_MS);
251
+ if (index >= 0 && index < days) counts[index] = (counts[index] ?? 0) + 1;
252
+ }
253
+ return counts.map((value, index) => ({ key: dayKey(start + index * DAY_MS), value }));
254
+ }
255
+
256
+ /** A figure for a tile, in the page's locale. \`Intl.NumberFormat\` needs no time zone. */
257
+ export const formatCount = (value: number, locale: string): string =>
258
+ new Intl.NumberFormat(locale).format(value);
259
+ `;
260
+
261
+ const exampleViewTest =
262
+ (): string => `// The numbers the dashboard shows, pinned against a fixed clock. The interesting cases are the
263
+ // window edges: a post from eight days ago is in the prior week, not this one, and a day with no
264
+ // posts is still a bar.
265
+ import { expect, unitTest } from '@ultimat3/testing';
266
+ import { bucketByDay, formatCount, type PostRow, postStats, toPostRow } from './dashboard-view';
267
+
268
+ const NOW = new Date('2026-03-15T12:00:00.000Z');
269
+ const DAY_MS = 86_400_000;
270
+
271
+ const row = (id: string, daysAgo: number): PostRow => ({
272
+ id,
273
+ title: \`post \${id}\`,
274
+ createdAt: new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString(),
275
+ });
276
+
277
+ const rows = [row('a', 1), row('b', 3), row('c', 8)];
278
+
279
+ unitTest('postStats counts the week, compares it with the prior one, and counts today', () => {
280
+ const stats = postStats(rows, NOW);
281
+ expect(stats.total).toBe(3);
282
+ expect(stats.lastWeek).toBe(2);
283
+ // Two this week against one the week before: +100%, and the chip says so.
284
+ expect(stats.delta).toEqual({ text: '+100%', trend: 'up' });
285
+ // 'a' is yesterday and nothing was created on 03-15 itself.
286
+ expect(stats.today).toBe(0);
287
+ expect(postStats([...rows, row('d', 0.25)], NOW).today).toBe(1);
288
+ });
289
+
290
+ unitTest('a zero baseline yields no delta, and no rows count nothing', () => {
291
+ expect(postStats([row('a', 1)], NOW).delta).toBeUndefined();
292
+ expect(postStats([], NOW)).toEqual({ total: 0, lastWeek: 0, delta: undefined, today: 0 });
293
+ });
294
+
295
+ unitTest('bucketByDay is one bar per day, oldest first, quiet days included', () => {
296
+ const points = bucketByDay(rows, NOW, 14);
297
+ expect(points).toHaveLength(14);
298
+ expect(points.at(-1)?.key).toBe('03-15');
299
+ expect(points[0]?.key).toBe('03-02');
300
+ expect(points.reduce((sum, point) => sum + point.value, 0)).toBe(3);
301
+ // Eight days ago is 03-07: inside a 14-day window, so the bar is there.
302
+ expect(points.find((point) => point.key === '03-07')?.value).toBe(1);
303
+ expect(bucketByDay([], NOW, 3).map((point) => point.value)).toEqual([0, 0, 0]);
304
+ });
305
+
306
+ unitTest('toPostRow serialises a Date, passes ISO text through, and reads the raw column', () => {
307
+ const at = new Date('2026-01-02T03:04:05.000Z');
308
+ expect(toPostRow({ id: 'x', title: 't', createdAt: at }).createdAt).toBe(at.toISOString());
309
+ expect(toPostRow({ id: 'x', title: 't', createdAt: '2026-01-01' }).createdAt).toBe('2026-01-01');
310
+ // What \`select *\` really returns: the snake_case column, and no \`createdAt\` at all.
311
+ expect(toPostRow({ id: 'x', title: 't', created_at: at }).createdAt).toBe(at.toISOString());
312
+ expect(toPostRow({ id: 'x', title: 't' }).createdAt).toBe('');
313
+ });
314
+
315
+ unitTest('formatCount follows the locale', () => {
316
+ expect(formatCount(1234, 'en')).toBe('1,234');
317
+ expect(formatCount(1234, 'de')).toBe('1.234');
318
+ });
319
+ `;
320
+
321
+ /** The example dashboard's page, view module and view test. */
322
+ export const exampleDashboardFiles = (app: NameSet): readonly GeneratedFile[] => [
323
+ { path: `${DASHBOARD_DIR}/page.tsx`, contents: examplePage(app) },
324
+ { path: `${DASHBOARD_DIR}/dashboard-view.ts`, contents: exampleView() },
325
+ { path: `${DASHBOARD_DIR}/dashboard-view.test.ts`, contents: exampleViewTest() },
326
+ ];
@@ -0,0 +1,77 @@
1
+ // What both dashboards share — `scaffold-dashboard.ts` picks one of `scaffold-dashboard-example.ts`
2
+ // and `scaffold-dashboard-bare.ts` per invocation, and everything the two must agree on lives here:
3
+ // the route declaration, the theme island, the stylesheet and the config test.
4
+
5
+ export const DASHBOARD_DIR = 'apps/web/app/dashboard';
6
+
7
+ export const routeConfig = (load: string): string => `export const config = defineRoute({
8
+ // 'ssr', not 'stream', and this is not a downgrade: 'stream' needs a boundary to stream into,
9
+ // and the framework has no hole marker yet. Solid's <Suspense> is not it — it throws outside a
10
+ // Solid renderer, and the server JSX factory is inert on purpose. A scaffolded 'stream' route
11
+ // therefore failed x routes with X_ROUTE_MODE_INVALID on the first run, printing a fix nobody
12
+ // could follow. Ship the mode that works. Async data needs no boundary: await it in the page.
13
+ render: 'ssr',
14
+ // 'visible' for the one island on this page, the theme toggle in the header.
15
+ hydrate: 'visible',
16
+ offline: 'runtime',
17
+ // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
18
+ policy: { permission: 'dashboard:read' },
19
+ // Only the toggle island hydrates; the tiles, the chart and the table are server markup. The
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}
27
+ meta: ({ t }) => ({
28
+ title: t('app.dashboard.title'),
29
+ description: t('app.dashboard.description'),
30
+ }),
31
+ });`;
32
+
33
+ /** Declared ABOVE `defineRoute`: the route drains the island declarations made before it. */
34
+ export const themeIsland = `// Named by SPECIFIER, never by import (axiom 6): a string has no import edge, so the page's
35
+ // bundle graph stays the page's. \`props\` is the exact contract of \`ThemeToggleIslandProps\`.
36
+ const ThemeSwitch = island({ src: '../../shared/theme-toggle.island.tsx', props: ['locale'] });`;
37
+
38
+ /**
39
+ * The pre-hydration shell: the catalog's toggle renders from server markup alone, so the served
40
+ * document already shows the control the island takes over. `initial="dark"` is this app's
41
+ * `theme.defaultMode`, stated once more here because the server has no browser to ask.
42
+ */
43
+ export const themeActions = `<ThemeSwitch locale={locale}>
44
+ <ThemeToggle mode="toggle" initial="dark" />
45
+ </ThemeSwitch>`;
46
+
47
+ export const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
48
+
49
+ .page {
50
+ display: flex;
51
+ flex-direction: column;
52
+ gap: tokens.space(6);
53
+ // A measure, not the full monitor: past ~80rem a table row is too long to read across.
54
+ max-inline-size: 80rem;
55
+ margin-inline: auto;
56
+ color: tokens.role('fg');
57
+ }
58
+ `;
59
+
60
+ export const dashboardPageTest =
61
+ (): string => `// The dashboard renders per request, is gated by a policy, hydrates its one island, and stays
62
+ // under its budget. Losing the policy is the interesting regression: the page still renders, to
63
+ // anyone.
64
+ import { expect, unitTest } from '@ultimat3/testing';
65
+ import { config } from './page';
66
+
67
+ unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
68
+ expect(config.render).toBe('ssr');
69
+ expect(config.policy?.permission).toBe('dashboard:read');
70
+ expect(config.offline).toBe('runtime');
71
+ });
72
+
73
+ unitTest('the dashboard hydrates its one island inside a stated budget', () => {
74
+ expect(config.hydrate).toBe('visible');
75
+ expect(config.budget.js).toBe('60kb');
76
+ });
77
+ `;
@@ -0,0 +1,27 @@
1
+ // The generated dashboard — `apps/web/app/dashboard/` — moved out of `scaffold-app.ts` because it
2
+ // stopped being an `<h1>` in a panel. Two shapes, one per `x new` invocation, and both show only
3
+ // what is true of the app that was just written:
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.
8
+ // --no-example no entity, so no rows to count and nothing honest to chart. Framework facts
9
+ // instead — routes, locales, roles, version — and the route table. A chart of
10
+ // invented numbers would teach the wrong thing to every app that starts here.
11
+ //
12
+ // One island on the page, the theme toggle (`scaffold-shell.ts`); the tiles, chart and table are
13
+ // server markup, which is what keeps the route inside its 60kb budget.
14
+
15
+ import type { GeneratedFile, NameSet } from './naming';
16
+ import { bareDashboardFiles } from './scaffold-dashboard-bare';
17
+ import { exampleDashboardFiles } from './scaffold-dashboard-example';
18
+ import { DASHBOARD_DIR, dashboardPageTest, dashboardStyle } from './scaffold-dashboard-shared';
19
+
20
+ /** `apps/web/app/dashboard/`, in the shape the invocation earns. */
21
+ export function dashboardFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
22
+ return [
23
+ ...(example ? exampleDashboardFiles(app) : bareDashboardFiles(app)),
24
+ { path: `${DASHBOARD_DIR}/page.module.scss`, contents: dashboardStyle() },
25
+ { path: `${DASHBOARD_DIR}/page.test.ts`, contents: dashboardPageTest() },
26
+ ];
27
+ }