You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Lighthouse CI reports CLS 0.975 on the homepage — one of the worst scores possible. This is the single dominant performance issue (weight 25 in the overall score) and the only thing keeping the homepage at Performance 0.76 instead of ≥ 0.9.
Accessibility is already at 1.0 after #431, so CLS is now the last blocker for a green Lighthouse CI.
Root cause
The site is a Vite SPA. The built dist/index.html ships with:
On first paint, #app is an empty <div> with zero height. Total body height at first paint is near zero. Once the JS module loads and runs, website/src/main.js populates #app in one shot:
Between frame N (empty #app) and frame N+1 (#app populated with header + page-content + footer), the entire visible page appears from nothing. Chrome/Lighthouse records this as a massive layout shift of body > div#app > div#page-content with a shift score of ~0.975 — effectively the full viewport moved one full viewport-height.
Why the previous attempt didn't work
In #431 I added #page-content { min-height: calc(100vh - 12rem) } thinking the issue was the card grid growing inside #page-content. That was the wrong diagnosis. min-height only kicks in after#page-content exists in the DOM, which is after JS has already run. The shift happens before that rule can apply, during the empty-to-populated transition of #app itself.
Why the other perf audits are already fine
Lighthouse confirms:
First Contentful Paint: 0.99
Largest Contentful Paint: 1.0
Total Blocking Time: 1.0
Speed Index: 1.0
Server response time: 1.0
Bootup time, main thread work, DOM size, etc.: all 1.0
Only CLS is failing. Everything else is green.
Fix: bake the page shell into the static HTML
The cleanest fix is to ensure #app is not empty on first paint. Instead of letting JS wipe and replace #app on boot, the static HTML should already contain:
The header (deterministic — no data needed beyond i18n defaults)
A #page-content div sized to the expected viewport
The footer (deterministic)
When JS boots, it enhances/rebinds the existing DOM instead of replacing it. No DOM nodes appear from nothing, so no layout shift.
Implementation plan
Step 1: Pre-render the homepage shell during build
Extend scripts/prerender-routes.js to also produce a "shell variant" of dist/index.html. Unlike the doc routes (where we inject a specific AsciiDoc fragment), the homepage shell injects:
A header HTML snippet matching what renderHeader() outputs at build time (English defaults, default theme)
<div id=\"page-content\" style=\"min-height: calc(100vh - 4rem)\"></div> — reserves vertical space
A footer HTML snippet matching renderFooter(APP_VERSION)
The key insight is that the header/footer are deterministic in English at default theme — they can be serialized at build time.
// If the shell is already in the DOM (pre-rendered), leave it in place// and only replace what's dynamic (header+footer re-render for language switch,// page-content for route handling).if(!document.getElementById('page-content')){app.innerHTML=`${renderHeader()}<div id=\"page-content\"></div>${renderFooter(...)}`}
This way:
Pre-rendered page load: shell is already there. Route handler runs and populates #page-content with the actual route content. No DOM shuffling of the outer structure.
Dev server / non-prerendered load: falls back to current behavior.
Step 3: Handle language/theme switches
When the user switches language or theme after boot, the existing applyTranslations() already updates data-i18n attributes in place. The header might need a re-render only if the HTML structure differs between languages — need to verify. Theme is CSS-only.
If the header genuinely needs re-rendering for language switches, use a targeted replacement (document.querySelector('header').outerHTML = renderHeader()) instead of wiping #app.
Step 4: Pre-rendered doc routes also benefit
The current prerender-routes.js injects content into #app for doc routes but the same empty-to-populated problem affects them too on the SPA transition. Changing main.js to be "enhance if already rendered" fixes this for all pre-rendered routes simultaneously.
Expected outcome
Homepage CLS: 0.975 → ~0.00 (target ≤ 0.1)
Performance: 0.76 → ≥ 0.9 (should exceed the threshold since CLS was the only weighted failure)
Lighthouse CI green
No user-visible change (SPA UX is identical; crawlers and no-JS fetchers additionally get full HTML instead of an empty shell)
curl https://llm-coding.github.io/Semantic-Anchors/ | grep -c \"semantic-anchor\\|nav-link\" returns > 0 (content is in the initial HTML)
Visual inspection: homepage loads without flicker on slow 3G simulation
E2E tests still pass (89/89 unit + all Playwright)
Pre-rendered doc routes (/workflow, /about, etc.) also benefit from the main.js "enhance" change
Post-deploy Lighthouse CI shows CLS ≤ 0.1 and Performance ≥ 0.9
Alternatives considered
Lower the Lighthouse thresholds — quick fix, hides the real UX problem. Rejected: CLS 0.975 is genuinely bad for users, not just for the CI score.
Delay JS bundle with defer — doesn't help. The empty-to-populated transition still happens; just later.
CSS-only: min-height: 100vh on #app — reserves body height, but the shift inside #app (nothing → header + content + footer appearing) is still counted. Tried in fix: Lighthouse a11y + CLS issues on homepage #431 on #page-content, didn't work.
Full SSR with a framework (Astro, Next.js, etc.) — correct long-term direction but way out of scope for this fix. The static pre-render approach above is ~90% as effective with much less churn.
Problem
Lighthouse CI reports CLS 0.975 on the homepage — one of the worst scores possible. This is the single dominant performance issue (weight 25 in the overall score) and the only thing keeping the homepage at Performance 0.76 instead of ≥ 0.9.
Accessibility is already at 1.0 after #431, so CLS is now the last blocker for a green Lighthouse CI.
Root cause
The site is a Vite SPA. The built
dist/index.htmlships with:On first paint,
#appis an empty<div>with zero height. Total body height at first paint is near zero. Once the JS module loads and runs,website/src/main.jspopulates#appin one shot:Between frame N (empty
#app) and frame N+1 (#apppopulated with header + page-content + footer), the entire visible page appears from nothing. Chrome/Lighthouse records this as a massive layout shift ofbody > div#app > div#page-contentwith a shift score of ~0.975 — effectively the full viewport moved one full viewport-height.Why the previous attempt didn't work
In #431 I added
#page-content { min-height: calc(100vh - 12rem) }thinking the issue was the card grid growing inside#page-content. That was the wrong diagnosis.min-heightonly kicks in after#page-contentexists in the DOM, which is after JS has already run. The shift happens before that rule can apply, during the empty-to-populated transition of#appitself.Why the other perf audits are already fine
Lighthouse confirms:
Only CLS is failing. Everything else is green.
Fix: bake the page shell into the static HTML
The cleanest fix is to ensure
#appis not empty on first paint. Instead of letting JS wipe and replace#appon boot, the static HTML should already contain:#page-contentdiv sized to the expected viewportWhen JS boots, it enhances/rebinds the existing DOM instead of replacing it. No DOM nodes appear from nothing, so no layout shift.
Implementation plan
Step 1: Pre-render the homepage shell during build
Extend
scripts/prerender-routes.jsto also produce a "shell variant" ofdist/index.html. Unlike the doc routes (where we inject a specific AsciiDoc fragment), the homepage shell injects:renderHeader()outputs at build time (English defaults, default theme)<div id=\"page-content\" style=\"min-height: calc(100vh - 4rem)\"></div>— reserves vertical spacerenderFooter(APP_VERSION)The key insight is that the header/footer are deterministic in English at default theme — they can be serialized at build time.
Step 2: Make
main.jsenhance instead of replaceChange the bootstrap in
website/src/main.jsfrom:to:
This way:
#page-contentwith the actual route content. No DOM shuffling of the outer structure.Step 3: Handle language/theme switches
When the user switches language or theme after boot, the existing
applyTranslations()already updatesdata-i18nattributes in place. The header might need a re-render only if the HTML structure differs between languages — need to verify. Theme is CSS-only.If the header genuinely needs re-rendering for language switches, use a targeted replacement (
document.querySelector('header').outerHTML = renderHeader()) instead of wiping#app.Step 4: Pre-rendered doc routes also benefit
The current
prerender-routes.jsinjects content into#appfor doc routes but the same empty-to-populated problem affects them too on the SPA transition. Changingmain.jsto be "enhance if already rendered" fixes this for all pre-rendered routes simultaneously.Expected outcome
Test plan
dist/index.htmlwith non-empty#appcontaining header + page-content + footercurl https://llm-coding.github.io/Semantic-Anchors/ | grep -c \"semantic-anchor\\|nav-link\"returns > 0 (content is in the initial HTML)/workflow,/about, etc.) also benefit from themain.js"enhance" changeAlternatives considered
Lower the Lighthouse thresholds — quick fix, hides the real UX problem. Rejected: CLS 0.975 is genuinely bad for users, not just for the CI score.
Delay JS bundle with
defer— doesn't help. The empty-to-populated transition still happens; just later.CSS-only:
min-height: 100vhon#app— reserves body height, but the shift inside#app(nothing → header + content + footer appearing) is still counted. Tried in fix: Lighthouse a11y + CLS issues on homepage #431 on#page-content, didn't work.Full SSR with a framework (Astro, Next.js, etc.) — correct long-term direction but way out of scope for this fix. The static pre-render approach above is ~90% as effective with much less churn.
Related
scripts/prerender-routes.jsfor doc routes; this issue extends the same approach to the homepage