fix: navigation blink root-caused — defer search-client.js (was parser-blocking mid-body), npm run demo serves the production build through the tunnel; DESIGN.md documents the VT removal + font-swap decision

This commit is contained in:
ctao
2026-07-25 17:48:48 +02:00
parent c9161af655
commit 1ef2cab89e
4 changed files with 41 additions and 5 deletions
+30 -1
View File
@@ -45,6 +45,16 @@ Type scale — the ONLY font sizes in the codebase (no ad-hoc rem values):
Always end `font-family` with a generic (`sans-serif`) — lint requirement. Always end `font-family` with a generic (`sans-serif`) — lint requirement.
Font loading (decided after the navigation-blink incident): self-hosted latin
woff2, preloaded in `Base.astro`, `font-display: swap` + metric-matched Arial
fallbacks for BOTH families (`Inter Fallback`, `Space Grotesk Fallback` —
size-adjust/ascent/descent computed from the real font metrics, capsize
method). Never `optional`: its ~100ms window loses to per-navigation
revalidation (dev/preview serve `Cache-Control: no-cache`), which randomly
committed whole pageviews to Arial — the "fonts flash between navigations"
bug. With swap + matched metrics every page converges on brand fonts and the
swap is layout-neutral.
## Spacing scale (fluid; mobile ≈ min, desktop ≈ max) ## Spacing scale (fluid; mobile ≈ min, desktop ≈ max)
| Token | Value | Use | | Token | Value | Use |
@@ -122,6 +132,12 @@ Rules:
- ALL `animation`/`transition` declarations live inside the single - ALL `animation`/`transition` declarations live inside the single
`@media (prefers-reduced-motion: no-preference)` block at the end of `@media (prefers-reduced-motion: no-preference)` block at the end of
global.css; the `reduce` block additionally force-disables everything. global.css; the `reduce` block additionally force-disables everything.
- NO cross-document view transitions (`@view-transition`). The root crossfade
snapshots the incoming page at its first render opportunity — on slow
networks that is a half-parsed page, so every navigation read as a
full-page blink (the diagnosed symptom); scrolled→top navigations crossfade
two unrelated frames even when it works. Navigation feel comes from static
HTML + Astro prefetch, not from animating the swap.
- Animate `transform`/`opacity` only (compositor-only); never - Animate `transform`/`opacity` only (compositor-only); never
background-position/box-shadow in keyframes. No `will-change`. No background-position/box-shadow in keyframes. No `will-change`. No
`filter: blur` on large layers and no `background-attachment: fixed` `filter: blur` on large layers and no `background-attachment: fixed`
@@ -276,6 +292,15 @@ inventing a section order. The navy band is reserved for `/` and `/login`.
caps, aria-hidden; 26px Galaxy) ABOVE the label — never instead of it. caps, aria-hidden; 26px Galaxy) ABOVE the label — never instead of it.
Glyphs are wayfinding, not decoration. Glyphs are wayfinding, not decoration.
## Serving the demo
Phone/tunnel demos run the **production build**: `npm run demo`
(= `astro build && astro preview`, same port 4321 so the tunnel URL keeps
working). `astro dev` through the tunnel adds HMR + dev-toolbar module
traffic and serves everything `no-cache` — every navigation re-negotiates
assets over the tunnel, which reads as blinking/slow paints that do NOT
exist in the built site. Dev mode is for local editing only.
## Do not ## Do not
- No new hues, tints, or grays — derive via `color-mix` from brand tokens only. - No new hues, tints, or grays — derive via `color-mix` from brand tokens only.
@@ -283,7 +308,11 @@ inventing a section order. The navy band is reserved for `/` and `/login`.
Typography); headline weight stays 500 (brand D.3.4). Typography); headline weight stays 500 (brand D.3.4).
- No font sizes outside the type-scale tokens; no ad-hoc spacing/shadow/radius. - No font sizes outside the type-scale tokens; no ad-hoc spacing/shadow/radius.
- No animation/transition outside the `no-preference` block; no JS motion; - No animation/transition outside the `no-preference` block; no JS motion;
no transition durations/easings outside `--dur`/`--ease`. no transition durations/easings outside `--dur`/`--ease`; no
`@view-transition` (see Motion policy).
- No parser-blocking `<script>` in the body — enhancement scripts are
`defer`/`type="module"` (a sync script stalls rendering below the header
on every navigation).
- No dependencies/frameworks (CSS or JS). No removal of mock disclosure at - No dependencies/frameworks (CSS or JS). No removal of mock disclosure at
interaction points. No phase banner — do not reintroduce it. interaction points. No phase banner — do not reintroduce it.
- No Cherenkov outside: primary CTA, flash, hero subtitle, logo cluster - No Cherenkov outside: primary CTA, flash, hero subtitle, logo cluster
+1
View File
@@ -7,6 +7,7 @@
"dev": "astro dev --port 4321 --host", "dev": "astro dev --port 4321 --host",
"build": "astro build", "build": "astro build",
"preview": "astro preview --port 4321 --host", "preview": "astro preview --port 4321 --host",
"demo": "astro build && astro preview --port 4321 --host",
"cms-proxy": "PORT=8082 decap-server" "cms-proxy": "PORT=8082 decap-server"
}, },
"dependencies": { "dependencies": {
+7 -2
View File
@@ -107,8 +107,13 @@ const links = [
</details> </details>
</div> </div>
</header> </header>
<script is:inline src="/search-client.js"></script> {/* Search enhancement is deferred — a synchronous script here would stall
<script is:inline> the parser mid-body on every navigation (blank below the header until
it arrives). `defer` + the inline `type="module"` init join the same
after-parse in-order queue (HTML spec), so newsSearch is defined first.
The forms are plain GET /search, so nothing user-facing waits on JS. */}
<script is:inline src="/search-client.js" defer></script>
<script is:inline type="module">
// Header search suggestions (desktop field + mobile reveal); the forms // Header search suggestions (desktop field + mobile reveal); the forms
// are plain GET /search, so everything below is progressive enhancement. // are plain GET /search, so everything below is progressive enhancement.
newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion', dismiss: true }); newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
+3 -2
View File
@@ -24,8 +24,9 @@ import Base from '../layouts/Base.astro';
</section> </section>
</Base> </Base>
{/* search-client.js is loaded once by the Base layout (header search) */} {/* search-client.js is loaded once (deferred) by the Base layout; this inline
<script is:inline> module runs after it in the shared after-parse queue — order guaranteed. */}
<script is:inline type="module">
const run = newsSearch({ input: 'q', list: 'search-results', status: 'search-status', limit: 20, hint: 'Type at least 2 characters.' }); const run = newsSearch({ input: 'q', list: 'search-results', status: 'search-status', limit: 20, hint: 'Type at least 2 characters.' });
// Support /search?q=… deep links (hero search form, footer, bookmarks) // Support /search?q=… deep links (hero search form, footer, bookmarks)
const q0 = new URLSearchParams(location.search).get('q'); const q0 = new URLSearchParams(location.search).get('q');