@ultimat3/cli 20.1.5 → 20.2.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.
- package/package.json +29 -29
- package/src/budgets.ts +17 -2
- package/src/cmd-dev.ts +14 -15
- package/src/cmd-mcp.ts +2 -2
- package/src/cmd-shot.ts +19 -5
- package/src/dev-render.ts +9 -1
- package/src/dev-roles.ts +6 -6
- package/src/error-codes.ts +20 -2
- package/src/error-page-csp.ts +50 -0
- package/src/mcp-errors.ts +20 -1
- package/src/mcp-host.ts +7 -0
- package/src/mcp-ui-diff.ts +141 -0
- package/src/mcp-ui-inspect.ts +183 -0
- package/src/mcp-ui-interact.ts +304 -0
- package/src/mcp-ui.ts +53 -15
- package/src/output.ts +1 -1
- package/src/prerender.ts +3 -0
- package/src/scaffold-typecheck.ts +5 -0
- package/src/script-csp.ts +5 -2
- package/src/serve.ts +8 -0
- package/src/templates/index.ts +5 -1
- package/src/templates/scaffold-app.ts +14 -155
- package/src/templates/scaffold-dashboard-bare.ts +182 -0
- package/src/templates/scaffold-dashboard-example.ts +326 -0
- package/src/templates/scaffold-dashboard-shared.ts +74 -0
- package/src/templates/scaffold-dashboard.ts +27 -0
- package/src/templates/scaffold-errors.ts +128 -0
- package/src/templates/scaffold-i18n.ts +46 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +461 -0
- package/src/templates/scaffold-site.ts +287 -0
- package/src/theme-boot.ts +59 -0
- package/src/ui-diff.ts +90 -0
- package/src/ui-inspect-probe.ts +131 -0
|
@@ -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,74 @@
|
|
|
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 60.9kb minified under Bun 1.4.2 — solid-js is 12.6kb of it, the catalog's
|
|
21
|
+
// toggle, provider and the ui runtime they share are the rest — so 64kb is that figure plus
|
|
22
|
+
// the few hundred bytes the minifier moves between Bun patch versions, not headroom to spend.
|
|
23
|
+
budget: { js: '64kb' },${load}
|
|
24
|
+
meta: ({ t }) => ({
|
|
25
|
+
title: t('app.dashboard.title'),
|
|
26
|
+
description: t('app.dashboard.description'),
|
|
27
|
+
}),
|
|
28
|
+
});`;
|
|
29
|
+
|
|
30
|
+
/** Declared ABOVE `defineRoute`: the route drains the island declarations made before it. */
|
|
31
|
+
export const themeIsland = `// Named by SPECIFIER, never by import (axiom 6): a string has no import edge, so the page's
|
|
32
|
+
// bundle graph stays the page's. \`props\` is the exact contract of \`ThemeToggleIslandProps\`.
|
|
33
|
+
const ThemeSwitch = island({ src: '../../shared/theme-toggle.island.tsx', props: ['locale'] });`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The pre-hydration shell: the catalog's toggle renders from server markup alone, so the served
|
|
37
|
+
* document already shows the control the island takes over. `initial="dark"` is this app's
|
|
38
|
+
* `theme.defaultMode`, stated once more here because the server has no browser to ask.
|
|
39
|
+
*/
|
|
40
|
+
export const themeActions = `<ThemeSwitch locale={locale}>
|
|
41
|
+
<ThemeToggle mode="toggle" initial="dark" />
|
|
42
|
+
</ThemeSwitch>`;
|
|
43
|
+
|
|
44
|
+
export const dashboardStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
45
|
+
|
|
46
|
+
.page {
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
gap: tokens.space(6);
|
|
50
|
+
// A measure, not the full monitor: past ~80rem a table row is too long to read across.
|
|
51
|
+
max-inline-size: 80rem;
|
|
52
|
+
margin-inline: auto;
|
|
53
|
+
color: tokens.role('fg');
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
export const dashboardPageTest =
|
|
58
|
+
(): string => `// The dashboard renders per request, is gated by a policy, hydrates its one island, and stays
|
|
59
|
+
// under its budget. Losing the policy is the interesting regression: the page still renders, to
|
|
60
|
+
// anyone.
|
|
61
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
62
|
+
import { config } from './page';
|
|
63
|
+
|
|
64
|
+
unitTest('the dashboard renders on the server, is gated, and has an offline strategy', () => {
|
|
65
|
+
expect(config.render).toBe('ssr');
|
|
66
|
+
expect(config.policy?.permission).toBe('dashboard:read');
|
|
67
|
+
expect(config.offline).toBe('runtime');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
unitTest('the dashboard hydrates its one island inside a stated budget', () => {
|
|
71
|
+
expect(config.hydrate).toBe('visible');
|
|
72
|
+
expect(config.budget.js).toBe('64kb');
|
|
73
|
+
});
|
|
74
|
+
`;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The two error pages `x new` writes — `apps/web/site/errors/404.html` and `500.html`, the files
|
|
2
|
+
// `packages/cli/src/error-pages.ts` serves verbatim for those statuses and the static export
|
|
3
|
+
// carries as `404.html`. Self-contained documents by construction: a 500 is answered when the app's
|
|
4
|
+
// own stylesheet may be the thing that failed, so nothing here links a bundle, loads a font or runs
|
|
5
|
+
// a script. The `<style>` body is hashed into `style-src` at boot (`error-page-csp.ts`), which is
|
|
6
|
+
// what lets the enforced policy a container sends admit it.
|
|
7
|
+
|
|
8
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
9
|
+
import { titleCase } from './naming';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The same two hex values `app.config.ts`'s `pwa.colors` declares — one source, read by both
|
|
13
|
+
* templates, so the splash a browser paints before any stylesheet and the page it shows when the
|
|
14
|
+
* stylesheet never arrives agree. Raw hex is legal in these two places and nowhere else in an app:
|
|
15
|
+
* the raw-colour guard scans `.scss` (a page with no stylesheet has no token to read), and an
|
|
16
|
+
* install prompt has no stylesheet either.
|
|
17
|
+
*/
|
|
18
|
+
export const PWA_COLORS = {
|
|
19
|
+
themeColor: '#1b1f3b',
|
|
20
|
+
lightBackground: '#ffffff',
|
|
21
|
+
darkBackground: '#0b0d1a',
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/** One page per status, English only: the file is served as bytes, so no translator runs. */
|
|
25
|
+
interface ErrorCopy {
|
|
26
|
+
readonly status: number;
|
|
27
|
+
readonly title: string;
|
|
28
|
+
readonly body: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COPY: readonly ErrorCopy[] = [
|
|
32
|
+
{
|
|
33
|
+
status: 404,
|
|
34
|
+
title: 'Page not found',
|
|
35
|
+
body: 'There is nothing at this address. The link may be old, or the page may have moved.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
status: 500,
|
|
39
|
+
title: 'Something went wrong',
|
|
40
|
+
body: 'The server could not finish this request. It has been recorded; try again in a moment.',
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `prefers-color-scheme` rather than `data-theme`: the theme boot script is inlined by the
|
|
46
|
+
* framework into documents it RENDERS, and this file is served as-is, so the OS preference is the
|
|
47
|
+
* only signal it has. The dark palette is the default and the light one is the media override,
|
|
48
|
+
* matching the `theme.defaultMode` the scaffold sets.
|
|
49
|
+
*/
|
|
50
|
+
const style = (): string => `
|
|
51
|
+
:root {
|
|
52
|
+
color-scheme: dark light;
|
|
53
|
+
--bg: ${PWA_COLORS.darkBackground};
|
|
54
|
+
--fg: #e6e8f0;
|
|
55
|
+
--muted: #9aa0b5;
|
|
56
|
+
--accent: #8b93ff;
|
|
57
|
+
--line: #262a45;
|
|
58
|
+
}
|
|
59
|
+
@media (prefers-color-scheme: light) {
|
|
60
|
+
:root {
|
|
61
|
+
--bg: ${PWA_COLORS.lightBackground};
|
|
62
|
+
--fg: ${PWA_COLORS.themeColor};
|
|
63
|
+
--muted: #5d6280;
|
|
64
|
+
--accent: #3b46d6;
|
|
65
|
+
--line: #dfe2ee;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
* { box-sizing: border-box; margin: 0; }
|
|
69
|
+
html, body { min-height: 100%; }
|
|
70
|
+
body {
|
|
71
|
+
display: grid;
|
|
72
|
+
place-items: center;
|
|
73
|
+
padding: 2rem;
|
|
74
|
+
background: var(--bg);
|
|
75
|
+
color: var(--fg);
|
|
76
|
+
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
77
|
+
line-height: 1.5;
|
|
78
|
+
}
|
|
79
|
+
main { max-width: 32rem; }
|
|
80
|
+
.status {
|
|
81
|
+
font-family: ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
|
82
|
+
font-size: 0.75rem;
|
|
83
|
+
letter-spacing: 0.12em;
|
|
84
|
+
text-transform: uppercase;
|
|
85
|
+
color: var(--accent);
|
|
86
|
+
}
|
|
87
|
+
h1 { margin-top: 0.5rem; font-size: 2rem; font-weight: 600; letter-spacing: -0.02em; }
|
|
88
|
+
p { margin-top: 0.75rem; color: var(--muted); }
|
|
89
|
+
a {
|
|
90
|
+
display: inline-block;
|
|
91
|
+
margin-top: 1.5rem;
|
|
92
|
+
padding: 0.6rem 1rem;
|
|
93
|
+
border: 1px solid var(--line);
|
|
94
|
+
border-radius: 0.5rem;
|
|
95
|
+
color: var(--fg);
|
|
96
|
+
text-decoration: none;
|
|
97
|
+
}
|
|
98
|
+
a:hover { border-color: var(--accent); }
|
|
99
|
+
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
const page = (app: NameSet, copy: ErrorCopy): string => `<!doctype html>
|
|
103
|
+
<html lang="en">
|
|
104
|
+
<head>
|
|
105
|
+
<meta charset="utf-8">
|
|
106
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
107
|
+
<meta name="robots" content="noindex">
|
|
108
|
+
<meta name="theme-color" content="${PWA_COLORS.themeColor}">
|
|
109
|
+
<title>${copy.title} · ${titleCase(app.raw)}</title>
|
|
110
|
+
<style>${style()}</style>
|
|
111
|
+
</head>
|
|
112
|
+
<body>
|
|
113
|
+
<main>
|
|
114
|
+
<p class="status">${String(copy.status)}</p>
|
|
115
|
+
<h1>${copy.title}</h1>
|
|
116
|
+
<p>${copy.body}</p>
|
|
117
|
+
<a href="/">Back to ${titleCase(app.raw)}</a>
|
|
118
|
+
</main>
|
|
119
|
+
</body>
|
|
120
|
+
</html>
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
/** `apps/web/site/errors/404.html` and `500.html`, in status order. */
|
|
124
|
+
export const errorPageFiles = (app: NameSet): readonly GeneratedFile[] =>
|
|
125
|
+
COPY.map((copy) => ({
|
|
126
|
+
path: `apps/web/site/errors/${String(copy.status)}.html`,
|
|
127
|
+
contents: page(app, copy),
|
|
128
|
+
}));
|
|
@@ -108,9 +108,55 @@ const i18nCatalog = (app: NameSet): string =>
|
|
|
108
108
|
catalogJson({
|
|
109
109
|
'site.home.title': app.pascal,
|
|
110
110
|
'site.home.description': 'Everything you need, one command from shippable.',
|
|
111
|
+
'site.home.eyebrow': 'Built on Ultimate',
|
|
112
|
+
'site.home.headline': 'Ship the whole product, not a prototype.',
|
|
113
|
+
'site.home.lede':
|
|
114
|
+
'Typed routes, actions and queries, one authz system, budgets that fail the build, and a dark, designed shell — all from one command.',
|
|
111
115
|
'site.home.cta': 'Open the dashboard',
|
|
116
|
+
'site.home.secondary': 'Open the admin',
|
|
117
|
+
'site.home.f1Title': 'One command to a running app',
|
|
118
|
+
'site.home.f1Body':
|
|
119
|
+
'bin/setup installs, migrates and seeds; bin/dev runs every role in one process.',
|
|
120
|
+
'site.home.f2Title': 'Policies, not flags',
|
|
121
|
+
'site.home.f2Body':
|
|
122
|
+
'Every route, action and query declares who may call it, and one system decides.',
|
|
123
|
+
'site.home.f3Title': 'Budgets that fail the build',
|
|
124
|
+
'site.home.f3Body':
|
|
125
|
+
'This page ships zero JavaScript, and the gate refuses the first byte over it.',
|
|
126
|
+
'shell.brand': app.pascal,
|
|
127
|
+
'shell.nav.dashboard': 'Dashboard',
|
|
128
|
+
'shell.nav.posts': 'Posts',
|
|
129
|
+
'shell.footer': 'Built with Ultimate',
|
|
112
130
|
'app.dashboard.title': 'Dashboard',
|
|
113
131
|
'app.dashboard.description': 'Your workspace.',
|
|
132
|
+
'app.dashboard.subtitle': 'What this app looks like from above.',
|
|
133
|
+
'app.dashboard.statTotal': 'Posts',
|
|
134
|
+
'app.dashboard.hintTotal': 'in the seeded org',
|
|
135
|
+
'app.dashboard.statWeek': 'Created this week',
|
|
136
|
+
'app.dashboard.hintVsPriorWeek': 'vs the week before',
|
|
137
|
+
'app.dashboard.statToday': 'Created today',
|
|
138
|
+
'app.dashboard.hintToday': 'so far, UTC',
|
|
139
|
+
'app.dashboard.chartTitle': 'Posts per day',
|
|
140
|
+
'app.dashboard.chartRange': 'Last 14 days, UTC',
|
|
141
|
+
'app.dashboard.chartLabel': 'Posts created per day over the last 14 days',
|
|
142
|
+
'app.dashboard.tableTitle': 'Latest posts',
|
|
143
|
+
'app.dashboard.tableCaption': 'The ten newest posts',
|
|
144
|
+
'app.dashboard.columnTitle': 'Title',
|
|
145
|
+
'app.dashboard.columnCreated': 'Created',
|
|
146
|
+
'app.dashboard.emptyTitle': 'No posts yet',
|
|
147
|
+
'app.dashboard.statRoutes': 'Routes',
|
|
148
|
+
'app.dashboard.hintRoutes': 'registered at boot',
|
|
149
|
+
'app.dashboard.statLocales': 'Locales',
|
|
150
|
+
'app.dashboard.hintLocales': 'in the catalog',
|
|
151
|
+
'app.dashboard.statRoles': 'Roles',
|
|
152
|
+
'app.dashboard.hintRoles': 'declared in shared/roles.ts',
|
|
153
|
+
'app.dashboard.statVersion': 'Ultimate',
|
|
154
|
+
'app.dashboard.hintVersion': 'framework version',
|
|
155
|
+
'app.dashboard.routesTitle': 'Route table',
|
|
156
|
+
'app.dashboard.routesCaption': 'Every route this app registers',
|
|
157
|
+
'app.dashboard.columnPath': 'Path',
|
|
158
|
+
'app.dashboard.columnSurface': 'Surface',
|
|
159
|
+
'app.dashboard.columnRender': 'Render',
|
|
114
160
|
'app.offline.title': 'You are offline',
|
|
115
161
|
'app.offline.description': 'This page will refresh itself when the connection returns.',
|
|
116
162
|
'admin.home.title': 'Admin',
|
|
@@ -14,6 +14,7 @@ import { dbPackageFiles } from './scaffold-db-package';
|
|
|
14
14
|
import { docsFiles } from './scaffold-docs';
|
|
15
15
|
import { domainPackageFiles } from './scaffold-domain-package';
|
|
16
16
|
import { envExampleSource, envSchemaSource } from './scaffold-env';
|
|
17
|
+
import { PWA_COLORS } from './scaffold-errors';
|
|
17
18
|
import { scaffoldGuardFiles } from './scaffold-guards';
|
|
18
19
|
import { i18nFiles } from './scaffold-i18n';
|
|
19
20
|
import { mcpPackageFiles } from './scaffold-mcp-package';
|
|
@@ -213,10 +214,14 @@ export const config = defineConfig({
|
|
|
213
214
|
offline: { fallback: '/offline' },
|
|
214
215
|
name: '${titleCase(app.raw)}',
|
|
215
216
|
colors: {
|
|
216
|
-
light: { themeColor: '
|
|
217
|
-
dark: { themeColor: '
|
|
217
|
+
light: { themeColor: '${PWA_COLORS.themeColor}', backgroundColor: '${PWA_COLORS.lightBackground}' },
|
|
218
|
+
dark: { themeColor: '${PWA_COLORS.themeColor}', backgroundColor: '${PWA_COLORS.darkBackground}' },
|
|
218
219
|
},
|
|
219
220
|
},
|
|
221
|
+
// The theme a first visit opens in; the framework inlines the no-flash boot script that reads
|
|
222
|
+
// it, and a choice stored by the theme toggle wins over it on every later visit; 'system' follows
|
|
223
|
+
// the OS.
|
|
224
|
+
theme: { defaultMode: 'dark' },
|
|
220
225
|
ai: { mcp: { expose: true, path: '/mcp' } },
|
|
221
226
|
});
|
|
222
227
|
`;
|