Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions _TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,65 @@ https://aws.plainenglish.io/how-to-build-a-chatbot-using-aws-lex-and-lambda-in-2

## Performance Issues

### FOUC / Setting Theme Styling

Yes, the concern is legitimate. A theme flash is real, especially when the default HTML ships as light and the user actually wants dark or a custom theme. But the current solution is too expensive: it avoids a theme flash by forcing a blank-screen flash instead, which is worse for both users and Lighthouse.

The important distinction is this:

Preventing the wrong theme from painting is a good goal.

Hiding the entire body until DOMContentLoaded is an overcorrection.

In your current setup, BaseLayout.astro:140 hides the page, and ThemeInit.astro:105 waits until DOMContentLoaded on first load before removing that hidden state. That means the browser is ready to paint, but your code tells it not to.

The better pattern is to set the theme before first paint, not to suppress paint. Usually that means:

- Put a very small inline script in <head>.
- That script runs immediately, not on DOMContentLoaded.
- It only does the minimum: read localStorage if available, fall back to matchMedia('(prefers-color-scheme: dark)'), and set document.documentElement.dataset.theme.
- Do not touch body at all.
- Do not wait for the DOM to finish parsing.

That works because document.documentElement already exists while the head is parsing. You do not need the body to exist in order to set the theme token that your CSS reads.

There are also better structural ways to reduce or eliminate mismatch:

For explicit user theme choices, store the selected theme in a cookie, not only localStorage. Then the server can render the correct data-theme on the initial HTML. This is the strongest fix.

For plain light/dark system preference, use CSS prefers-color-scheme where possible. That lets the browser choose the right theme without JavaScript.

If you need multiple custom themes, use a hybrid:

- server reads a cookie for explicit chosen theme
- client falls back to a tiny head script for first-time visitors using system preference
Add <meta name="color-scheme" content="light dark"> so browser UI elements align better during startup.

The practical conclusion is: your concern is valid, but hiding the page is not the right tradeoff. The usual fix is a two-phase approach:

- Pre-paint phase: tiny inline head script sets the html theme immediately.
- Post-load phase: optional richer theme logic updates meta color, event listeners, analytics, stores, and transition behavior.

That gives you the theme correctness you want without sacrificing first paint.

If you want, I can sketch the safest refactor path next without changing code yet, or I can go implement the minimal pre-paint split directly.

Implemented the pre-paint theme fix.

The change is in three places. In src/components/Head/index.astro, the theme init script now runs after the head metadata is emitted but still before the body is parsed. In src/components/Head/ThemeInit.astro, the script now resolves the theme and writes data-theme to `<html>` immediately, without waiting for DOMContentLoaded and without touching body visibility. In src/layouts/BaseLayout.astro, the invisible class was removed from `<body>`.

This keeps the legitimate part of the old design, setting the correct theme before first paint, but removes the blank-screen gate that was delaying FCP and LCP. The script still populates window.metaColors, still updates the theme-color meta tag on initial load, and still handles Astro view transitions.

## Secondary Contributors

- The page is also heavier than it needs to be on first load: Lighthouse shows 85 requests and about 676 KiB transferred. The biggest buckets are 50 script requests / 194 KiB, 25 image requests / 287 KiB, and 3 font requests / 119 KiB.

- The homepage in index.astro pulls in a lot of sections at once. The report shows several carousel cover images loading during initial navigation, and those covers come from index.astro:137.

- The Backstage section also ships a large homepage image from index.astro:39, and that request is the single largest network item in the report at about 120 KiB.

- Fonts are not the main regression because fonts.css:40 uses font-display: swap, but they still add noticeable startup weight.

### Home page size

- Audit the homepage hydration/chunk fan-out after prerendering. The 22 JS chunks suggest too much client code is shipping for a marketing landing page.
Expand Down
86 changes: 24 additions & 62 deletions src/components/Head/ThemeInit.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@
/**
* Theme Initialization - Inline Script
*
* Set data-theme attribute on <html> and the <meta theme-color> element
* from localStorage, prefers-color-scheme as a fallback, or sets a default.
* Set data-theme on <html> as early as possible so the first paint uses the
* correct theme, then sync supporting metadata for the active theme.
*
* CRITICAL: This script MUST be inlined (is:inline) and run as early as possible
* to prevent Flash of Unstyled Content (FOUC).
* CRITICAL: This script MUST be inlined (is:inline) and run before the body is
* parsed to avoid flashing the default light theme for dark or custom themes.
*
* !!! DO NOT EXPAND THE SCOPE OF THIS SCRIPT OR MOVE OUT OF THIS FILE !!!
*
* It runs synchronously and blocks rendering.
*/
import { themeData } from './server/themeData'

Expand Down Expand Up @@ -41,72 +39,37 @@ const { defaultThemeIdJson, darkThemeIdJson, metaColorsJson } = themeData()
targetDocument.documentElement.dataset['theme'] = themeId
}

const applyThemeMetaColor = (targetDocument, themeId) => {
const metaElement = targetDocument.querySelector('meta[name="theme-color"]')
if (metaElement && window.metaColors) {
metaElement.setAttribute('content', window.metaColors[themeId] || '')
}
}

const runThemeInit = () => {
/**
* This code is only for initial page load from the site. DOMContentLoaded does
* not fire when using the Astro View Transitions API for page navigation, so
* the themes store defines a side effect to set up an event listener for the
* "astro:before-swap" event.
*/
try {
const defaultThemeId = JSON.parse(defaultThemeIdJson)
const darkThemeId = JSON.parse(darkThemeIdJson)
const themeMetaColors = JSON.parse(metaColorsJson)

/** 1. Read theme preference from localStorage (set by user's previous selection) */
const stored = localStorage.getItem('theme')
const resolvedThemeId = getResolvedThemeId({ defaultThemeId, darkThemeId })
const activeThemeId = stored && stored !== defaultThemeId ? stored : resolvedThemeId

applyThemeToDocument(document, activeThemeId)

if (stored && stored !== defaultThemeId) {
/** User explicitly chose a theme - apply it immediately */
/** <html> element */
applyThemeToDocument(document, stored)
} else if (!stored) {
/** No stored preference - use system preference */
/** <html> element */
applyThemeToDocument(document, resolvedThemeId)
}

/** 2. Turn <body> visible. It's set to hidden in BaseLayout.astro to avoid FOUC. Do
* here to make sure it gets turned on, in case the <meta> element selector throws.
*/
document.body.classList.remove('invisible')

/** 3. Add theme entries from src/content/themes.json to the window.metaColors object */
window.metaColors = window.metaColors || {}
Object.assign(window.metaColors, themeMetaColors)

/** 4. Update meta theme-color used for PWAs */
const metaElement = document.querySelector('meta[name="theme-color"]')
if (stored && stored !== defaultThemeId && metaElement && window.metaColors) {
/** User explicitly chose a theme - apply it immediately */
metaElement.setAttribute('content', window.metaColors[stored] || '')
} else if (!stored && metaElement && window.metaColors) {
/** No stored preference - use system preference */
metaElement.setAttribute('content', window.metaColors[resolvedThemeId] || '')
}

/**
* 5. If stored === defaultThemeId, BaseLayout.astro already set it
* on <html> data-theme attribute and Meta.astro already set it on
* <meta name="theme-color">, so nothing to do
*/

/** 6. Success! */
applyThemeMetaColor(document, activeThemeId)

console.log('🎨 Theme init on initial load executed')
} catch (error) {
/** localStorage access can fail (privacy mode, etc.) */
/** Fall back to BaseLayout's data-theme="light", make sure the page is visible */
document.body.classList.remove('invisible')
console.error('❌ Theme init on initial load failed with errors:', error)
}
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runThemeInit, { once: true })
} else {
runThemeInit()
}
runThemeInit()

/**
* Add the fast theme setting logic for Astro View Transition API navigation events.
Expand All @@ -117,18 +80,17 @@ const { defaultThemeIdJson, darkThemeIdJson, metaColorsJson } = themeData()
try {
const defaultThemeId = JSON.parse(defaultThemeIdJson)
const darkThemeId = JSON.parse(darkThemeIdJson)
/** 1. Read theme preference from localStorage (set by user's previous selection) */
const themeMetaColors = JSON.parse(metaColorsJson)
const resolvedThemeId = getResolvedThemeId({ defaultThemeId, darkThemeId })
/** 2. <html> element */

window.metaColors = window.metaColors || {}
Object.assign(window.metaColors, themeMetaColors)

applyThemeToDocument(event.newDocument, resolvedThemeId)
/** 3. Turn <body> visible. It's set to hidden in BaseLayout.astro to avoid FOUC */
event.newDocument.body.classList.remove('invisible')
/** 4. Success! */
applyThemeMetaColor(event.newDocument, resolvedThemeId)

console.log('🎨 Theme init on "astro:before-swap" executed')
} catch (error) {
/** localStorage access can fail (privacy mode, etc.) */
/** Fall back to BaseLayout's data-theme="light", make sure the page is visible */
event.newDocument.body.classList.remove('invisible')
console.error('❌ Theme init on "astro:before-swap" failed with errors:', error)
}
})
Expand Down
4 changes: 2 additions & 2 deletions src/components/Head/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ const {
{/* hoisting issues related to <html> and <head> elements */}
<meta charset="utf-8" />
<title>{pageTitle}</title>
{/* Inline script to set theme ASAP - prevents FOUC */}
<ThemeInit />
{/* Site <meta> tags, Open Graph social tags, and favicon, PWA manifest, */}
{/* RSS, canonical, pingback, and webmention links */}
<Meta
Expand All @@ -59,6 +57,8 @@ const {
{...section && { section }}
{...tags && { tags }}
/>
{/* Inline script to set the active theme before the body is parsed. */}
<ThemeInit />
<script>
import { AppBootstrap } from '@components/scripts/bootstrap'
AppBootstrap.init()
Expand Down
5 changes: 1 addition & 4 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,7 @@ applyRenderSentryContext({
<ClientRouter />
{/* <Linter expanded /> */}
</head>
{/* Visibility set to hidden is a FOUC trick. It is set to 'visible' once */}
{/* the current data-theme value for the <> element is checked and set */}
{/* in components/Head/ThemeInit.astro */}
<body class="invisible flex flex-col h-full overflow-hidden">
<body class="flex flex-col h-full overflow-hidden">
{/* Skip to main content link for accessibility */}
<Skip />
{/* Cookie Consent Overlay */}
Expand Down
Loading
Loading