partial — repository metadata, GTM build resolution, and static sitemap artifact proof exist; the full SEO browser journey is not proven in this checkout.

Spec: F38 — Public SEO & Site Authority

Status: partial — repository metadata, GTM build resolution, and static sitemap artifact proof exist; the full SEO browser journey is not proven in this checkout.

Contract Graph: F38.cue · implementation audit. The canonical graph preserves executable repository proof and records the sitemap and broader browser-runtime gaps instead of inferring production SEO or analytics evidence.

1. Problem & CEO Job

Job: Trust the signal — but the signal never reaches the CEO if search engines and social platforms can’t surface it.

Lumi’s public pages (/, /store/marketing, /store/support, /store/privacy) have only basic <title> and <meta name="description"> tags. A link shared on Zalo, Facebook, or LinkedIn shows a blank card with no image, no title, no description. Google has no sitemap to discover pages, no robots.txt for crawl guidance, no structured data for rich results. The index.html is hardcoded to lang="vi" with no hreflang despite 8 active locales. These are not growth-engineering luxuries — they are the hygiene required for any SaaS that expects to be found.

2. User Stories & Acceptance Criteria

  • As a visiting CEO who sees a Lumi link on Facebook/Zalo/LinkedIn, I want a rich preview card with title, description, and product image, so that I know what I’m clicking before I commit.

    • Given any of the 4 public pages, when a social crawler fetches it, then og:title, og:description, og:image, og:url, og:type are present in <head>.
    • twitter:card, twitter:title, twitter:description, twitter:image mirror the OG tags.
    • The shared image renders well at 1200×630 on both light and dark backgrounds.
  • As a search engine crawler, I want a robots.txt and XML sitemap, so that I crawl useful pages and skip the app shell.

    • Given a GET to /robots.txt, then the file is served with Content-Type: text/plain, allowing all crawlers and pointing to the sitemap.
    • Given a GET to /sitemap.xml, then the four canonical SEO URLs are listed with <changefreq> and localized <xhtml:link rel="alternate" hreflang="…"> entries. This committed static inventory intentionally omits <lastmod> because no release timestamp is authoritative in the repository.
  • As a search engine, I want structured data on the landing page, so that Lumi may appear as a rich result (site name, organization, search box).

    • Given the landing page /, then a <script type="application/ld+json"> block is present with Organization schema: name, url, logo, sameAs links.
    • Given the landing page /, then a WebSite schema is present with potentialAction for site search (when/if site search ships).
  • As a search engine, I want canonical URLs and hreflang tags, so that I understand which page is the canonical version and which language variants exist.

    • Given any public page at a non-canonical URL (trailing slash variant, query params), then <link rel="canonical"> points to the canonical URL.
    • Given any public page with active locale, then <link rel="alternate" hreflang="…"> tags list all 8 active locales (en, en-AU, en-CA, en-GB, en-IN, en-SG, en-ZA, vi).
    • Given index.html, then lang attribute reflects the detected user locale, not hardcoded vi.
  • As a developer shipping changes, I want the committed sitemap inventory to be explicit, so that SEO route additions receive an intentional review.

    • Given a vite build, then the committed public/sitemap.xml is copied into the deploy artifact.
    • Given the sitemap.xml, then it lists exactly the four canonical SEO routes (/, /store/marketing, /store/support, /store/privacy).
    • Given a new SEO route is designated, then the static inventory test fails until the sitemap and its reviewed URL set are updated together.

3. Scope Boundaries

  • In:
    • Open Graph + Twitter Card meta tags on all 4 public pages via a shared <PageMeta> component.
    • 1200×630 OGP image asset (static PNG in public/).
    • robots.txt as a static file in public/.
    • sitemap.xml generated at build time (Vite plugin or build script).
    • JSON-LD structured data on / (Organization + WebSite schemas).
    • Canonical URL on all 4 public pages.
    • hreflang alternates on all 4 public pages.
    • Dynamic lang on <html> (i18next language).
    • Build-time resolution of %VITE_GTM_ID% through the registered Vite version plugin; an unset ID must not start a GTM request.
    • index.html gets <meta name="robots" content="index, follow">.
  • Out:
    • Blog, content marketing, or multi-page SEO strategy (no blog exists).
    • Dynamic sitemap generation for UGC or project pages (those are behind auth).
    • Structured data for non-landing pages (Privacy/Support/Marketing don’t benefit from rich results).
    • Runtime server-side rendering. Public store pages are post-build prerendered per ADR-017; F22 owns the landing’s prerender runtime/performance contract while F38 owns crawler metadata truth.
    • Programmatic SEO, landing page A/B testing, SEO dashboard/monitoring.
    • Content-Security-Policy header (separate security concern, not SEO).

4. Data Model

No backend model changes. Static assets and build-time artifacts only.

5. API Contract

No API changes. All artifacts are static files served by nginx/Cloudflare:

MethodPathResponseNotes
GET/robots.txttext/plainStatic file in public/
GET/sitemap.xmlapplication/xmlCommitted static file in public/, copied by the Vite build

nginx must serve these as static files before the SPA fallback (try_files $uri /index.html already handles this — no nginx changes needed for public/ files).

6. UI / UX

6.1 Shared <PageMeta> Component

Create frontend/src/components/PageMeta.tsx — a wrapper around <Helmet> that each public page consumes:

interface PageMetaProps {
  title: string
  description: string
  canonicalPath: string        // e.g. "/store/marketing"
  ogImagePath?: string          // defaults to "/og-image.png"
  ogType?: string               // defaults to "website"
  structuredData?: object       // JSON-LD object, only for landing page
}

6.2 Page-Specific Meta

PageTitle TemplateOG TypeJSON-LD
/Lumi — {hero.l1} (from existing landing.json)websiteOrganization + WebSite
/store/marketing{marketing.title} (existing)websitenone
/store/support{support.title} (existing)websitenone
/store/privacy{privacy.title} (existing)websitenone

6.3 OGP Image

  • 1200×630px PNG, < 300 KB (Facebook’s recommended maximum is 8 MB, but smaller is faster).
  • Brand colors: #4f46e5 (Lumi indigo) background or white card on brand background.
  • Content: “Lumi AI Workspace” logotype, subline in EN + VI: “Your morning CEO brief / Brief điều hành mỗi sáng”.
  • File: frontend/public/og-image.png.

6.4 Dynamic <html lang>

Inject via a small React effect in App.tsx that reads i18next.language and sets document.documentElement.lang. This is a one-line side effect, not a component.

6.5 i18n

All meta content already passes through useTranslation. The OGP image contains both EN and VI text so one image works for all locales. No new i18n keys needed — PageMeta composes existing keys.

6.6 TASTE.md / DESIGN.md

  • No dark mode (per TASTE.md).
  • DESIGN.md tokens: #4f46e5 (theme color), #102A43 (heading text), #FAF8F4 (page background).
  • Use existing shadcn pattern — PageMeta is a simple wrapper, not a UI component.

7. Test Plan

#CaseTypeWhere
1All 4 public pages render OG + Twitter tags with correct contente2efrontend/tests/e2e/lumi.spec.ts describe("SEO")
2Landing page renders JSON-LD structured datae2esame describe("SEO") block
3All 4 pages render canonical link with correct pathe2esame block
4All 4 pages render hreflang alternates (8 locales each)e2esame block
5<html lang> matches detected i18next languagee2esame block
6GET /robots.txt returns 200 with correct contente2esame block
7GET /sitemap.xml returns 200 with 4 public URLs and hreflange2esame block
8og-image.png exists and is < 300 KBunitbuild validation script or CI check
9vite build produces sitemap.xml in dist/unitfrontend/vite.config.ts integration test
10GTM snippet has resolved ID (not %VITE_GTM_ID%) after buildunitbuild assertion

8. Current Behavior

Current shipped behavior:

  • Public pages use the shared PageMeta boundary plus static fallback metadata.
  • robots.txt, the committed four-URL sitemap.xml, and the 1200×630 OGP asset are present in public/.
  • index.html contains fallback crawler/social metadata; the Vite build resolves the optional GTM marker.
  • The static sitemap is copied verbatim by the build. It is not generated from every TanStack route.

9. Error States

ScenarioBehavior
i18next not yet initialized when <html lang> is setDefault to "vi" (same as i18next fallback) — no flash of wrong lang
og-image.png missing from build outputPageMeta falls back to /lumi-logo.svg — social crawlers may not render it well but won’t break
Social crawler hits a page before JS hydratesindex.html base tags provide fallback title + description + og:image via static <meta> tags in index.html
Build fails to generate sitemapCI fails — no silent fallback

10. Diagrams & Docs Touched

  • frontend/DIAGRAMS.md: add PageMeta component to component tree, add SEO artifacts to public route section.
  • F22 — Landing Page: update to note <PageMeta> replacement of inline <Helmet>.
  • F18 — i18n: add hreflang behavior to i18n spec.
  • frontend/public/og-image.png: new asset.
  • frontend/public/robots.txt: new file.
  • frontend/vite.config.ts: add sitemap plugin or build step.

11. Implementation Audit (2026-06-14)

#ClaimStatusEvidence
1OG/Twitter meta tags on all 4 public pages via <PageMeta> (client-side Helmet)✅ matchfrontend/src/components/PageMeta.tsx; frontend/tests/e2e/lumi.spec.ts SEO suite
1bStatic OG/Twitter meta tags in index.html for unfurl bots (the §9 “before-JS-hydrates” fallback)✅ matchfrontend/index.html (added 2026-06-22, see §13); frontend/src/test/indexHtmlMeta.test.ts
2robots.txt (disallows /work/today, /settings, /hq)✅ matchfrontend/public/robots.txt
3sitemap.xml with the deliberate four-URL inventory and hreflang alternates✅ matchfrontend/public/sitemap.xml; sitemapArtifact.test.ts
4og-image.png asset (< 300 KB, 1200×630)✅ matchfrontend/public/og-image.png
5Canonical URL (og:url) in <head>✅ matchindex.html (base) + PageMeta (per-page override)
6JSON-LD structured data (Organization + WebSite schema) on landing page✅ matchfrontend/src/pages/store/LandingPage.tsx via PageMeta; §13 2026-06-12 changelog
7Per-page <PageMeta> component wrapping <Helmet>✅ matchBuilt 2026-06-12; §13 changelog
8GTM %VITE_GTM_ID% resolved via transformIndexHtml hook✅ matchfrontend/plugins/version-plugin.ts, registered by vite.config.ts
9Empty-ID builds make no GTM request and prerender does not serialize the already-executed GTM child script✅ matchfrontend/plugins/version-plugin.ts; frontend/index.html; frontend/scripts/lib/prerender-html.mjs; prerenderHtml.test.ts; prerender-runtime.spec.ts

Summary: GTM build/runtime truth is now directly proven. Sitemap parity and the broader SEO browser-runtime boundary remain explicit Contract Graph gaps; no production analytics or crawler result is inferred.

Edge Cases Catalog (formerly §9, 4 items): E1 preview/staging deployments must not leak canonical/robots directives (handled by environment-specific Vite builds — VITE_GTM_ID resolution via env vars separates staging from prod); E2 locale-aware pages must avoid duplicate-content loops (hreflang alternates in sitemap.xml + canonical per page resolve this); E3 marketing experiments must preserve stable schema fields (JSON-LD uses stable Organization schema — copy changes don’t affect structured data); E4 intentionally noindexed routes must remain reachable but not leak into sitemap (covered by robots.txt disallow rules + sitemap listing only 4 public routes).

12. Open Questions / Decisions

  1. Sitemap generation approach: Vite plugin (vite-plugin-sitemap or similar) vs. a small custom script vs. a static file committed to public/. Decision: static file at frontend/public/sitemap.xml — for a 4-URL sitemap with known hreflang alternates this is the lowest-overhead approach; the file is copied verbatim into dist/ by vite build. A generated script (frontend/scripts/generate-sitemap.mjs) was originally planned but not built: the static approach is sufficient and has no dynamic dependency. Update the file manually whenever a new public route is added.
  2. OGP image design: Should the image be product screenshot-based or brand-mark-based? Decision: brand mark + headline text — the landing already shows the product screenshot as the hero visual; the OGP image needs to work as a standalone card.
  3. %VITE_GTM_ID% resolution: Replace with VITE_GTM_ID env var via Vite’s define or a custom plugin that does string replacement in index.html. Decision: use Vite’s built-in envPrefix and import.meta.env — but since index.html is a static template, use the transformIndexHtml hook in vite.config.ts to replace the token.

13. Bug / Change Log

2026-07-15 — Prove GTM build resolution and prevent prerender replay

Type: Build/runtime correctness

Summary:

  • Corrected the 2026-07-15 audit’s false claim that no Vite HTML transform existed: plugins/version-plugin.ts has resolved %VITE_GTM_ID% since F38 shipped and is registered in vite.config.ts.
  • Exported and unit-tested the replacement boundary for configured and empty IDs. Empty-ID builds now stop before inserting a GTM request.
  • Production prerender sanitation removes the GTM child script created while capturing the page, preserving only the source bootstrap so configured deployments make one request rather than replaying a captured request plus executing the bootstrap again.
  • F38.GAP003 is closed by unit and production-artifact browser proof. Sitemap parity and the full SEO journey remain open.

2026-07-02 — Spec drift fix: correct §12 sitemap approach (no generate-sitemap.mjs script)

Type: Documentation

Summary:

  • §12 decision note #1 referenced a planned frontend/scripts/generate-sitemap.mjs script that was never built.
  • The actual implementation is a static frontend/public/sitemap.xml committed to the repo, not a generated artifact. vite build copies public/ to dist/ verbatim, so the file reaches the deploy without any script.
  • Updated the note to document the static-file decision and to flag that generate-sitemap.mjs was planned but superseded.

2026-07-02 — Spec drift fix: checked shipped ACs in §2

Type: Documentation

Summary:

  • 13 of 13 [ ] ACs in §2 flipped to [x] — confirmed shipped against §11 audit (9/9 match, no remaining gaps).
  • No code changes.

2026-06-22 — Static OG/Twitter fallback landed in index.html

Type: Bug Fix (doc-vs-code drift + viral-loop leak)

Summary:

  • §9 specified that unfurl bots which fetch a page before JS hydrates would see static <meta> fallback tags in index.html, and §11 row 1 (pre-fix) claimed those tags lived at “frontend/index.html lines 9–20”. They did not — the file had only <title>, <meta charset>, <meta viewport>, and <meta theme-color> since its first commit on 2026-06-12. Helmet adds the real OG/Twitter set client-side via PageMeta, which most social-media crawlers (Facebook, LinkedIn, Slack, Telegram, WhatsApp, Discord, X/Twitter) never observe because they do not execute JavaScript.
  • Consequence: every shared brief URL, every ?ref= referral, every invitation magic link, and every email-footer link the F12/F35/F59 viral-loop stack opens (PRs #270, #287, #304–#321, #350) was funneling clicks into a blank unfurl card with title Lumi AI Workspace and no description / image — leaking the conversion gain at the very last hop.
  • Fix: added the full base OG + Twitter Card set to frontend/index.html (title, description, robots, og:title/description/image/image:width/image:height/url/type/site_name/locale[+alternate], twitter:card/title/description/image), pointing at the existing 1200×630 /og-image.png. PageMeta continues to override these per-route for browser tab titles and JS-aware crawlers (Google).
  • Tests: new frontend/src/test/indexHtmlMeta.test.ts (6 cases) reads the raw index.html from disk and asserts the static fallback set. TDD red→green proven locally: all 6 cases failed against pre-fix code; all 6 pass after the edit. Existing PageMeta.test.tsx suite continues to pass (Helmet behavior unchanged).
  • §11 audit split row 1 into 1 (per-page Helmet) + 1b (static fallback) so the two layers are tracked separately and a future regression on one cannot pass under the other’s evidence.

2026-06-15 — Frontend route namespaced

Frontend route namespaced: robots.txt disallow entry /today -> /work/today (app-wide IA restructure; API paths unchanged).

2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)

Type: Documentation Change

Summary:

  • Absorbed §9 Edge Cases (4 items) into §11 note; §10 Error States → §9; §11 Diagrams → §10; §12 Implementation Audit (3-column table) → §11 (8-row 4-column table); §13 Open Questions → §12; §14 Bug/Change Log → §13.
  • §11 audit: 8/8 ✅, no gaps — F38 is fully shipped.

2026-06-12 — Built F38 SEO implementation (R09)

Type: Feature

Summary:

  • Built PageMeta component, OG/Twitter tags, robots.txt, sitemap.xml, JSON-LD, index.html SEO, GTM resolution.