Files
lockbitchat 9e63cf65a4 v6.8.0: light theme
The palette lived as ~620 hex literals in inline styles plus a few hundred more in
the stylesheets, so there was no single thing to change. It is now 113 custom
properties in src/styles/theme.css, in two blocks.

src/scripts/theme-boot.js decides the theme before first paint — blocking, in <head>,
above the stylesheet, because a deferred script paints dark first and corrects itself.
It stores the mode ('system' | 'light' | 'dark'), never the colour it resolved to, and
stamps data-theme so an explicit choice can beat the media query. The switcher in the
header is a view onto it.

A filled accent stays the brand colour in both themes — the ink on it is near-black
either way — while an accent used as text darkens to clear 4.5:1 on white. A colour
reaches a fill by four routes (a style property, a constant, a helper argument, an SVG
source string), and tests/theme-switching.test.mjs covers all four.

The dark theme is unchanged: every colour declaration the previous build produced comes
out of this one identically once the properties are resolved.

Also: the roadmap drops its status chips on mobile, and Roadmap.jsx no longer splits a
colour with parseInt at runtime, which a var() reference cannot survive.
2026-09-04 17:38:41 -04:00

97 lines
4.1 KiB
JavaScript

/**
* build-css.js — concatenates every render-blocking stylesheet into one file.
*
* The page used to link eight of them: the Tailwind build, the Inter @font-face
* declarations, five hand-written sheets, and pwa.css at the end of <body>. Together
* they are about 21 KB compressed, which is nothing — but each one is a separate
* request that has to complete before the browser paints, and on a throttled mobile
* connection each cost between 400 and 900 ms of latency for a file of 1 to 7 KB.
* GTmetrix put the whole thing at 441 ms of render-blocking time, spent almost
* entirely on round trips rather than bytes.
*
* Order is the one thing this must not get wrong. The sheets are written to argue with
* each other on source order: components.css settles layout, apple-motion.css then
* overrides press feedback and reduced-motion behaviour on top of it, and rtl.css has
* to win over everything because its rules only apply under [dir="rtl"]. Concatenating
* in the same sequence the <link> tags had preserves that exactly — the cascade cannot
* tell the difference between eight files and one.
*
* The only rewriting done here is url(): inter.css refers to its woff2 files relatively,
* and those paths are resolved against the sheet's own directory before it moves.
*/
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'assets', 'app.css');
// The exact sequence the <link> tags had, head first and pwa.css last.
const SHEETS = [
'assets/tailwind.css',
// Font Awesome, cut to the 82 icons this interface draws (scripts/subset-icons.py).
// The full sheet was 102 KB and loaded asynchronously to keep it off the critical
// path; at 7 KB it is cheaper to have it here than to spend a request on it, and
// icons stop arriving a beat after the text.
'assets/fontawesome/css/subset.css',
'assets/fonts/inter/inter.css',
// First of the hand-written sheets: it declares the custom properties every other
// sheet and every inline style in the app reads, so it has to be in the cascade
// before anything that uses one.
'src/styles/theme.css',
'src/styles/main.css',
'src/styles/animations.css',
'src/styles/components.css',
'src/styles/apple-motion.css',
'src/styles/rtl.css',
'src/styles/pwa.css',
];
/**
* Make every relative url() root-absolute, resolved against the sheet it came from.
* Absolute paths, full URLs and data: URIs are already correct and left alone.
*/
function absolutizeUrls(css, sheetPath) {
const dir = path.posix.dirname(`/${sheetPath}`);
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (whole, quote, target) => {
if (/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(target.trim())) return whole;
return `url(${quote}${path.posix.join(dir, target.trim())}${quote})`;
});
}
function build() {
const parts = [];
for (const sheet of SHEETS) {
const file = path.join(ROOT, sheet);
if (!fs.existsSync(file)) {
throw new Error(`stylesheet listed in build-css.js is missing: ${sheet}`);
}
parts.push(`/* ${sheet} */\n${absolutizeUrls(fs.readFileSync(file, 'utf8'), sheet)}`);
}
const combined = parts.join('\n\n');
// esbuild minifies from stdin so nothing intermediate has to be written to disk.
const minified = execFileSync(
'npx',
['--no-install', 'esbuild', '--loader=css', '--minify'],
{ input: combined, encoding: 'utf8', cwd: ROOT, maxBuffer: 32 * 1024 * 1024 }
);
fs.writeFileSync(OUT, minified, 'utf8');
return { bytes: Buffer.byteLength(minified), sheets: SHEETS.length };
}
if (require.main === module) {
try {
const { bytes, sheets } = build();
console.log('🎨 Bundling stylesheets...');
console.log(` ✅ assets/app.css — ${sheets} sheets, ${bytes.toLocaleString('en-US')} bytes`);
} catch (error) {
console.error('❌ css build failed:', error.message);
process.exit(1);
}
}
module.exports = { build, SHEETS };