From ea9d8ad39c09b5a5a21257896a4fc680ba267d70 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 15:50:36 +0000 Subject: [PATCH 1/2] Close the PWA first-paint gap: ETag revalidation + inlined critical CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopening the installed PWA on a slow connection showed a blank white screen between the OS splash and the loading screen. Two fixes: - server/routes.py: every HTML page route now sends an ETag and honors If-None-Match, so a repeat load (e.g. reopening at the PWA's start_url) revalidates with a cheap 304 instead of re-fetching the whole document under the existing Cache-Control: no-cache. - scripts/build_assets.mjs / server/security.py: critical.css is now inlined into dist/index.html in prod, removing the extra network round trip that previously gated first paint of the #loading screen. This stays CSP-compliant (no unsafe-inline) via a content-pinned CSP hash: the build writes the inlined content's sha256 to dist/csp.json, and the security middleware allows exactly that hash in style-src. Dev is unaffected — critical.css stays an external there. tests/assets_test.py and docs/ASSET_PIPELINE.md updated for the new contract; verified with a real prod build (dist/), a bare TestClient checking the CSP header + 304 behavior, and a Playwright screenshot at the mobile viewport confirming the dice loader paints under the new CSP. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GiXTGVkJ1yut7i8DUGLwxi --- docs/ASSET_PIPELINE.md | 6 +++-- scripts/build_assets.mjs | 45 +++++++++++++++++++++++++++++--------- server/routes.py | 47 +++++++++++++++++++++++++--------------- server/security.py | 39 ++++++++++++++++++++++++++++----- tests/assets_test.py | 35 +++++++++++++++++++++--------- 5 files changed, 127 insertions(+), 45 deletions(-) diff --git a/docs/ASSET_PIPELINE.md b/docs/ASSET_PIPELINE.md index bafbf9ef..3e2ca2ff 100644 --- a/docs/ASSET_PIPELINE.md +++ b/docs/ASSET_PIPELINE.md @@ -16,9 +16,9 @@ All of it runs in `scripts/build_assets.mjs`, in the Docker builder stage, befor 2. The 38 JS modules (37 in the runtime import graph — `types.js` is JSDoc-only) get bundled into a single file with esbuild. `minify: true` handles identifier mangling, syntax compression, and whitespace removal, including collapsing the newlines in HTML template strings that esbuild normally leaves alone. Asset references get rewritten, then the whole thing gets content-hashed. -3. The 14 CSS files get concatenated and run through esbuild's CSS transformer. Non-critical styles (everything except `critical.css`) merge into a single `app.css`. Both get content-hashed. +3. The 14 CSS files get concatenated and run through esbuild's CSS transformer. Non-critical styles (everything except `critical.css`) merge into a single, content-hashed `app.css`. `critical.css` itself is minified but *not* fingerprinted as a file — it's inlined straight into `index.html` (step 4) so first paint (the inline `#loading` screen) doesn't wait on a second network round trip beyond the document itself. Its sha256 is written to `dist/csp.json`. -4. `index.html` gets rewritten: the 13 non-critical `` tags collapse to one, the modulepreload graph (37 entries) drops, every URL swaps to its hashed equivalent, and the HTML gets stripped of comments and collapsed to a single line. The web-app manifest is fingerprinted the same pass (`manifest.webmanifest`, step 4b): its icon refs are rewritten to hashed paths and its `` href updated to match. +4. `index.html` gets rewritten: the critical.css `` becomes an inline ``, +); // collapse the 9 non-critical stylesheet links into one bundled link html = html.replace( @@ -199,6 +219,11 @@ writeFileSync(join(DIST, 'index.html'), html); writeFileSync(join(DIST, 'manifest.json'), JSON.stringify(Object.fromEntries(manifest), null, 1)); +// ── 7b. CSP hash for the inlined critical.css ", html, re.S) + check(style_match is not None, "index.html has an inline `, + /