From 6d365cf529dbbe4e36e7fec33c7b98411b66e466 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Fri, 6 Nov 2026 23:50:22 +0100 Subject: [PATCH] fix: a page in the dark color scheme, also one in an iframe, is no longer white while it loads, and test-container.mjs runs the checks with Docker. --- ADR.md | 36 +++++++++++++++++++ CHANGELOG.md | 12 +++++++ README.md | 16 ++++++--- package-lock.json | 4 +-- package.json | 4 +-- test-container.mjs | 87 ++++++++++++++++++++++++++++++++++++++++++++++ test.Dockerfile | 16 +++++++++ vite/index.ts | 25 +++++++++++++ 8 files changed, 192 insertions(+), 8 deletions(-) create mode 100755 test-container.mjs create mode 100644 test.Dockerfile diff --git a/ADR.md b/ADR.md index b374aea..53aa2a8 100644 --- a/ADR.md +++ b/ADR.md @@ -157,3 +157,39 @@ store is first used. Consequences: The schema of an instance's files is the schema of its newest revision; files imported without a schema, or written before revisions, have none until a configurator writes them. Revisions grow with every write; the README describes the SQL that removes them. + +## ADR-8: A Vite plugin sets the color scheme before the first paint + +Status: Accepted + +Context: `appTheme` puts the class of the color scheme on `` from React, after the scripts +of the application have loaded. Until then a page in the dark scheme is white, which shows on +every load, and on every opening of a page that embeds an application in an iframe. + +Decision: The `colorScheme()` plugin adds an inline script and style to `index.html`, so the class +is set and the dark background painted before the application loads. The script reads MUI's +storage key `mui-mode` and follows `prefers-color-scheme` for the system mode, as MUI does, and +the style uses MUI's default dark background, which `appTheme` keeps. The plugin is part of the +Vite helpers, which every application already uses, so the applications need no code of their own +in `index.html`. + +Consequences: The storage key and the dark background are repeated in the plugin; a theme that +changes either has to change the plugin too. An application of the toolkit adds `colorScheme()` +to the plugins of its `vite.config.ts`. + +## ADR-9: The checks run in a container built from the files git knows, with a scratch PostgreSQL + +Status: Accepted + +Context: The checks are faster on a remote Docker engine than on the developer's machine. The +store's tests need a PostgreSQL server whose role creates databases, and a remote engine sees +neither the checkout nor its `node_modules`. + +Decision: `test-container.mjs` sends a tar archive of the files git tracks and the untracked files +it does not ignore as the build context of `test.Dockerfile`, whose build runs the checks that need +no server, and runs the tests in a container next to a PostgreSQL container on a network of their +own, both removed afterwards. The image's `package` stage holds the packed toolkit, which the +applications install to check against the checkout. + +Consequences: The tests need no PostgreSQL server of the developer's. A file git ignores is not +part of the checks. The checks run on Linux, whatever the developer's system. diff --git a/CHANGELOG.md b/CHANGELOG.md index 59766bb..9c2c7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.7.1 + +fix: a page in the dark color scheme, also one in an iframe, is no longer white while it loads, +and test-container.mjs runs the checks with Docker. + +- The `colorScheme()` plugin of `/vite` sets the color scheme of `appTheme` and the dark background + on the page before its first paint; an application adds it to the plugins of its + `vite.config.ts`. +- `./test-container.mjs` runs the typecheck, the lint, the package build and the tests in a + container, on the engine of the docker CLI's context, with a scratch PostgreSQL; + `--package ` writes the packed toolkit to the folder. + ## 0.7.0 feature: every write of an instance's variable files is kept as a revision with the schema version diff --git a/README.md b/README.md index 5176e34..caa5475 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,15 @@ published version and imports `@koszewscy/sk-react-toolkit/server`, `/shared`, ` { "dependencies": { "@koszewscy/sk-react-toolkit": "^0.2.0" } } ``` -The web application adds the toolkit's resolve options and font plugin to its `vite.config.ts`, so -React, MUI and Emotion come from its own `node_modules`: +The web application adds the toolkit's resolve options, so React, MUI and Emotion come from its +own `node_modules`, and its font and color scheme plugins to its `vite.config.ts`: ```ts -import { font, fontChoice, TOOLKIT_RESOLVE } from '@koszewscy/sk-react-toolkit/vite' +import { colorScheme, font, fontChoice, TOOLKIT_RESOLVE } from '@koszewscy/sk-react-toolkit/vite' import { defineConfig, loadEnv } from 'vite' export default defineConfig(({ mode }) => ({ - plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname)))], + plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname))), colorScheme()], resolve: TOOLKIT_RESOLVE, })) ``` @@ -94,6 +94,7 @@ API types imported by both, without the code of either: `CredentialField`, `Cred | Export | Purpose | | --- | --- | | `font(choice)`, `fontChoice(env)`, `FONTS` | the font plugin of `VITE_FONT`: `cdn` (Google Fonts links, the default), `bundled` (`@fontsource/roboto`, a dependency of the application) or `none` | +| `colorScheme()` | the plugin that sets the color scheme of `appTheme`, chosen in the theme menu or by the system, on the page before its first paint, so a dark page, also one in an iframe, does not show white while it loads | | `TOOLKIT_RESOLVE` | the resolve options that keep one copy of React, MUI and Emotion | ## Credential store @@ -239,6 +240,13 @@ database `postgres`. `npm run build` (`build.mjs`) builds the published package `dist/`: the code compiled to JavaScript with its declarations, the SQL of the server, README.md, CHANGELOG.md and a `package.json` whose exports name the compiled files. +`./test-container.mjs` runs the same checks with Docker, on the engine of the docker CLI's +context, which may be remote: it sends the files git tracks, and the untracked files it does not +ignore, as the build context of `test.Dockerfile`, whose build runs the typecheck, the lint and +the package build, and runs the tests against a scratch PostgreSQL in a container on a private +network, removed afterwards. `--package ` writes the packed toolkit to the folder instead, +for the tests of an application against the checkout. + ## Releases `./release.mjs` commits, tags the version of the newest CHANGELOG.md entry and pushes `main` with diff --git a/package-lock.json b/package-lock.json index 6cd2781..6581969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@koszewscy/sk-react-toolkit", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@koszewscy/sk-react-toolkit", - "version": "0.7.0", + "version": "0.7.1", "devDependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/package.json b/package.json index 23933db..3cdc2f4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@koszewscy/sk-react-toolkit", - "version": "0.7.0", - "description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.", + "version": "0.7.1", + "description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font and color scheme plugins.", "private": true, "type": "module", "engines": { diff --git a/test-container.mjs b/test-container.mjs new file mode 100755 index 0000000..e9fbc6f --- /dev/null +++ b/test-container.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +// Runs the checks of the toolkit with Docker, on the engine of the docker CLI's context: builds test.Dockerfile from +// the files of the checkout git knows, then runs the tests against a scratch PostgreSQL on a private network. + +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +const NAME = 'sk-react-toolkit-test'; +const POSTGRES_IMAGE = 'postgres:18'; + +const usage = `Usage: test-container.mjs [-h|--help] [--package ] + + Builds test.Dockerfile, which runs the typecheck, the lint and the package build, and runs the + tests in a container with a scratch PostgreSQL. The build context is the files git tracks and the + untracked files it does not ignore. + + --package write the packed toolkit, the .tgz file npm pack makes, to the folder instead + of running the tests`; + +const { values } = parseArgs({ + options: { + package: { type: 'string' }, + help: { type: 'boolean', short: 'h', default: false }, + }, +}); + +if (values.help) { + console.log(usage); + process.exit(0); +} + +function fail(message) { + console.error(`test-container: ${message}`); + process.exit(1); +} + +// Runs docker, failing with its message when it cannot start; answers the result. +function docker(args, options = {}) { + const result = spawnSync('docker', args, { stdio: 'inherit', ...options }); + if (result.error) fail(`docker: ${result.error.message}`); + return result; +} + +const files = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: import.meta.dirname, maxBuffer: 1 << 26 }); +if (files.status !== 0) fail(`git ls-files failed: ${files.stderr}`); +// macOS tar adds extended attributes and AppleDouble files, which a Linux engine refuses. +const macOptions = process.platform === 'darwin' ? ['--no-xattrs', '--no-mac-metadata'] : []; +const context = spawnSync('tar', [...macOptions, '--null', '-T', '-', '-cf', '-'], { cwd: import.meta.dirname, input: files.stdout, maxBuffer: 1 << 30 }); +if (context.status !== 0) fail(`tar failed: ${context.stderr}`); + +if (values.package !== undefined) { + const output = `type=local,dest=${resolve(values.package)}`; + const built = docker(['build', '-f', 'test.Dockerfile', '--target', 'package', '--output', output, '-'], { input: context.stdout, stdio: ['pipe', 'inherit', 'inherit'] }); + process.exit(built.status ?? 1); +} + +const built = docker(['build', '-f', 'test.Dockerfile', '--target', 'check', '-t', NAME, '-'], { input: context.stdout, stdio: ['pipe', 'inherit', 'inherit'] }); +if (built.status !== 0) process.exit(built.status ?? 1); + +// Ctrl-C stops the running docker command; the cleanup below still runs. +process.on('SIGINT', () => {}); + +const network = `${NAME}-${process.pid}`; +const database = `${NAME}-pg-${process.pid}`; +let status = 1; +try { + if (docker(['network', 'create', network], { stdio: 'ignore' }).status !== 0) throw new Error(`cannot create the network ${network}`); + const started = docker(['run', '-d', '--rm', '--name', database, '--network', network, '-e', 'POSTGRES_PASSWORD=test', POSTGRES_IMAGE], { stdio: ['ignore', 'ignore', 'inherit'] }); + if (started.status !== 0) throw new Error(`cannot start ${POSTGRES_IMAGE}`); + // The server answers on TCP once its initialization has finished. + let ready = false; + for (let attempt = 0; attempt < 60 && !ready; attempt++) { + ready = docker(['exec', database, 'pg_isready', '-q', '-h', '127.0.0.1', '-U', 'postgres'], { stdio: 'ignore' }).status === 0; + if (!ready) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000); + } + if (!ready) throw new Error('PostgreSQL did not start within 60 seconds'); + const environment = ['PGHOST=' + database, 'PGUSER=postgres', 'PGPASSWORD=test', 'PGDATABASE=postgres'].flatMap((variable) => ['-e', variable]); + status = docker(['run', '--rm', '--network', network, ...environment, NAME]).status ?? 1; +} catch (error) { + console.error(`test-container: ${error.message}`); +} finally { + docker(['rm', '-f', database], { stdio: 'ignore' }); + docker(['network', 'rm', network], { stdio: 'ignore' }); +} +process.exit(status); diff --git a/test.Dockerfile b/test.Dockerfile new file mode 100644 index 0000000..d1ee8e7 --- /dev/null +++ b/test.Dockerfile @@ -0,0 +1,16 @@ +# The toolkit's checks on Linux: the build runs the typecheck, the lint and the package build, the +# container the tests; the package stage holds the packed toolkit for the tests of an application. + +ARG NODE_VERSION=26 + +FROM node:${NODE_VERSION}-trixie-slim AS check +WORKDIR /src +COPY package.json package-lock.json ./ +RUN npm ci --no-fund --no-audit +COPY . . +RUN npm run typecheck && npm run lint && npm run build +RUN mkdir /pack && npm pack ./dist --pack-destination /pack +CMD ["npm", "test"] + +FROM scratch AS package +COPY --from=check /pack/ / diff --git a/vite/index.ts b/vite/index.ts index 11a4e39..c883290 100644 --- a/vite/index.ts +++ b/vite/index.ts @@ -38,6 +38,31 @@ export function font(choice: Font): Plugin { } } +/** + * Sets the color scheme of appTheme on the page before its first paint, so a dark page, and a page in an iframe of a + * dark page, is not white until the application renders: adds to index.html a script that sets the class dark or + * light on , from MUI's mui-mode in localStorage or, for system, from prefers-color-scheme, and a style that + * gives those classes their color-scheme and the dark scheme MUI's dark background. + */ +export function colorScheme(): Plugin { + const script = `(function () { + try { + var mode = localStorage.getItem('mui-mode') || 'system'; + var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); + document.documentElement.classList.add(dark ? 'dark' : 'light'); + } catch (e) {} +})();` + return { + name: 'color-scheme', + transformIndexHtml() { + return [ + { tag: 'style', children: 'html.light { color-scheme: light; }\nhtml.dark { color-scheme: dark; background-color: #121212; }', injectTo: 'head' }, + { tag: 'script', children: script, injectTo: 'head' }, + ] + }, + } +} + /** VITE_FONT of env, the variables Vite's loadEnv answers, cdn when it is not set; another value throws. */ export function fontChoice(env: Record): Font { const choice = env.VITE_FONT ?? 'cdn'