@sempods/apps 0.8.0 → 0.9.0-dev.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +13 -6
  3. package/bin/sempods-apps.mjs +1 -0
  4. package/instructions/app-workflow.md +145 -72
  5. package/instructions/publish.md +29 -11
  6. package/instructions/setup.md +2 -2
  7. package/instructions/visual-check.md +20 -2
  8. package/package.json +5 -3
  9. package/scripts/build-site.mjs +37 -9
  10. package/scripts/check-theme.mjs +398 -0
  11. package/scripts/check.mjs +16 -5
  12. package/scripts/icon.mjs +1 -1
  13. package/scripts/lib/apps.mjs +115 -27
  14. package/scripts/lib/generate.mjs +58 -28
  15. package/scripts/lib/json.mjs +6 -0
  16. package/scripts/lib/kit.mjs +81 -0
  17. package/scripts/lib/merge.mjs +12 -9
  18. package/scripts/lib/sdk.mjs +10 -2
  19. package/scripts/lib/themes.mjs +45 -19
  20. package/scripts/migrate.mjs +6 -1
  21. package/scripts/new-app.mjs +2 -7
  22. package/scripts/sdk-update.mjs +32 -10
  23. package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
  24. package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
  25. package/shared/snapshot.json +3 -3
  26. package/skeleton/app/package.json +3 -0
  27. package/skeleton/app/src/App.tsx +53 -21
  28. package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
  29. package/skeleton/app/src/examples/ListScreen.tsx +154 -56
  30. package/skeleton/app/src/index.css +10 -110
  31. package/skeleton/app/src/main.tsx +1 -3
  32. package/skeleton/app/src/places.test.ts +128 -0
  33. package/skeleton/app/src/places.ts +146 -0
  34. package/skeleton/app/src/texts.ts +8 -0
  35. package/skeleton/app/src/themes/calm.css +22 -8
  36. package/skeleton/app/src/themes/fresh.css +22 -8
  37. package/skeleton/app/src/themes/playful.css +22 -8
  38. package/skeleton/app/src/themes/warm.css +22 -8
  39. package/skeleton/app/vite.config.ts +12 -2
  40. package/skills/update/SKILL.md +6 -1
  41. package/update-policy.json +1 -1
  42. package/skeleton/app/src/routes.test.ts +0 -19
  43. package/skeleton/app/src/routes.ts +0 -139
  44. package/skeleton/app/src/ui/Composer.tsx +0 -18
  45. package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
  46. package/skeleton/app/src/ui/Dialog.tsx +0 -120
  47. package/skeleton/app/src/ui/Menu.tsx +0 -118
  48. package/skeleton/app/src/ui/Tabs.tsx +0 -39
  49. package/skeleton/app/src/ui/dismiss.ts +0 -30
  50. package/skeleton/app/src/ui/icons.tsx +0 -53
  51. package/skeleton/app/src/ui/ui.css +0 -249
@@ -4,6 +4,20 @@ The owner judges an app by what they see. Before reporting a screen as done,
4
4
  look at it the way they will and fix what fails; the [app workflow](app-workflow.md)
5
5
  says when.
6
6
 
7
+ ## Check the theme's contrast
8
+
9
+ Run `pnpm run check:theme <id>` first. It builds the app, loads its
10
+ stylesheet in a browser and checks every role pair of the UI kit's theme
11
+ contract in light and dark mode, in every action variant, the ones the page
12
+ does not use too. Each failure names the variant, state, roles, colours and
13
+ ratio, and the roles to adjust: change those in the app's theme, keeping the
14
+ pairs listed alongside readable, and run it again until it passes. A scheme
15
+ the theme excludes is reported as not sampled, not as passing. The first run
16
+ on a machine may ask for `pnpm run check:theme --install-browser`.
17
+
18
+ The command checks the palette roles only. Classes on single controls,
19
+ images, opacity and interaction states need the screenshots below.
20
+
7
21
  ## Look at the real states
8
22
 
9
23
  Open the app in a browser you can control, signed in to the test context, and
@@ -22,7 +36,7 @@ screen is visible.
22
36
 
23
37
  - Spacing and alignment are consistent; nothing touches the screen edge.
24
38
  - The hierarchy is clear: one primary action per screen or dialog, other
25
- actions read as text.
39
+ actions secondary or quieter.
26
40
  - Every control has a 44px touch target; checkboxes are wrapped in their
27
41
  label.
28
42
  - Nothing scrolls sideways at 375px.
@@ -31,9 +45,13 @@ screen is visible.
31
45
  - The app's icon and name are present in the header and on the sign-in
32
46
  screen.
33
47
  - Text and controls keep their contrast in both modes; colours come from the
34
- app's theme.
48
+ app's theme roles, and `check:theme` passes.
35
49
  - A bar fixed at the bottom is not covered and leaves room for the content
36
50
  above it.
51
+ - An item or action screen (`ScreenDialog`) fills the phone's screen and
52
+ closes with Back, Escape and Close; with an unsaved edit, each asks first.
53
+ - Menus, dialogs and the date chooser work by keyboard: arrows move, Escape
54
+ closes one level, and focus returns to what opened them.
37
55
 
38
56
  ## Ask the owner
39
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sempods/apps",
3
- "version": "0.8.0",
3
+ "version": "0.9.0-dev.1",
4
4
  "description": "Tooling for a sempods apps repository: create, check, update and publish several sempods apps with one shared SDK version.",
5
5
  "keywords": [
6
6
  "sempods",
@@ -45,6 +45,7 @@
45
45
  "LICENSE"
46
46
  ],
47
47
  "dependencies": {
48
+ "playwright": "1.64.1",
48
49
  "semver": "7.8.5",
49
50
  "typescript": "6.0.3"
50
51
  },
@@ -55,8 +56,9 @@
55
56
  "unified-engine": "11.2.2"
56
57
  },
57
58
  "peerDependencies": {
58
- "@sempods/app-sdk": "^0.6.0",
59
- "@sempods/client-sdk": "^0.6.0"
59
+ "@sempods/app-sdk": "^0.7.0-dev.2",
60
+ "@sempods/app-ui": "^0.7.0-dev.2",
61
+ "@sempods/client-sdk": "^0.7.0-dev.2"
60
62
  },
61
63
  "scripts": {}
62
64
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Builds every app with a published profile into its path of one static site
3
3
  // folder, site-dist/, with a page for each route, and writes the overview and
4
- // the host's routing files.
4
+ // the host's routing files: 404.html, and _redirects for resource patterns.
5
5
  // apps/<id>/dist keeps the app's local build.
6
6
  // Usage: pnpm run build-site [--profile production|preview]
7
7
  // (or SEMPODS_SITE_PROFILE=<profile> in the host's environment)
@@ -13,12 +13,12 @@ import {
13
13
  rmSync,
14
14
  writeFileSync,
15
15
  } from 'node:fs';
16
- import { join, relative, resolve } from 'node:path';
16
+ import { dirname, join, relative, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { parseArgs } from 'node:util';
19
19
  import { AUTO, readApps, SITE_PROFILES, siteIdentity } from './lib/apps.mjs';
20
20
  import { PROFILE_MODES, staleGenerated } from './lib/generate.mjs';
21
- import { ifPresent, writeJson } from './lib/json.mjs';
21
+ import { ifPresent, readJson, writeJson } from './lib/json.mjs';
22
22
  import { renderNotFound, renderOverview } from './lib/overview.mjs';
23
23
  import { repositoryRoot } from './lib/paths.mjs';
24
24
  import { pnpm, scriptArgs } from './lib/pnpm.mjs';
@@ -51,6 +51,10 @@ const RESERVED = [
51
51
  'callback.html',
52
52
  ];
53
53
 
54
+ const REDIRECTS_HEADER = `# Generated by pnpm run build-site from the resource patterns in apps.json.
55
+ # Each line serves an app's page for its resource addresses.
56
+ `;
57
+
54
58
  /** Removes a file or directory and reports whether there was one. */
55
59
  const removeIfPresent = (path) =>
56
60
  ifPresent(() => rmSync(path, { recursive: true }) ?? true) ?? false;
@@ -59,9 +63,13 @@ const removeIfPresent = (path) =>
59
63
  const copyIfPresent = (from, to) =>
60
64
  ifPresent(() => copyFileSync(from, to) ?? true) ?? false;
61
65
 
62
- /** The SDK's Apache-2.0 notices, which travel with the published code. */
66
+ /**
67
+ * The Apache-2.0 notices of the SDK packages the app declares, which travel
68
+ * with the published code.
69
+ */
63
70
  function copyLicences(roots, appOut, appId) {
64
- for (const pkg of SDK) {
71
+ const { dependencies = {} } = readJson(join(roots[0], 'package.json'));
72
+ for (const pkg of SDK.filter((name) => dependencies[name])) {
65
73
  const to = join(appOut, 'licenses', pkg.split('/')[1]);
66
74
  mkdirSync(to, { recursive: true });
67
75
  const from = roots
@@ -115,6 +123,7 @@ export function buildSite(root, { profile = 'production', build = appBuild } = {
115
123
 
116
124
  const entries = [];
117
125
  const warnings = [];
126
+ const rewrites = [];
118
127
  for (const app of apps) {
119
128
  const appDir = join(root, 'apps', app.id);
120
129
  const appOut = join(out, app.id);
@@ -147,14 +156,22 @@ export function buildSite(root, { profile = 'production', build = appBuild } = {
147
156
  const callback = new URL(redirectUri).pathname;
148
157
  writeFileSync(join(out, `${callback.slice(1)}.html`), html);
149
158
  // Each route answers the same way from a copy of the page, so the host
150
- // needs no rewrite and unknown paths still show the not-found page.
151
- for (const route of app.routes ?? []) {
159
+ // needs no rewrite for it and unknown paths still show the not-found page.
160
+ // A route with several segments gets its copy in a subfolder.
161
+ const routes = app.routes ?? [];
162
+ for (const route of routes)
152
163
  if (['', '.html'].some((ext) => existsSync(join(appOut, route + ext))))
153
164
  throw new Error(
154
165
  `apps/${app.id}: the route ${route} collides with ${route} in the app's build output; rename one of them.`,
155
166
  );
167
+ for (const route of routes) {
168
+ mkdirSync(dirname(join(appOut, route)), { recursive: true });
156
169
  writeFileSync(join(appOut, `${route}.html`), html);
157
170
  }
171
+ // Resource patterns have no file per address: the host rewrites exactly
172
+ // these addresses to the page, after the published files.
173
+ for (const pattern of app.resources ?? [])
174
+ rewrites.push(`${app.path}${pattern} ${app.path}index.html 200`);
158
175
 
159
176
  entries.push(overviewEntry(app, appOut));
160
177
  }
@@ -163,7 +180,14 @@ export function buildSite(root, { profile = 'production', build = appBuild } = {
163
180
  // Hosts serve it for unknown paths; without it, Cloudflare Pages would serve
164
181
  // the overview for every unknown path.
165
182
  writeFileSync(join(out, '404.html'), renderNotFound(entries));
166
- return { apps, site, warnings };
183
+ // Netlify and Cloudflare Pages read it at the site root; a placeholder
184
+ // matches one segment, and a published file at the address wins.
185
+ if (rewrites.length > 0)
186
+ writeFileSync(
187
+ join(out, '_redirects'),
188
+ `${REDIRECTS_HEADER}${rewrites.join('\n')}\n`,
189
+ );
190
+ return { apps, site, warnings, rewrites };
167
191
  }
168
192
 
169
193
  function main() {
@@ -174,10 +198,14 @@ function main() {
174
198
  const root = repositoryRoot();
175
199
  const profile =
176
200
  values.profile ?? process.env.SEMPODS_SITE_PROFILE ?? 'production';
177
- const { apps, site, warnings } = buildSite(root, { profile });
201
+ const { apps, site, warnings, rewrites } = buildSite(root, { profile });
178
202
  for (const warning of warnings) console.warn(`build-site: ${warning}`);
179
203
  console.log(`\nBuilt ${SITE_DIR}/ for ${site[profile]} (${profile}):`);
180
204
  for (const app of apps) console.log(` ${site[profile]}${app.path}`);
205
+ if (rewrites.length > 0)
206
+ console.log(
207
+ `\n${SITE_DIR}/_redirects serves the resource addresses; the host must read it (Netlify, Cloudflare Pages).`,
208
+ );
181
209
  }
182
210
 
183
211
  if (
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+ // Checks the contrast of an app's theme in a browser: builds the app, loads
3
+ // its stylesheet with the attributes of its <html> and runs the UI kit's role
4
+ // check (@sempods/app-ui/theme-check) under a light and a dark colour
5
+ // preference. Every action variant is sampled, the inactive ones too. A
6
+ // failure names the variant, state, roles, colours and ratio; fix the roles it
7
+ // names and run the command again. Screens still need a visual check.
8
+ // Usage: pnpm run check:theme [<id>] [--json] [--install-browser]
9
+ import { spawnSync } from 'node:child_process';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ rmSync,
17
+ } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import { dirname, join, relative, resolve, sep } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { parseArgs } from 'node:util';
22
+ import { readApps } from './lib/apps.mjs';
23
+ import { isFile } from './lib/files.mjs';
24
+ import { escapeHtml } from './lib/html.mjs';
25
+ import { readJson } from './lib/json.mjs';
26
+ import { repositoryRoot } from './lib/paths.mjs';
27
+ import { pnpm, scriptArgs } from './lib/pnpm.mjs';
28
+ import { UI_KIT } from './lib/sdk.mjs';
29
+
30
+ export const SCHEMES = ['light', 'dark'];
31
+ // The page's origin: requests to it are answered from local files, nothing
32
+ // goes to the network.
33
+ const ORIGIN = 'http://theme-check.invalid';
34
+
35
+ /** The attributes of the page's <html> element, such as data-actions. */
36
+ export function htmlAttributes(html) {
37
+ const tag = html.match(/<html\b([^>]*)>/i)?.[1] ?? '';
38
+ return [...tag.matchAll(/([^\s=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g)]
39
+ .map(([, name, a, b, c]) => [name.toLowerCase(), a ?? b ?? c ?? ''])
40
+ .filter(([name]) => name !== 'lang');
41
+ }
42
+
43
+ /** The stylesheets a built page links, as paths below the build folder. */
44
+ export function linkedStylesheets(html, basePath) {
45
+ return [...html.matchAll(/<link\b[^>]*>/gi)]
46
+ .map(([tag]) => tag)
47
+ .filter((tag) => /\brel\s*=\s*["']?stylesheet\b/i.test(tag))
48
+ .map((tag) => tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1])
49
+ .filter((href) => href?.startsWith(basePath))
50
+ .map((href) => href.slice(basePath.length));
51
+ }
52
+
53
+ /** The check page: the app's stylesheets, its <html> attributes, the check. */
54
+ export function checkPage(attributes, stylesheets) {
55
+ const attrs = attributes
56
+ .map(([name, value]) => ` ${name}="${escapeHtml(value)}"`)
57
+ .join('');
58
+ const links = stylesheets
59
+ .map((file) => `<link rel="stylesheet" href="/app/${file}">`)
60
+ .join('');
61
+ return `<!doctype html><html${attrs}><head><meta charset="utf-8">${links}<script type="module">import * as check from '/theme-check/index.js'; window.themeCheck = check;</script></head><body></body></html>`;
62
+ }
63
+
64
+ /** The UI kit an app has installed, or undefined. */
65
+ function installedKit(root, appDir) {
66
+ return [appDir, root]
67
+ .map((base) => join(base, 'node_modules', ...UI_KIT.split('/')))
68
+ .find((dir) => existsSync(join(dir, 'dist', 'theme-check', 'index.js')));
69
+ }
70
+
71
+ /** Builds the app into `outDir`, or throws. */
72
+ function buildApp(appDir, outDir) {
73
+ // The output path is relative to the app, so no user directory name passes
74
+ // through the Windows shell (lib/pnpm.mjs).
75
+ const result = pnpm(
76
+ [
77
+ ...['exec', 'vite', 'build', '--outDir', relative(appDir, outDir)],
78
+ ...['--emptyOutDir', '--logLevel', 'error'],
79
+ ],
80
+ { cwd: appDir, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' },
81
+ );
82
+ if (result.status !== 0)
83
+ throw new Error(
84
+ `the build failed:\n${`${result.stdout}${result.stderr}`.trim()}`,
85
+ );
86
+ }
87
+
88
+ const MISSING_BROWSER =
89
+ 'the browser for the check is missing: run pnpm run check:theme --install-browser once, then again without it';
90
+
91
+ /** Starts Chromium through the tooling's Playwright. */
92
+ async function launch() {
93
+ const { chromium } = await import('playwright');
94
+ try {
95
+ return await chromium.launch();
96
+ } catch (error) {
97
+ if (/Executable doesn't exist|playwright install/i.test(error.message))
98
+ throw new Error(MISSING_BROWSER);
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ /** Installs the Chromium of the tooling's Playwright into its user cache. */
104
+ export function installBrowser() {
105
+ const cli = join(
106
+ dirname(createRequire(import.meta.url).resolve('playwright/package.json')),
107
+ 'cli.js',
108
+ );
109
+ return (
110
+ spawnSync(process.execPath, [cli, 'install', 'chromium'], {
111
+ stdio: 'inherit',
112
+ }).status === 0
113
+ );
114
+ }
115
+
116
+ /** Serves `root` for requests below `prefix`, and nothing outside it. */
117
+ function fileFrom(root, prefix, path) {
118
+ let name;
119
+ try {
120
+ name = decodeURIComponent(path.slice(prefix.length));
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ // A null byte names no file; the file system would refuse it.
125
+ if (name.includes('\0')) return undefined;
126
+ const file = resolve(root, name);
127
+ return file.startsWith(root + sep) && isFile(file) ? file : undefined;
128
+ }
129
+
130
+ const TYPES = { '.css': 'text/css', '.js': 'text/javascript' };
131
+
132
+ /**
133
+ * The reports of one built app under each colour preference: `{ scheme:
134
+ * report }`, with `error` for a scheme whose check threw.
135
+ */
136
+ export async function inspectBuild(browser, { outDir, kitDir, basePath }) {
137
+ const html = readFileSync(join(outDir, 'index.html'), 'utf8');
138
+ const stylesheets = linkedStylesheets(html, basePath);
139
+ if (stylesheets.length === 0)
140
+ throw new Error('the built page links no stylesheet');
141
+ const page = await browser.newPage();
142
+ const checkDir = join(kitDir, 'dist', 'theme-check');
143
+ await page.route(`${ORIGIN}/**`, (route) => {
144
+ const { pathname } = new URL(route.request().url());
145
+ if (pathname === '/')
146
+ return route.fulfill({
147
+ contentType: 'text/html',
148
+ body: checkPage(htmlAttributes(html), stylesheets),
149
+ });
150
+ const file = pathname.startsWith('/app/')
151
+ ? fileFrom(outDir, '/app/', pathname)
152
+ : pathname.startsWith('/theme-check/')
153
+ ? fileFrom(checkDir, '/theme-check/', pathname)
154
+ : undefined;
155
+ if (!file) return route.fulfill({ status: 404, body: '' });
156
+ return route.fulfill({
157
+ contentType: TYPES[file.slice(file.lastIndexOf('.'))] ?? 'text/plain',
158
+ body: readFileSync(file),
159
+ });
160
+ });
161
+ // Fonts, images and anything else outside the page stay unloaded.
162
+ await page.route(
163
+ (url) => url.origin !== ORIGIN,
164
+ (route) => route.abort(),
165
+ );
166
+ try {
167
+ await page.goto(`${ORIGIN}/`);
168
+ await page.waitForFunction(() => 'themeCheck' in window);
169
+ const reports = {};
170
+ for (const scheme of SCHEMES) {
171
+ await page.emulateMedia({ colorScheme: scheme });
172
+ reports[scheme] = await page.evaluate((only) => {
173
+ try {
174
+ return window.themeCheck.checkTheme(only);
175
+ } catch (error) {
176
+ return { error: String(error?.message ?? error) };
177
+ }
178
+ }, scheme);
179
+ }
180
+ return reports;
181
+ } finally {
182
+ await page.close();
183
+ }
184
+ }
185
+
186
+ // The action variants the check samples: data-actions × data-secondary.
187
+ const VARIANT_COUNT = 6;
188
+
189
+ /**
190
+ * Failures of the same pair with the same colours, once each, with the
191
+ * variants they occur in; the page's own variant is marked.
192
+ */
193
+ export function failureGroups(failures) {
194
+ const groups = new Map();
195
+ for (const f of failures) {
196
+ const key = [
197
+ f.state,
198
+ f.pair,
199
+ f.backdrop,
200
+ f.foreground.colour,
201
+ f.surface.colour,
202
+ ].join('|');
203
+ if (!groups.has(key)) groups.set(key, { failure: f, variants: [] });
204
+ groups
205
+ .get(key)
206
+ .variants.push(
207
+ `${f.variant.actions}/${f.variant.secondary}${f.active ? ' (the page)' : ''}`,
208
+ );
209
+ }
210
+ return [...groups.values()].map(({ failure, variants }) => ({
211
+ failure,
212
+ variants:
213
+ variants.length === VARIANT_COUNT
214
+ ? 'every variant'
215
+ : variants.join(', '),
216
+ }));
217
+ }
218
+
219
+ /** The variants a scheme skipped and why, for the end of its line. */
220
+ function skippedNote(skips) {
221
+ if (skips.length === 0) return '';
222
+ const variants = skips.map((s) => `${s.variant.actions}/${s.variant.secondary}`);
223
+ const reasons = [...new Set(skips.map((s) => s.reason))];
224
+ return `; not sampled in ${[...new Set(variants)].join(', ')} (${reasons.join('; ')})`;
225
+ }
226
+
227
+ /**
228
+ * Lines for the console and whether everything passed. A scheme the theme
229
+ * excludes (a forced colour scheme) is reported as not sampled, not as
230
+ * passing or failing: the kit's report has no samples and so no pass for it.
231
+ * Each app needs at least one scheme with samples; variants a sampled scheme
232
+ * skipped are named on its line.
233
+ */
234
+ export function summarize(results) {
235
+ const lines = [];
236
+ let ok = true;
237
+ for (const { id, error, reports } of results) {
238
+ if (error) {
239
+ ok = false;
240
+ lines.push(`✗ apps/${id}: ${error}`);
241
+ continue;
242
+ }
243
+ let sampled = false;
244
+ for (const scheme of SCHEMES) {
245
+ const report = reports[scheme];
246
+ const label = `apps/${id} ${scheme}`;
247
+ if (report.error) {
248
+ ok = false;
249
+ lines.push(`✗ ${label}: the check failed: ${report.error}`);
250
+ continue;
251
+ }
252
+ const failures = report.failures.filter((f) => f.scheme === scheme);
253
+ const skips = report.skipped.filter((skip) => skip.scheme === scheme);
254
+ // The kit passes a scheme only with samples and no failure; without
255
+ // either, nothing was measured.
256
+ if (!report.pass && failures.length === 0) {
257
+ lines.push(
258
+ `– ${label}: not sampled (${skips[0]?.reason ?? 'no sample was taken'})`,
259
+ );
260
+ continue;
261
+ }
262
+ sampled = true;
263
+ if (report.pass)
264
+ lines.push(
265
+ `✓ ${label}: every role pair passes in every sampled variant (the page uses ${report.active.actions}/${report.active.secondary})${skippedNote(skips)}`,
266
+ );
267
+ else {
268
+ ok = false;
269
+ const groups = failureGroups(failures);
270
+ lines.push(
271
+ `✗ ${label}: ${groups.length} pair(s) below the minimum${skippedNote(skips)}`,
272
+ );
273
+ for (const { failure: f, variants } of groups)
274
+ lines.push(
275
+ ` ${f.pair} on ${f.backdrop} (${f.state}): ${f.foreground.colour} on ${f.surface.colour}, ${f.ratio.toFixed(2)}:1, needs ${f.required}:1, in ${variants}. Adjust ${f.adjust.join(', ')}.`,
276
+ );
277
+ }
278
+ }
279
+ if (!sampled && SCHEMES.every((scheme) => !reports[scheme].error)) {
280
+ ok = false;
281
+ lines.push(`✗ apps/${id}: no colour scheme was sampled, so nothing was checked`);
282
+ }
283
+ }
284
+ return { ok, lines };
285
+ }
286
+
287
+ /** Checks the given apps; the build and the inspection are replaceable for tests. */
288
+ export async function checkThemes(
289
+ root,
290
+ ids,
291
+ { build = buildApp, inspect, browser: start = launch } = {},
292
+ ) {
293
+ const { apps } = readApps(root);
294
+ const unknown = ids.filter((id) => !apps.some((app) => app.id === id));
295
+ if (unknown.length > 0)
296
+ throw new Error(`${unknown.join(', ')} is not listed in apps.json`);
297
+ const selected = ids.length
298
+ ? apps.filter((app) => ids.includes(app.id))
299
+ : apps;
300
+ const results = [];
301
+ const skipped = [];
302
+ let browser;
303
+ try {
304
+ for (const app of selected) {
305
+ const appDir = join(root, 'apps', app.id);
306
+ const { dependencies = {} } = readJson(join(appDir, 'package.json'));
307
+ if (!dependencies[UI_KIT]) {
308
+ skipped.push(
309
+ `apps/${app.id} does not use ${UI_KIT}; its theme is not checked`,
310
+ );
311
+ if (ids.length) results.push({ id: app.id, error: skipped.at(-1) });
312
+ continue;
313
+ }
314
+ const kitDir = installedKit(root, appDir);
315
+ if (!kitDir) {
316
+ results.push({
317
+ id: app.id,
318
+ error: `${UI_KIT} with its theme check is not installed; run pnpm install`,
319
+ });
320
+ continue;
321
+ }
322
+ // Inside the app's node_modules, where builds leave nothing to commit.
323
+ const cache = join(appDir, 'node_modules', '.cache');
324
+ mkdirSync(cache, { recursive: true });
325
+ const outDir = mkdtempSync(join(cache, 'sempods-theme-'));
326
+ try {
327
+ build(appDir, outDir);
328
+ const target = { outDir, kitDir, basePath: app.path };
329
+ const reports = inspect
330
+ ? await inspect(target)
331
+ : await inspectBuild((browser ??= await start()), target);
332
+ results.push({ id: app.id, reports });
333
+ } catch (error) {
334
+ if (error.message === MISSING_BROWSER) throw error;
335
+ results.push({ id: app.id, error: error.message });
336
+ } finally {
337
+ rmSync(outDir, { recursive: true, force: true });
338
+ }
339
+ }
340
+ } finally {
341
+ await browser?.close();
342
+ }
343
+ return { results, skipped };
344
+ }
345
+
346
+ async function main() {
347
+ const { values, positionals } = parseArgs({
348
+ args: scriptArgs(),
349
+ allowPositionals: true,
350
+ options: {
351
+ json: { type: 'boolean' },
352
+ 'install-browser': { type: 'boolean' },
353
+ },
354
+ });
355
+ if (values['install-browser']) {
356
+ if (!installBrowser()) throw new Error('the browser could not be installed');
357
+ if (positionals.length === 0 && !values.json) return;
358
+ }
359
+ const root = repositoryRoot();
360
+ const { results, skipped } = await checkThemes(root, positionals);
361
+ const { ok, lines } = summarize(results);
362
+ if (values.json)
363
+ console.log(
364
+ JSON.stringify(
365
+ {
366
+ pass: ok,
367
+ apps: results.map(({ id, error, reports }) => ({
368
+ id,
369
+ ...(error ? { error } : {}),
370
+ ...(reports ? { schemes: reports } : {}),
371
+ })),
372
+ skipped,
373
+ },
374
+ null,
375
+ 2,
376
+ ),
377
+ );
378
+ else {
379
+ for (const line of skipped) console.log(`– ${line}`);
380
+ for (const line of lines) console.log(line);
381
+ if (results.length === 0) console.log('No app uses the UI kit yet.');
382
+ console.log(
383
+ ok
384
+ ? '\ncheck:theme passed. Check the screens visually as well.'
385
+ : '\ncheck:theme failed: fix what is named above (a role, the colour scheme or the build), then run it again.',
386
+ );
387
+ }
388
+ if (!ok) process.exitCode = 1;
389
+ }
390
+
391
+ if (
392
+ process.argv[1] &&
393
+ resolve(process.argv[1]) === fileURLToPath(import.meta.url)
394
+ )
395
+ main().catch((error) => {
396
+ console.error(`check:theme: ${error.message}`);
397
+ process.exitCode = 1;
398
+ });
package/scripts/check.mjs CHANGED
@@ -26,7 +26,13 @@ import { migrationState } from './migrate.mjs';
26
26
  import { readJson, writeJson } from './lib/json.mjs';
27
27
  import { repositoryRoot, TOOLING } from './lib/paths.mjs';
28
28
  import { INSTALL, pnpm, scriptArgs } from './lib/pnpm.mjs';
29
- import { EXACT_VERSION, REFERENCE, SDK } from './lib/sdk.mjs';
29
+ import {
30
+ appSdk,
31
+ EXACT_VERSION,
32
+ REFERENCE,
33
+ SDK,
34
+ UI_KIT,
35
+ } from './lib/sdk.mjs';
30
36
 
31
37
  const SOURCE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
32
38
  const BUILTIN = new Set(builtinModules);
@@ -106,10 +112,14 @@ export function staticProblems(root, { tooling = TOOLING } = {}) {
106
112
  );
107
113
  versions.set(`${where} ${pkg}`, version);
108
114
  };
109
- // The root installs the SDK too, so its shipped reference is there before any
110
- // app; new-app gives a new app the root's version.
115
+ // The root installs the SDK and the UI kit too, so their shipped references
116
+ // are there before any app; new-app gives a new app the root's version.
111
117
  for (const pkg of SDK)
112
- expect('root', pkg, rootManifest.devDependencies?.[pkg]);
118
+ if (pkg === UI_KIT && rootManifest.devDependencies?.[pkg] === undefined)
119
+ problems.push(
120
+ `root: package.json declares no ${pkg}; run pnpm run sdk-update <version>, which adds it at the SDK's version`,
121
+ );
122
+ else expect('root', pkg, rootManifest.devDependencies?.[pkg]);
113
123
 
114
124
  for (const app of manifest.apps) {
115
125
  const dir = join(appsDir, app.id);
@@ -123,7 +133,8 @@ export function staticProblems(root, { tooling = TOOLING } = {}) {
123
133
  );
124
134
  problems.push(...pageProblems(dir, app.id));
125
135
  const pkg = readJson(join(dir, 'package.json'));
126
- for (const sdk of SDK)
136
+ // The UI kit is optional: an app built before it keeps its own kit.
137
+ for (const sdk of appSdk(pkg.dependencies))
127
138
  expect(`apps/${app.id}`, sdk, pkg.dependencies?.[sdk]);
128
139
  const declared = new Set([
129
140
  ...Object.keys(pkg.dependencies ?? {}),
package/scripts/icon.mjs CHANGED
@@ -21,7 +21,7 @@ export function drawIcon(root, id) {
21
21
  const theme = appTheme(dir);
22
22
  if (!theme || !existsSync(join(dir, 'src', 'themes', `${theme}.css`)))
23
23
  throw new Error(
24
- `apps/${id}/src/main.tsx imports no theme from src/themes/; import one as the skeleton does, then run this again`,
24
+ `apps/${id}/src/index.css imports no theme from src/themes/; import one as the skeleton does, then run this again`,
25
25
  );
26
26
  return writeAppIcons(dir, app.title, iconColours(themeCss(dir, theme))).map(
27
27
  (file) => `apps/${id}/${file}`,