step 002 complete

This commit is contained in:
2026-04-10 18:41:58 +02:00
parent e9aa6f452b
commit 089dbaa606
26 changed files with 2732 additions and 15 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-10

View File

@@ -0,0 +1,79 @@
## 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 page** → `index.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.

View File

@@ -0,0 +1,31 @@
## Why
Step 001 established brand tokens and fonts — we now have a working Astro project but every page must manually construct its own `<html>` document with head meta tags. Step 002 creates the shared `BaseLayout.astro` shell so all subsequent pages start from a consistent, SEO-complete foundation without repetition.
## What Changes
- **New**: `src/layouts/BaseLayout.astro` — HTML document shell accepting props for title, description, locale, canonical URL, alternate URL, OG image, and JSON-LD structured data
- **New**: `src/components/Nav.astro` — empty stub (filled in step 003)
- **New**: `src/components/Footer.astro` — empty stub (filled in a later step)
- **New**: `src/content/en.json` — initial structure with global meta fields (site name, default title, default description, default OG image path)
- **New**: `src/content/nl.json` — Dutch translations of the same global meta fields
- **New**: `src/content/i18n.ts` — helper that loads and returns the correct JSON content object based on locale string
- **Update**: `src/pages/index.astro` — replace manual `<html>` shell with `<BaseLayout>`, passing appropriate props
## Capabilities
### New Capabilities
- `base-layout`: HTML document shell providing consistent `<head>` (charset, viewport, favicon, Archia font preloads, canonical link, hreflang alternates, OG tags, Twitter card tags) and `<body>` defaults (Midnight background, Snow text, Archia font), with stub nav/footer and content/JSON-LD slots
- `content-i18n`: File-based content loading system — JSON files per locale (`en.json`, `nl.json`) plus an `i18n.ts` helper function that returns the correct content object given a locale string
### Modified Capabilities
- `brand-tokens`: No requirement changes — the existing spec covers colors, fonts, and gradient; BaseLayout consumes these tokens but does not change them
## Impact
- All future pages (`/`, `/about`, `/ai-launchpad`, `/contact`, `/privacy`, plus `/nl/*` variants) will import and use `BaseLayout`
- SEO: adds canonical URL, hreflang (`en``nl`), OG tags, and Twitter card tags to every page
- Affects `en.json` / `nl.json` content structure (introduces the `meta` key)
- No new npm dependencies — uses only Astro built-ins and TypeScript

View File

@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: BaseLayout provides complete HTML document shell
`src/layouts/BaseLayout.astro` SHALL render a complete HTML document (`<!DOCTYPE html>`, `<html>`, `<head>`, `<body>`) that every page can wrap its content in. It SHALL accept the following props:
- `title: string` — page-specific `<title>` and OG title
- `description: string` — page-specific meta description and OG description
- `locale?: string` — BCP 47 language tag, default `"en"`
- `canonicalUrl: string` — absolute canonical URL for this page
- `alternateUrl: string` — absolute URL of the alternate-locale version of this page
- `ogImage?: string` — absolute URL for `og:image`; tag is omitted when falsy
- `jsonLd?: Record<string, unknown>` — structured data object to serialize as JSON-LD
#### Scenario: Page renders with all head meta tags
- **WHEN** a page wraps its content in `<BaseLayout>` with all props provided
- **THEN** the rendered HTML `<head>` SHALL contain: `<meta charset="utf-8">`, `<meta name="viewport">`, `<link rel="icon">` for both SVG and ICO favicons, `<link rel="canonical">`, `<link rel="alternate" hreflang>` for both locales, `<meta property="og:*">` tags, `<meta name="twitter:*">` tags, and `<title>`
#### Scenario: OG image tag omitted when ogImage is falsy
- **WHEN** `ogImage` prop is an empty string or not provided
- **THEN** no `<meta property="og:image">` tag SHALL appear in the rendered HTML
#### Scenario: Body has brand defaults
- **WHEN** any page uses BaseLayout
- **THEN** the `<body>` element SHALL have Tailwind classes `bg-midnight text-snow font-archia` applied
### Requirement: BaseLayout preloads critical Archia font weights
The `<head>` SHALL contain `<link rel="preload" as="font" type="font/woff2" crossorigin>` for Archia Regular (400), SemiBold (600), and Bold (700) woff2 files. Thin (100) and Light (300) SHALL NOT be preloaded.
#### Scenario: Correct preload tags rendered
- **WHEN** the page HTML is rendered
- **THEN** exactly three font preload links SHALL appear in `<head>`: one each for `archia-regular-webfont.woff2`, `archia-semibold-webfont.woff2`, and `archia-bold-webfont.woff2`
### Requirement: BaseLayout renders hreflang alternates
The `<head>` SHALL contain `<link rel="alternate" hreflang>` tags for both `en` and `nl` locales, plus an `x-default` pointing to the EN URL.
#### Scenario: EN page hreflang output
- **WHEN** a page passes `locale="en"`, `canonicalUrl="https://qumo.io/about"`, `alternateUrl="https://qumo.io/nl/about"`
- **THEN** the head SHALL contain:
- `<link rel="alternate" hreflang="en" href="https://qumo.io/about">`
- `<link rel="alternate" hreflang="nl" href="https://qumo.io/nl/about">`
- `<link rel="alternate" hreflang="x-default" href="https://qumo.io/about">`
#### Scenario: NL page hreflang output
- **WHEN** a page passes `locale="nl"`, `canonicalUrl="https://qumo.io/nl/about"`, `alternateUrl="https://qumo.io/about"`
- **THEN** the head SHALL contain the same three hreflang tags with EN/NL values swapped appropriately, and `x-default` SHALL always point to the EN URL (the `alternateUrl` on NL pages)
### Requirement: BaseLayout supports JSON-LD injection via prop
When the `jsonLd` prop is provided, BaseLayout SHALL render a `<script type="application/ld+json">` tag in `<head>` containing the JSON-serialized value.
#### Scenario: JSON-LD rendered from prop
- **WHEN** `jsonLd={{ "@context": "https://schema.org", "@type": "WebPage", "name": "About" }}` is passed
- **THEN** the head SHALL contain `<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebPage","name":"About"}</script>`
#### Scenario: No JSON-LD script when prop omitted
- **WHEN** `jsonLd` prop is not provided
- **THEN** no `<script type="application/ld+json">` tag SHALL appear in the rendered head (beyond any injected via the named slot)
### Requirement: BaseLayout supports additional JSON-LD via named slot
A `<slot name="jsonld" />` SHALL be provided inside `<head>` for pages that need additional or multiple structured data blocks beyond the `jsonLd` prop.
#### Scenario: Named slot accepts additional script
- **WHEN** a page passes `<script slot="jsonld" type="application/ld+json">{"@type":"BreadcrumbList"}</script>`
- **THEN** that script tag SHALL appear in the rendered `<head>`
### Requirement: BaseLayout includes Nav and Footer stub components
`BaseLayout.astro` SHALL import `src/components/Nav.astro` and `src/components/Footer.astro` and render them at the top and bottom of `<body>` respectively. Both components are stubs that render nothing in this step.
#### Scenario: Nav and Footer positions in body
- **WHEN** any page uses BaseLayout
- **THEN** the `<body>` structure SHALL be: Nav stub output → `<slot />` (page content) → Footer stub output
### Requirement: BaseLayout content slot renders page content
A default `<slot />` SHALL be provided between Nav and Footer for page-specific content.
#### Scenario: Page content appears in slot
- **WHEN** a page places markup inside `<BaseLayout>...</BaseLayout>`
- **THEN** that markup SHALL appear in the rendered body between Nav and Footer

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Locale content JSON files contain global meta fields
`src/content/en.json` and `src/content/nl.json` SHALL each contain a top-level `"meta"` key with the following fields:
- `siteName: string` — the site name (e.g., `"Qumo"`)
- `defaultTitle: string` — default page title (used as fallback)
- `defaultDescription: string` — default meta description (used as fallback)
- `defaultOgImage: string` — default OG image path; may be empty string initially
#### Scenario: en.json has correct meta structure
- **WHEN** `src/content/en.json` is parsed as JSON
- **THEN** it SHALL have a `meta` object with string values for `siteName`, `defaultTitle`, `defaultDescription`, and `defaultOgImage`
#### Scenario: nl.json has correct meta structure
- **WHEN** `src/content/nl.json` is parsed as JSON
- **THEN** it SHALL have the same `meta` object shape as `en.json`, with Dutch-language values for `defaultTitle` and `defaultDescription`
### Requirement: i18n helper returns correct content for locale
`src/content/i18n.ts` SHALL export a function `getContent(locale: string)` that returns the parsed content object for the given locale. It SHALL return the EN content object for any unrecognized locale string.
#### Scenario: Returns EN content for "en" locale
- **WHEN** `getContent("en")` is called
- **THEN** the function SHALL return the parsed contents of `src/content/en.json`
#### Scenario: Returns NL content for "nl" locale
- **WHEN** `getContent("nl")` is called
- **THEN** the function SHALL return the parsed contents of `src/content/nl.json`
#### Scenario: Falls back to EN for unknown locale
- **WHEN** `getContent("fr")` or any unrecognized locale string is called
- **THEN** the function SHALL return the parsed contents of `src/content/en.json`
### Requirement: Content files use static imports (no runtime fetch)
The `i18n.ts` helper SHALL use static ES module imports (`import enContent from './en.json'`) rather than dynamic `fetch` or `fs.readFile`. This ensures type safety and tree-shaking at build time.
#### Scenario: Build succeeds with static imports
- **WHEN** `npm run build` is executed in the `website/` directory
- **THEN** the build SHALL complete without errors, with locale content bundled statically

View File

@@ -0,0 +1,33 @@
## 1. Content Files
- [x] 1.1 Create `src/content/en.json` with top-level `meta` key: `siteName`, `defaultTitle`, `defaultDescription`, `defaultOgImage` (empty string)
- [x] 1.2 Create `src/content/nl.json` with same `meta` shape, Dutch values for `defaultTitle` and `defaultDescription`
- [x] 1.3 Create `src/content/i18n.ts` exporting `getContent(locale: string)` using static imports of both JSON files; fall back to EN for unknown locales
## 2. Stub Components
- [x] 2.1 Create `src/components/Nav.astro` as an empty stub (renders nothing — filled in step 003)
- [x] 2.2 Create `src/components/Footer.astro` as an empty stub (renders nothing — filled in a later step)
## 3. BaseLayout Component
- [x] 3.1 Create `src/layouts/BaseLayout.astro` with props: `title`, `description`, `locale` (default `"en"`), `canonicalUrl`, `alternateUrl`, `ogImage` (optional), `jsonLd` (optional `Record<string, unknown>`)
- [x] 3.2 Add `<head>` content: `<meta charset="utf-8">`, `<meta name="viewport">`, `<title>`, `<link rel="icon">` for both SVG and ICO favicons
- [x] 3.3 Add three Archia font preload tags in `<head>`: Regular (400), SemiBold (600), Bold (700) woff2 files only
- [x] 3.4 Add `<link rel="canonical">` and three hreflang `<link rel="alternate">` tags (`en`, `nl`, `x-default`) using `canonicalUrl` and `alternateUrl` props
- [x] 3.5 Add OG meta tags (`og:type`, `og:url`, `og:title`, `og:description`, `og:site_name`); add `og:image` only when `ogImage` is truthy
- [x] 3.6 Add Twitter card meta tags (`twitter:card`, `twitter:title`, `twitter:description`); add `twitter:image` only when `ogImage` is truthy
- [x] 3.7 Add JSON-LD script tag in `<head>` rendered from `jsonLd` prop when provided (`set:html={JSON.stringify(jsonLd)}`)
- [x] 3.8 Add `<slot name="jsonld" />` inside `<head>` for additional JSON-LD blocks
- [x] 3.9 Add `<body class="bg-midnight text-snow font-archia">` with `<Nav />`, default `<slot />`, and `<Footer />` in order
## 4. Update Index Page
- [x] 4.1 Update `src/pages/index.astro` to import and use `BaseLayout`, passing `title`, `description`, `canonicalUrl="https://qumo.io/"`, `alternateUrl="https://qumo.io/nl"`, and `locale="en"`; remove manual `<html>/<head>/<body>` shell
## 5. Verification
- [x] 5.1 Run `cd website && npm run build` — build must succeed with zero errors
- [x] 5.2 View page source in browser (`npm run dev`): confirm `<head>` contains charset, viewport, title, favicon links, three font preload tags, canonical, three hreflang links, OG tags, Twitter card tags
- [x] 5.3 Confirm body has `bg-midnight text-snow font-archia` classes and smoke-test content still renders correctly
- [x] 5.4 Confirm no `og:image` or `twitter:image` tag appears (since `ogImage` is not passed)