Files
qumo-website/openspec/changes/archive/2026-04-10-step-002-base-layout/design.md
2026-04-10 18:41:58 +02:00

5.9 KiB

Context

Step 001 produced a working Astro project with Tailwind v4 brand tokens, Archia font-face declarations, and a smoke-test index.astro that manually constructs its own <html> document. Every subsequent page (homepage, about, AI Launchpad, contact, privacy — plus /nl/* variants) will need the same HTML shell with correct <head> meta, hreflang, OG tags, and body defaults. Without a shared layout this is copied per page, creating drift and SEO inconsistency.

Current state of note:

  • global.css is Tailwind v4 (@import "tailwindcss" + @theme) — no tailwind.config.mjs
  • Archia @font-face declarations already live in global.css
  • public/ has favicon.svg and favicon.ico
  • No src/layouts/, src/components/, or src/content/ directories yet

Goals / Non-Goals

Goals:

  • Single BaseLayout.astro that every page wraps its content in
  • Correct SEO <head>: charset, viewport, favicon, font preloads, canonical, hreflang, OG, Twitter card
  • Body defaults applied once (Midnight bg, Snow text, Archia font)
  • JSON-LD injection mechanism (both prop and named slot)
  • Stub Nav.astro and Footer.astro so BaseLayout can import them (filled in steps 003+)
  • en.json / nl.json content files + i18n.ts loader for global meta fields
  • index.astro updated to use BaseLayout

Non-Goals:

  • Actual nav or footer UI (step 003)
  • Page-specific JSON-LD schemas (each page step handles its own)
  • OG image file creation (deferred — ogImage prop will be empty string initially)
  • Any client-side JavaScript
  • @astrojs/image or picture optimisation (images not needed in this step)

Decisions

1. hreflang: explicit props, not derived

Decision: BaseLayout accepts two explicit props — canonicalUrl: string and alternateUrl: string. The component does not compute the alternate URL.

Rationale: EN routes are /about, NL routes are /nl/about — a simple /nl/ prefix works for most paths, but /contact vs /nl/contact or future edge cases (slugs, catch-alls) could diverge. Explicit props make each page's intent clear and avoid regex surprises. Callers always know their own routes.

Alternative considered: Auto-derive by prepending/stripping /nl/ based on locale prop. Rejected: fragile for edge cases, magic at a distance.

2. Font preloads: critical weights only

Decision: Preload only Archia Regular (400), SemiBold (600), and Bold (700). Thin (100) and Light (300) load on demand.

Rationale: Each <link rel="preload"> is an unconditional network request. Regular is needed for all body copy; SemiBold and Bold are used for headings and CTAs — the elements visible above the fold. Thin and Light appear only in specific decorative contexts (if at all) and are not worth the preload cost.

3. JSON-LD: prop for common case + named slot for extras

Decision: BaseLayout accepts an optional jsonLd?: Record<string, unknown> prop. If provided, it is serialized with JSON.stringify and injected as <script type="application/ld+json" set:html={...}>. A <slot name="jsonld" /> is also provided for pages that need additional or multiple JSON-LD blocks.

Rationale: Most pages will pass one structured data object (e.g., ProfessionalService, WebPage). The prop keeps call sites clean. The named slot exists as an escape hatch for pages like the homepage that may need multiple JSON-LD entries.

4. Nav/Footer: imported stub components

Decision: BaseLayout.astro imports Nav.astro and Footer.astro directly as Astro components. Both are created as empty stubs (render nothing). Step 003 fills in Nav.astro; a later step fills Footer.astro.

Alternative considered: Named slots (<slot name="nav" />). Rejected: would require every page to manually import and wire up Nav, which is error-prone and defeats the purpose of a shared layout.

5. Body defaults: Tailwind classes on <body>

Decision: Apply class="bg-midnight text-snow font-archia" directly on the <body> element in BaseLayout. No @layer base rule added to global.css.

Rationale: These are layout-level defaults, not global resets. Keeping them as explicit Tailwind classes on the element makes them visible and overridable per-section without specificity surprises. Consistent with how index.astro already applies them.

6. Content JSON: flat meta key at top level

Decision: Both en.json and nl.json start with a top-level "meta" key containing siteName, defaultTitle, defaultDescription, and defaultOgImage. Subsequent steps add page-specific keys ("home", "about", etc.) at the same top level.

Rationale: Flat top-level keys by section keep the file scannable and allow each step to add its own section without touching others. The "meta" key is reserved for global/shared values.

7. i18n helper: synchronous import, not dynamic fetch

Decision: i18n.ts exports a function getContent(locale: string) that does a static import of each JSON and returns the correct one. No dynamic fetch or fs.readFile.

Rationale: Astro builds statically — all pages are rendered at build time. Static imports are tree-shakeable and type-safe. Dynamic fetch is unnecessary complexity.

Risks / Trade-offs

  • OG image missing → Social card previews will have no image until a default OG image is created. Mitigation: ogImage prop defaults to empty string; the <meta property="og:image"> tag is only rendered when ogImage is truthy.
  • Stub Nav/Footer render nothing → The smoke-test page will show Midnight body with no navigation. This is expected and clearly temporary. Mitigation: none needed — step 003 immediately follows.
  • hreflang on smoke-test pageindex.astro (the brand smoke-test) will need placeholder canonicalUrl and alternateUrl props. Use "/" and "/nl" as stand-ins. This is fine since the smoke-test page will be replaced when the real homepage is built.