1
0

Front-end fixes

This commit is contained in:
2026-03-24 11:49:05 +01:00
parent b620d8eee6
commit 2a87ec67af
6 changed files with 125 additions and 43 deletions

3
.gitignore vendored
View File

@@ -42,5 +42,8 @@ tests/e2e/test_firmware.zip
# NickelMenu build artifacts
nickelmenu/kobo-config/
# Proposals
proposals/
# Claude
.claude

View File

@@ -1150,3 +1150,57 @@ test.describe('Custom patches', () => {
await expect(page.locator('#step-connect')).not.toBeHidden();
});
});
// ============================================================
// Build output
// ============================================================
test.describe('Build output', () => {
const path = require('path');
const distDir = path.join(__dirname, '..', '..', 'web', 'dist');
test('CSS cache-bust hash is present on style.css link', async () => {
const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf-8');
expect(html).toMatch(/css\/style\.css\?h=[0-9a-f]{8}/);
});
test('JS cache-bust hash is present on bundle.js script', async () => {
const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf-8');
expect(html).toMatch(/bundle\.js\?h=[0-9a-f]{8}/);
});
test('critical CSS is inlined with :root tokens', async () => {
const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf-8');
// :root block should be inside an inline <style> tag, not in a <link>
expect(html).toMatch(/<style>[^<]*:root\{[^}]*--primary:/);
// var() references should be used (not hardcoded hex colors for themed values)
expect(html).toMatch(/<style>[^<]*var\(--primary\)/);
});
test('style.css does not contain a :root block', async () => {
const css = fs.readFileSync(path.join(distDir, 'css', 'style.css'), 'utf-8');
expect(css).not.toContain(':root');
});
test('--primary-hover differs from --primary', async () => {
const critical = fs.readFileSync(
path.join(__dirname, '..', '..', 'web', 'src', 'css', 'critical.css'), 'utf-8'
);
const primary = critical.match(/--primary:\s*([^;]+);/);
const hover = critical.match(/--primary-hover:\s*([^;]+);/);
expect(primary).not.toBeNull();
expect(hover).not.toBeNull();
expect(primary[1].trim()).not.toBe(hover[1].trim());
});
test('no jszip script tag in built HTML', async () => {
const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf-8');
expect(html).not.toContain('jszip');
});
test('no unreplaced template placeholders in built HTML', async () => {
const html = fs.readFileSync(path.join(distDir, 'index.html'), 'utf-8');
expect(html).not.toMatch(/\{\{[\w-]+\}\}/);
expect(html).not.toContain('@critical-css');
});
});

View File

@@ -102,6 +102,18 @@ async function build() {
let html = readFileSync(join(srcDir, 'index.html'), 'utf-8');
// Inline critical.css into the <head> so :root tokens and loading styles
// are available before style.css arrives on slow connections.
const criticalCss = readFileSync(join(srcDir, 'css', 'critical.css'), 'utf-8');
const { code: criticalMinified } = await esbuild.transform(criticalCss, {
loader: 'css',
minify: !isDev && !isWatch,
});
html = html.replace(
'<!-- @critical-css -->',
`<style>${criticalMinified.trimEnd()}</style>`
);
// Remove all <script src="js/..."> tags
html = html.replace(/\s*<script src="js\/[^"]*"><\/script>\n/g, '');
// Add the bundle script before </body>
@@ -112,7 +124,7 @@ async function build() {
// Update CSS cache bust
html = html.replace(
/css\/style\.css\?[^"]*/,
/css\/style\.css(?:\?[^"]*)?/,
`css/style.css?h=${cssHash}`
);

46
web/src/css/critical.css Normal file
View File

@@ -0,0 +1,46 @@
/*
* Critical CSS — inlined into index.html at build time.
*
* Contains the :root design tokens and the minimal styles needed to render
* the hero header and loading spinner before style.css arrives. Because this
* is inlined, var() references resolve immediately.
*
* This is the single source of truth for all CSS custom properties.
* style.css uses these variables but does not redefine them.
*/
:root {
--bg: #f5f5f7;
--card-bg: #fff;
--border: #d1d5db;
--border-light: #e5e7eb;
--text: #111827;
--text-secondary: #6b7280;
--primary: #01a7c4;
--primary-hover: #018da6;
--primary-light: #eff6ff;
--error-bg: #fef2f2;
--error-border: #fca5a5;
--error-text: #991b1b;
--warning-bg: #fffbeb;
--warning-border: #fcd34d;
--warning-text: #92400e;
--success-bg: #f0fdf4;
--success-border: #86efac;
--success-text: #166534;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
}
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; margin: 0; }
main { max-width: 640px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
.hero { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-light); }
h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
.hero-accent { color: var(--primary); font-weight: 600; }
.beta-pill { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; background: var(--primary); color: #fff; padding: 0.35rem 0.7rem; border-radius: 10px; vertical-align: middle; position: relative; top: -0.15rem; margin-left: 5px; }
.subtitle { color: var(--text-secondary); font-size: 0.95rem; margin-top: 0.25rem; }
.initial-loader { display: flex; align-items: center; justify-content: center; gap: 0.75rem; padding: 2rem 0; }
.initial-loader p { margin: 0; font-size: 0.93rem; color: var(--text-secondary); }
.spinner { width: 22px; height: 22px; border: 2.5px solid var(--border-light); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
[hidden] { display: none !important; }

View File

@@ -6,41 +6,21 @@
padding: 0;
}
:root {
--bg: #f5f5f7;
--card-bg: #fff;
--border: #d1d5db;
--border-light: #e5e7eb;
--text: #111827;
--text-secondary: #6b7280;
--primary: #01a7c4;
--primary-hover: #01a7c4;
--primary-light: #eff6ff;
--error-bg: #fef2f2;
--error-border: #fca5a5;
--error-text: #991b1b;
--warning-bg: #fffbeb;
--warning-border: #fcd34d;
--warning-text: #92400e;
--success-bg: #f0fdf4;
--success-border: #86efac;
--success-text: #166534;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
button, input, select, textarea {
font-family: inherit;
}
/* Design tokens (:root variables) are defined in critical.css, which is
inlined into <head> at build time — do not redefine them here. */
body {
font-family: "Google Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif !important;
font-family: "Google Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
button {
font-family: "Google Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif !important;
}
main {
max-width: 640px;
margin: 0 auto;
@@ -863,6 +843,7 @@ input[type="file"] {
flex-shrink: 0;
}
/* Also defined in critical.css (inlined into <head>) for the pre-CSS loading spinner */
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -55,23 +55,9 @@
<link rel="preload" href="/patches/downloads.json" as="fetch" crossorigin>
<link rel="preload" href="/patches/index.json" as="fetch" crossorigin>
<!-- Inline critical styles so the hero + loader render before style.css arrives on slow connections -->
<style>
body { background: #f5f5f7; color: #111827; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; margin: 0; }
main { max-width: 640px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
.hero { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid #e5e7eb; }
h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; margin: 0; }
.hero-accent { color: #00bedf; font-weight: 600; }
.beta-pill { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; background: #00bedf; color: #fff; padding: 0.35rem 0.7rem; border-radius: 10px; vertical-align: middle; position: relative; top: -0.15rem; margin-left: 5px; }
.subtitle { color: #6b7280; font-size: 0.95rem; margin-top: 0.25rem; }
.initial-loader { display: flex; align-items: center; justify-content: center; gap: 0.75rem; padding: 2rem 0; }
.initial-loader p { margin: 0; font-size: 0.93rem; color: #6b7280; }
.spinner { width: 22px; height: 22px; border: 2.5px solid #e5e7eb; border-top-color: #00bedf; border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
[hidden] { display: none !important; }
</style>
<!-- Inlined at build time from css/critical.css so the hero + loader render before style.css arrives -->
<!-- @critical-css -->
<link rel="stylesheet" href="css/style.css">
<script src="js/jszip.min.js"></script>
</head>
<body>
<main>