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:typeare present in<head>. -
twitter:card,twitter:title,twitter:description,twitter:imagemirror the OG tags. - The shared image renders well at 1200×630 on both light and dark backgrounds.
- Given any of the 4 public pages, when a social crawler fetches it, then
-
As a search engine crawler, I want a
robots.txtand XML sitemap, so that I crawl useful pages and skip the app shell.- Given a GET to
/robots.txt, then the file is served withContent-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.
- Given a GET to
-
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 withOrganizationschema: name, url, logo, sameAs links. - Given the landing page
/, then aWebSiteschema is present withpotentialActionfor site search (when/if site search ships).
- Given the landing page
-
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, thenlangattribute reflects the detected user locale, not hardcodedvi.
- Given any public page at a non-canonical URL (trailing slash variant, query params), then
-
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 committedpublic/sitemap.xmlis 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.
- Given a
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.txtas a static file inpublic/.sitemap.xmlgenerated 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
langon<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.htmlgets<meta name="robots" content="index, follow">.
- Open Graph + Twitter Card meta tags on all 4 public pages via a shared
- 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-Policyheader (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:
| Method | Path | Response | Notes |
|---|---|---|---|
| GET | /robots.txt | text/plain | Static file in public/ |
| GET | /sitemap.xml | application/xml | Committed 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
| Page | Title Template | OG Type | JSON-LD |
|---|---|---|---|
/ | Lumi — {hero.l1} (from existing landing.json) | website | Organization + WebSite |
/store/marketing | {marketing.title} (existing) | website | none |
/store/support | {support.title} (existing) | website | none |
/store/privacy | {privacy.title} (existing) | website | none |
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 —
PageMetais a simple wrapper, not a UI component.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | All 4 public pages render OG + Twitter tags with correct content | e2e | frontend/tests/e2e/lumi.spec.ts describe("SEO") |
| 2 | Landing page renders JSON-LD structured data | e2e | same describe("SEO") block |
| 3 | All 4 pages render canonical link with correct path | e2e | same block |
| 4 | All 4 pages render hreflang alternates (8 locales each) | e2e | same block |
| 5 | <html lang> matches detected i18next language | e2e | same block |
| 6 | GET /robots.txt returns 200 with correct content | e2e | same block |
| 7 | GET /sitemap.xml returns 200 with 4 public URLs and hreflang | e2e | same block |
| 8 | og-image.png exists and is < 300 KB | unit | build validation script or CI check |
| 9 | vite build produces sitemap.xml in dist/ | unit | frontend/vite.config.ts integration test |
| 10 | GTM snippet has resolved ID (not %VITE_GTM_ID%) after build | unit | build assertion |
8. Current Behavior
Current shipped behavior:
- Public pages use the shared
PageMetaboundary plus static fallback metadata. robots.txt, the committed four-URLsitemap.xml, and the 1200×630 OGP asset are present inpublic/.index.htmlcontains 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
| Scenario | Behavior |
|---|---|
i18next not yet initialized when <html lang> is set | Default to "vi" (same as i18next fallback) — no flash of wrong lang |
og-image.png missing from build output | PageMeta falls back to /lumi-logo.svg — social crawlers may not render it well but won’t break |
| Social crawler hits a page before JS hydrates | index.html base tags provide fallback title + description + og:image via static <meta> tags in index.html |
| Build fails to generate sitemap | CI fails — no silent fallback |
10. Diagrams & Docs Touched
- frontend/DIAGRAMS.md: add
PageMetacomponent 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)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | OG/Twitter meta tags on all 4 public pages via <PageMeta> (client-side Helmet) | ✅ match | frontend/src/components/PageMeta.tsx; frontend/tests/e2e/lumi.spec.ts SEO suite |
| 1b | Static OG/Twitter meta tags in index.html for unfurl bots (the §9 “before-JS-hydrates” fallback) | ✅ match | frontend/index.html (added 2026-06-22, see §13); frontend/src/test/indexHtmlMeta.test.ts |
| 2 | robots.txt (disallows /work/today, /settings, /hq) | ✅ match | frontend/public/robots.txt |
| 3 | sitemap.xml with the deliberate four-URL inventory and hreflang alternates | ✅ match | frontend/public/sitemap.xml; sitemapArtifact.test.ts |
| 4 | og-image.png asset (< 300 KB, 1200×630) | ✅ match | frontend/public/og-image.png |
| 5 | Canonical URL (og:url) in <head> | ✅ match | index.html (base) + PageMeta (per-page override) |
| 6 | JSON-LD structured data (Organization + WebSite schema) on landing page | ✅ match | frontend/src/pages/store/LandingPage.tsx via PageMeta; §13 2026-06-12 changelog |
| 7 | Per-page <PageMeta> component wrapping <Helmet> | ✅ match | Built 2026-06-12; §13 changelog |
| 8 | GTM %VITE_GTM_ID% resolved via transformIndexHtml hook | ✅ match | frontend/plugins/version-plugin.ts, registered by vite.config.ts |
| 9 | Empty-ID builds make no GTM request and prerender does not serialize the already-executed GTM child script | ✅ match | frontend/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
- Sitemap generation approach: Vite plugin (
vite-plugin-sitemapor similar) vs. a small custom script vs. a static file committed topublic/. Decision: static file atfrontend/public/sitemap.xml— for a 4-URL sitemap with known hreflang alternates this is the lowest-overhead approach; the file is copied verbatim intodist/byvite 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. - 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.
%VITE_GTM_ID%resolution: Replace withVITE_GTM_IDenv var via Vite’sdefineor a custom plugin that does string replacement inindex.html. Decision: use Vite’s built-inenvPrefixandimport.meta.env— but sinceindex.htmlis a static template, use thetransformIndexHtmlhook invite.config.tsto 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.tshas resolved%VITE_GTM_ID%since F38 shipped and is registered invite.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.mjsscript that was never built. - The actual implementation is a static
frontend/public/sitemap.xmlcommitted to the repo, not a generated artifact.vite buildcopiespublic/todist/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.mjswas 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 inindex.html, and §11 row 1 (pre-fix) claimed those tags lived at “frontend/index.htmllines 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 viaPageMeta, 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 titleLumi AI Workspaceand 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.PageMetacontinues 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 rawindex.htmlfrom 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. ExistingPageMeta.test.tsxsuite 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.