# Asqvox Voice AI Glossary — Section Architecture & Build Plan

> **Status: PLAN (2026-07-30). Nothing built.** Strategy for publishing ~150 page-per-term voice-AI
> definition pages on asqvox.com, derived from `C:\DD\AsqVox\Definitions\00-The AsqVox Voice AI
> Glossary.md` (index + build guide) and `ASQVOX_GLOSSARY_STAGE1.md` (10 finished entries).
>
> Grounded in the real codebase: the `/blog` CMS shipped 2026-07-11 (`c4d53b7`→`62382ff`, migrations
> `0033`/`0034`) is the proven template for a new marketing content section, and this plan states
> where the glossary should follow it and where it deliberately should not.

---

## 0. TL;DR + the decisions I need from you

**Recommended shape:** a `/glossary` section on the marketing apex, built in **both `site-v0/` and
`beta-v1/`** (byte-identical, exactly like `/blog`), with **content as generated typed TS in-repo**
for Stage 1, **8 reusable React+SVG figure primitives** instead of Canva PNGs, and a **two-tier
sticky right sidebar** (on-page TOC + category index).

Six decisions are load-bearing. Everything else follows mechanically.

| # | Decision | My recommendation | Why it can't be deferred |
|---|---|---|---|
| D1 | **Typography + palette for the 150 visuals** — specs say Poppins; Asqvox is Urbanist/DM Sans, and there are two yellows (`#feca00` wordmark-reserved vs `#FFDB6A` used by `/about`+`/how-it-works`) | Urbanist/DM Sans + `--yellow-300: #FFDB6A` | Every visual is affected. Redoing 150 later is not viable. |
| D2 | **Visual production method** — React/SVG components vs Canva/AI raster images | React+SVG primitives (§7) | Text inside a PNG is invisible to answer engines. Raster kills the AEO purpose of the visual. |
| D3 | **Content storage** — generated typed TS in-repo vs mirror the blog CMS (DB + admin editor) | In-repo for Stage 1; promote to CMS at Stage 2+ if editing-without-deploy becomes the bottleneck | Determines whether Phase 1 is ~3 days or ~2 weeks. |
| D4 | **Spelling** — the source docs are British (`optimisation`, `centre`, `per cent`); US spellings carry the search volume for `answer engine optimization` | US in slug/title/H1, British allowed in body prose, other spelling carried as an on-page alias | 150 permalinks are effectively permanent. |
| D5 | **URL word** — `/glossary` vs `/definitions` vs `/voice-ai-glossary` | `/glossary` (short, conventional, hub H1 carries the keywords) | Same permanence problem. |
| D6 | **Scope of Phase 1** — ship Stage 1's 10 terms, or wait for more content | Ship the 10. They span 6 categories and prove the whole template. | Sequencing. |

**The one correction to your brief:** stages ≠ categories. See §2 — Stage 1's ten terms span **six
different categories**, so naming stages as categories would break the information architecture.
You need both, modelled separately.

---

## 1. Where it lives

### Package

The marketing apex (`asqvox.com`) is served by the `marketing` container, built from
`${MARKETING_PKG}` in `/opt/aiw/{dev,prod}/.env` — **default `site-v0`** (live waitlist site), with
`beta-v1` parked for the payment-gateway restore.

**Build the glossary in BOTH packages, byte-identical.** This is not a suggestion — it is the
established convention, verified: all 8 blog files (`app/blog/page.tsx`, `app/blog/[slug]/page.tsx`,
`app/api/blog/route.ts`, `lib/blog.ts`, `components/Blog{List,Hero,Share}.tsx`, `styles/blog.css`)
are md5-identical across `site-v0/` and `beta-v1/`. If the glossary ships only to `site-v0`, it
disappears the day `MARKETING_PKG` flips to `beta-v1`.

**Never the SPA.** CLAUDE.md's strict origin split forbids public/marketing content in
`aiw-voice-app/` — it splits SEO authority across two domains.

### Routes and permalinks

```
/glossary                          hub — all ~150 terms, A–Z + by-category + filter
/glossary/<term-slug>              term page          e.g. /glossary/time-to-first-audio
/glossary/topic/<category-slug>    category hub       e.g. /glossary/topic/latency-and-performance
```

**Flat term URLs, not nested under category.** Reasoning:

- URL path depth is a weak relevance signal; Google gets hierarchy from breadcrumbs and internal
  links, both of which we emit properly (§8).
- Nesting makes recategorisation a 301 — and with 150 terms across 11 categories, terms *will* move.
  `/glossary/latency/ttfa` → `/glossary/speech/ttfa` is churn we'd inflict on ourselves.
- Flat keeps link equity concentrated on one short path and makes the auto-linker (§5) trivial.

**`topic/` is a reserved segment** so a category slug can never collide with a term slug. Without it,
a future term literally named "speech technology" would shadow the category hub.

Canonicals, hreflang and OG all come from `buildPageMetadata({ path, title, description })` in
`lib/site.ts:238`. **This is mandatory on every page** — the doc comment at `lib/site.ts:178-210`
records that Next's metadata merge is shallow at top-level field granularity, so a page that sets
only `{title, description}` silently inherits the layout's `openGraph.url` pointing at the homepage.

---

## 2. Taxonomy — categories (public) vs stages (internal)

### The correction

Your brief asked to "name each stage a category name". Stage 1's ten terms map like this:

| Stage 1 term | Category |
|---|---|
| Voice agent, Website voice agent, Voice widget | Voice AI Fundamentals |
| RAG, Grounding | LLMs, RAG & Orchestration |
| Time to first audio | Latency & Performance |
| Containment rate | Contact Centre Metrics |
| Zero-click search, Answer engine optimisation | Web, Conversion & AI Search |
| Voice navigation as accessibility | Accessibility & Inclusion |

Stage 1 is a **publishing wave selected for commercial value** (per the build guide: "terms where
AsqVox has genuine product relevance AND where hard citable benchmarks exist"). It deliberately cuts
*across* the taxonomy. So:

- **Categories** = the public, permanent taxonomy. They get hub pages, appear in breadcrumbs, in the
  sidebar index, in schema, and in URLs. They are the SEO asset.
- **Stages** = internal release waves. They get a name for planning and a `wave` field on each term
  for tracking. **They are never exposed as a browsable surface** — a visitor has no reason to care
  which batch a definition shipped in, and exposing them would create thin, duplicate-intent index
  pages competing with the category hubs.

### The 11 public categories

Derived from the build guide's sections A–K, renamed from internal letters to search-aligned names.
Each hub page targets the head term in the right column.

| Doc | Category name (hub H1) | Slug | Head keyword the hub targets |
|---|---|---|---|
| A | Voice AI Fundamentals | `voice-ai-fundamentals` | voice AI terms · what is a voice agent |
| B | Speech Technology | `speech-technology` | speech recognition terms · ASR vs TTS |
| C | Latency & Performance | `latency-and-performance` | voice AI latency · time to first audio |
| D | LLMs, RAG & Orchestration | `llms-rag-and-orchestration` | RAG glossary · grounding vs retrieval |
| E | Telephony & Infrastructure | `telephony-and-infrastructure` | SIP vs WebRTC for voice AI |
| F | Contact Centre Metrics | `contact-centre-metrics` | containment rate · deflection rate |
| G | Web, Conversion & AI Search | `web-conversion-and-ai-search` | zero-click search · AEO · GEO |
| H | Trust, Privacy & Compliance | `trust-privacy-and-compliance` | EU AI Act voice · DPDP voice data |
| I | Accessibility & Inclusion | `accessibility-and-inclusion` | voice navigation accessibility · WCAG voice |
| J | Emerging & Agentic | `emerging-and-agentic` | speech-to-speech · MCP · agent-to-agent |
| K | Market & Economics | `market-and-economics` | voice AI pricing per minute · unit economics |

### Stage names (internal only)

| Stage | Internal name | Terms | Rationale |
|---|---|---|---|
| 1 | **Wave 1 — Commercial Core** | 10 | Product relevance × hard benchmarks. Highest citation probability. |
| 2A | **Wave 2 — Speech Stack** | ~10 | Developer/procurement search. |
| 2B | **Wave 3 — Transport & Telephony** | ~8 | |
| 2C | **Wave 4 — Retrieval & Orchestration** | ~10 | |
| 3 | **Wave 5 — Compliance, Market & Emerging** | remainder | Fastest-moving numbers → quarterly refresh. |

---

## 3. Content model

### The typed shape

The five-element template is fixed and identical across all 150 entries, which means it should be a
**type**, not prose. This is the single highest-leverage decision in the plan: typing the structure
is what makes 150 pages maintainable, makes the statistics machine-readable for schema/citation
markup, and makes the interlink lint (§5) possible.

```ts
// lib/glossary/types.ts  — server-safe, NO 'use client'
export type CategorySlug =
  | 'voice-ai-fundamentals' | 'speech-technology' | 'latency-and-performance'
  | 'llms-rag-and-orchestration' | 'telephony-and-infrastructure'
  | 'contact-centre-metrics' | 'web-conversion-and-ai-search'
  | 'trust-privacy-and-compliance' | 'accessibility-and-inclusion'
  | 'emerging-and-agentic' | 'market-and-economics'

/** Provenance badge. `gap` is a first-class value, not an absence — see §4. */
export type StatKind = 'independent' | 'vendor' | 'industry-range' | 'analyst' | 'gap'

export interface Stat {
  claim: string             // "68.01% of US Google searches ended without a click"
  value?: string            // "68.01%"   — pulled out for the figure primitives
  context?: string          // "up from 60.45% in 2024"
  source?: string           // "SparkToro with Similarweb data"
  year?: number             // 2026
  kind: StatKind
  note?: string             // the "cite with its date" / "treat as directional" caveat
}

export type RelationKind =
  | 'broader' | 'narrower' | 'contrast'      // "often confused with"
  | 'prerequisite' | 'component' | 'metric-for'

export interface Relation { kind: RelationKind; slug: string; why?: string }

export interface GlossaryTerm {
  slug: string
  term: string                    // "Time to first audio"
  abbr?: string                   // "TTFA"
  alsoKnownAs?: string[]          // aliases + the other spelling (D4)
  category: CategorySlug
  wave: 1 | 2 | 3 | 4 | 5

  /** The AEO snippet. ONE plain sentence. Rendered top-of-page in <CiteBlock>. */
  oneLine: string

  definition: {
    plain: string[]               // 1–2 sentences, plain English
    deeper: Block[]               // practitioner explanation (prose + lists)
    misconception: string
    whyItMatters: string
    voiceNote?: string            // the "voice-specific note" several entries carry
    asqvoxRelevance?: string      // ALWAYS rendered in a labelled disclosure block
  }

  visual: VisualSpec              // §7 — discriminated union over the 8 primitives
  stats: Stat[]
  examples: { technical: string; business: string }
  usage: {
    whoSaysIt: Block[]
    whereItAppears: Block[]       // "in an RFP" / "in a spec sheet"
    commonMisuse: Block[]
  }
  faq?: Array<readonly [string, string]>   // tuple shape — feeds buildFaqLd() directly
  relations: Relation[]

  reviewedAt: string              // ISO — drives dateModified + the stale filter
  nextReviewAt?: string           // quarterly for waves 3+5 per the build guide
}
```

Two fields exist purely to stop the thing rotting:

- **`reviewedAt`** feeds schema `dateModified` **and** the sitemap's `lastModified`. Note this is a
  real win: `scripts/generate-lastmod.mjs` returns `null` for every static route because there is no
  `.git` inside the Docker build context — but glossary pages carry a genuine date from their own
  data, exactly as blog posts do.
- **`nextReviewAt`** makes the build guide's mandated quarterly refresh a queryable field rather than
  a good intention. Surface an "overdue review" list on the hub in dev, or as a lint warning.

### Storage: in-repo generated (Phase 1) → CMS (Phase 2, if needed)

**Recommendation: mirror the blog's *seed* pipeline, not its CMS.**

The blog already proves the pattern — `src/scripts/blog-seed/*.md` → `gen-blog-seed-data.ts` →
committed pre-sanitized `blog-seed-data.ts`. Apply the same idea to the glossary, but land the
generated module in the marketing package instead of the backend:

```
C:\DD\AsqVox\Definitions\ASQVOX_GLOSSARY_STAGE*.md      ← you keep authoring here
        │  scripts/gen-glossary.mjs  (parses the fixed 5-section structure)
        ▼
site-v0/lib/glossary/terms.generated.ts                 ← committed, typed, server-safe
beta-v1/lib/glossary/terms.generated.ts                 ← identical copy
```

| | In-repo generated | Mirror the blog CMS |
|---|---|---|
| Build cost, Phase 1 | ~3 days | ~2 weeks (migration 0036 + module + admin page + 2× routes) |
| Rendering | fully static via `generateStaticParams` — best CWV | ISR, 300s revalidate |
| Interlinking / auto-linker / sidebar index | trivial — whole dictionary is a static import | needs a second fetch per render (cacheable, but more moving parts) |
| Edit without deploy | ✗ needs `docker compose build marketing` | ✓ |
| Non-technical editing | ✗ | ✓ TipTap admin |
| Cross-link lint at build | ✓ | harder |

The deciding factor: **interlinking is the stated priority**, and every page needs the *whole*
dictionary to render its related-terms block, auto-link its prose, and build its sidebar index. That
is what a static import is for. The rebuild cost is one already-routine command
(`docker compose build marketing && up -d marketing`).

Promote to a CMS at Stage 2+ **if and only if** editing-without-deploy becomes a real bottleneck. The
typed shape above maps 1:1 to a `glossary_terms` table (jsonb for `stats`/`relations`/`visual`), so
the migration is mechanical — and it would be migration **0036** (journal head is currently 0035).

> **Hard constraint (production incident, do not violate):** `terms.generated.ts` and everything in
> `lib/glossary/` **must not** carry `'use client'`. Exporting plain data from a client module and
> importing it into a Server Component makes Next wrap it in a client-reference Proxy; calling
> `.map()` on it throws at render. This 500'd the homepage on 2026-06-09 (Error Digest `330002672`,
> shipped in `1cbda0f`, hotfixed by relocating the array to `lib/faqs.ts` — the incident is written
> up at `site-v0/lib/faqs.ts:22-42`). The client sidebar and the Server Component page both import
> the same dictionary, so this is exactly the shape that broke before.

---

## 4. The page template

Section order is optimised for extraction, not for narrative. The build guide's core instruction —
*"keep one 'definition + source + year' line near the top of each page so answer engines can lift a
clean, attributable snippet"* — is element #2, above everything else.

```
┌─ Breadcrumb: Home › Glossary › Latency & Performance › Time to first audio
│
├─ 1  H1  "Time to first audio (TTFA)"
│        category chip · abbr · "also called…" aliases
│
├─ 2  <CiteBlock>            ★ THE AEO UNIT
│        one-sentence definition · primary source + year · Last reviewed <date>
│        visually calm, machine-liftable, mirrored verbatim into DefinedTerm.description
│
├─ 3  "Not to be confused with"   → inline contrast links (TTFA ↔ TTFT, containment ↔ deflection)
│
├─ 4  Definition
│        plain (1–2 sentences) → deeper practitioner explanation → misconception
│        → why it matters commercially → voice-specific note
│        → ┌ AsqVox relevance ┐  clearly labelled disclosure block, never inline prose
│
├─ 5  Visual              — the figure (§7). Above the statistics, always.
│
├─ 6  Statistics          — one row per stat, each with a provenance badge:
│        [independent] [vendor claim] [industry range] [analyst forecast] [no reliable figure]
│
├─ 7  Examples            — two cards: technical · what a business owner recognises
│
├─ 8  Usage               — who says it · where it appears in an RFP · common misuse
│
├─ 9  Related terms       — typed relation blocks (§5)
│
├─ 10 FAQ                 — 2–4 Q&A → FAQPage schema via existing buildFaqLd()
│
└─ 11 CTA                 — "Ask the Orb about this" (§7.4). One CTA. Restrained.
```

### Why the statistics rendering matters more than it looks

Stage 1 contains, by my count, **nine explicit "honest gap" statements** — places where the research
concluded no reliable figure exists and instructs the page to say so ("There is no reliable published
benchmark for website voice agent adoption… Say this plainly rather than reaching for a number").

Render those as **first-class rows with a `no reliable figure exists` badge**, not as omissions. Two
reasons, both concrete:

1. It is the differentiator the build guide itself identifies: *"Pages that admit their gaps are more
   citable than pages that fill them with vendor numbers, and the whole point of this glossary is to
   be the thing that gets cited."*
2. `[vendor claim]` vs `[independent]` badging is a visible, scannable trust signal no competitor
   glossary has — and it maps directly onto schema `citation` entries.

This is only possible because `Stat.kind` is typed (§3). Free rich text cannot do it.

---

## 5. Interlinking — the system, not ad-hoc links

You flagged this as very important. Four mechanisms, layered.

### 5.1 Typed relations, rendered as labelled blocks

Generic "related posts" is near-worthless for both readers and crawlers. The source documents are
already written as explicit relationships — mine them into `relations[]` and render each kind with
its own heading:

| Kind | Rendered heading | Stage 1 examples |
|---|---|---|
| `contrast` | **Often confused with** | TTFA ↔ TTFT · containment ↔ deflection · website voice agent ↔ voice search · AEO ↔ GEO · grounding ↔ accuracy · widget ↔ agent |
| `prerequisite` | **Read this first** | RAG → Grounding |
| `broader` | **Part of** | TTFA → Latency & Performance · voice widget → website voice agent |
| `narrower` | **Includes** | voice agent → {voice bot, IVR, agentic voice} |
| `component` | **Made up of** | TTFA → {endpointing, ASR, retrieval, TTFT, TTS TTFB} |
| `metric-for` | **Measures** | containment → contact-centre automation |

`contrast` is the highest-value type — it captures precisely the confusions the research documents
spend their word count correcting, and "X vs Y" is a real search intent.

**Enforce bidirectionality**: if A contrasts B, B contrasts A. A lint pass (§5.4) fails the build
otherwise.

### 5.2 Automatic in-body cross-linking

Hand-maintaining prose links across 150 pages guarantees rot. Instead, a build-time linker walks the
term dictionary and links the **first mention only** of any other term in the body prose.

Rules (all of these matter, and all are cheap to implement once):
- first occurrence per target per page, max ~1 link per target
- never self-link; never link inside headings, the `<CiteBlock>`, or the AsqVox disclosure block
- longest-match-first so "website voice agent" wins over "voice agent"
- match `alsoKnownAs` aliases and the abbreviation (`TTFA`)
- a per-page cap (~12 auto-links) so a dense page doesn't become a link farm

### 5.3 Hub-and-spoke

- **`/glossary`** — every term reachable in one hop. A–Z jump bar + category grouping + client-side
  text filter. This is the crawl entry point; keeps every term at depth 2 from the homepage.
- **`/glossary/topic/<category>`** — the hub in hub-and-spoke. Lists its terms with one-line
  definitions (reuse `oneLine`), links to sibling hubs, and is linked *from* every term in it via
  the `broader` relation and the breadcrumb.
- **Inbound from existing content** — link glossary terms from `/how-it-works`, `/about`, and blog
  articles. Currently `/blog` article bodies are the natural host (see the trap below).

### 5.4 Link-graph lint (there is no test suite — this must be a script)

`scripts/lint-glossary.mjs`, run in `prebuild` alongside `generate-lastmod.mjs`:

- every `relations[].slug` resolves to a real term → else fail
- every `contrast` is bidirectional → else fail
- no orphans: every term is linked *from* ≥3 others
- no dead ends: every term links *to* ≥3 others
- every `Stat` with `kind !== 'gap'` has `source` **and** `year` → else fail
- slug uniqueness; slug matches `/^[a-z0-9]+(?:-[a-z0-9]+)*$/` (same regex as `blog.schemas.ts:18`)
- `nextReviewAt` overdue → warn, don't fail

### ⚠️ 5.5 The sanitizer trap (only if you choose the CMS route, D3)

`aiw-voice-backend/src/lib/html/sanitize.ts:49-60` **rewrites every `<a>`** to
`rel="nofollow noopener" target="_blank"`. That is correct for untrusted blog body content — and
actively harmful for glossary interlinking, which depends on internal link equity flowing between
150 pages. `nofollow` on internal links throws that away, and `target="_blank"` on an internal
cross-reference is hostile UX.

If the glossary ever moves to the CMS, it needs its **own sanitizer profile** that leaves
same-origin `/glossary/*` links bare. With the in-repo route (recommended) the problem doesn't
arise — links are React components, never sanitized HTML.

Same note applies to linking glossary terms *from* blog bodies: those links will be `nofollow`ed by
the existing sanitizer. Prefer a structural "Related definitions" block on blog articles over inline
body links.

---

## 6. The right-hand sidebar index

Two tiers in one sticky rail, which is what makes it useful for both "where am I on this page" and
your stated "navigate through definitions in categories easily".

```
┌──────────────────────────┐
│ ON THIS PAGE             │  ← tier 1: on-page TOC, scroll-spy active state
│   Definition             │
│ ▸ Visual                 │     active marker = --yellow-300, the only colour in the rail
│   Statistics             │
│   Examples               │
│   Usage                  │
│   Related terms          │
│   FAQ                    │
├──────────────────────────┤
│ LATENCY & PERFORMANCE    │  ← tier 2: category index. Current category EXPANDED.
│   End-to-end latency     │
│ • Time to first audio    │     current term marked, aria-current="true"
│   Barge-in latency       │
│   Jitter                 │
│                          │
│ + Voice AI Fundamentals  │  ← sibling categories collapsed, term count shown
│ + Speech Technology      │
│ + …9 more                │
├──────────────────────────┤
│ Ask the Orb about this ▸ │
└──────────────────────────┘
```

### Implementation — reuse what exists

**The sticky right-rail CSS already exists and works.** `styles/blog.css:139-150` + `397-400`:

```css
.blog-article__wrap { max-width:1140px; margin-inline:auto; padding:120px 24px 40px;
  display:grid; grid-template-columns: minmax(0,1fr) 280px; gap:56px; }
.blog-article__main { min-width: 0; }          /* the min-width:0 is load-bearing */
.blog-sidebar { position: sticky; top: 96px; }
```
…with the mobile collapse at `blog.css:492-502` (`grid-template-columns: 1fr; position: static`).
Copy this shape into `styles/glossary.css` scoped under `.glossary-page`. Widen the rail to ~300px
for the two-tier content.

**The scroll-spy already exists too — but only as vanilla JS**, inside `public/developers.html:818-841`:

```js
var spy = new IntersectionObserver(function (entries) { /* … */ },
  { rootMargin: '0px 0px -72% 0px', threshold: 0 });
```
plus `active.scrollIntoView({ block: 'nearest' })` to keep the active link visible in a long rail.
**There is no React port anywhere in the repo** — port these exact parameters into a small
`'use client'` island (`components/glossary/GlossaryToc.tsx`). The `-72%` bottom margin is tuned;
don't re-derive it.

**An A–Z / filterable index does not exist** and must be built for the hub. Nearest precedent is
`components/BlogList.tsx:82-101` (chip group + `useState` + `useMemo`) — same idea, more rows.

### Boundaries and accessibility

- Tier 2 (the category index) is **pure data → keep it a Server Component**, exactly like
  `blog-sidebar` is today. Only tier 1's scroll-spy needs `'use client'`.
- `<nav aria-label="On this page">` / `<nav aria-label="Glossary categories">`;
  `aria-current="true"` on the active term and active section.
- `html { scroll-behavior: smooth }` is already set (`developers.html:33`); honour the existing
  `@media (prefers-reduced-motion: reduce)` block at `globals.css:298-305`.
- Mobile: tier 1 becomes a collapsed "On this page" `<details>` under the H1 (zero-JS, the pattern
  already used for blog FAQ at `app/blog/[slug]/page.tsx:176-180`); tier 2 moves below the article.

---

## 7. The visual system — 8 primitives, not 150 illustrations

Every entry ships a fully art-directed VISUAL spec. Producing 150 of those by hand in Canva is
roughly 150 × 40 minutes of irreversible work. Producing them as **data fed into a small library of
figure primitives** is 8 components plus a data blob per term.

### 7.1 Why components, not Canva PNGs (D2)

| | React + inline SVG/CSS | Canva / AI raster |
|---|---|---|
| **Text inside the figure is crawlable + citable** | ✓ real text | ✗ invisible to answer engines |
| Quarterly stat refresh (mandated for waves 3+5) | edit one number in data | re-export by hand, ×N |
| Design-system compliance | tokens enforced in code | manual, drifts |
| Page weight / CLS | ~2–4 KB inline, no layout shift | 100–400 KB per image |
| Accessibility | `<title>`/`<desc>`, real contrast | alt text only |
| Responsive | reflows | fixed raster |

The first row is decisive. The entire strategic purpose of this glossary is to be **cited by AI
answer engines**. A statistic rendered inside a PNG is invisible to them — so a Canva infographic
carrying the TTFA budget numbers actively removes the page's most citable content from the page.

### 7.2 Mapping Stage 1's ten specs onto primitives

| # | Term | Spec title | Primitive |
|---|---|---|---|
| 1 | Voice agent | Anatomy of a voice agent | `<PipelineFigure>` + `<ContrastRows>` |
| 2 | Website voice agent | Two homes for a voice agent | `<SplitCompare>` |
| 3 | Voice widget | What is inside a voice widget | `<LayerStack>` |
| 4 | RAG | How RAG answers a spoken question | `<CycleFlow>` |
| 5 | Grounding | Grounded against ungrounded | `<ContrastPanels>` |
| 6 | TTFA | Where the milliseconds go | `<BudgetBar>` ★ |
| 7 | Containment rate | Containment, honestly measured | `<LeakyFunnel>` |
| 8 | Zero-click search | The clicks are going away | `<TrendChart>` + `<AbsorptionFunnel>` |
| 9 | AEO | Ranking vs being cited | `<ContrastPanels>` (variant) |
| 10 | Voice nav accessibility | Voice widens the door | `<ConvergeFigure>` |

Eight primitives cover all ten, and spot-checking the remaining ~140 term list in the build guide,
they cover the large majority of what's coming (latency budgets, vs-comparisons, pipelines, funnels,
trends recur constantly). Two shared conventions every primitive implements:

- **the takeaway line** — every spec ends with one (*"The model is one block of five. The other four
  decide whether it feels human."*). Make it a required prop, rendered in a consistent bottom band.
- **yellow as punctuation only** — one accent per figure, matching the design system's hard rule.
  Never a fill, never text.

### 7.3 What I'll produce, and what you'll paste elsewhere

| Tier | Share | Who makes it | How |
|---|---|---|---|
| **1 — primitives** | ~80% | **I write them** | React + inline SVG/CSS, design-system tokens |
| **2 — bespoke SVG** | ~15% | **I write them** | One-off hand-authored SVG where no primitive fits (e.g. RAG's circular flow with side rail) |
| **3 — Mermaid** | drafting only | I generate, you eyeball | Fast structural draft to agree a diagram *before* I hand-write the SVG. Artifacts render Mermaid natively, so I can show it inline. |
| **4 — Canva / AI image** | ~0% of data figures | **you**, with a prompt I write | Only for genuinely editorial art (a section hero). **Never** for anything carrying a statistic. |

**OG / social images: fully automated.** Next's `ImageResponse` via
`app/glossary/[slug]/opengraph-image.tsx` generates a unique 1200×630 card per term at build time
from the same tokens — 150 branded OG images for zero manual work. This also closes known-drift item
#5 in the design-system doc ("OG image is a placeholder").

### 7.4 The unfair advantage: the figure that talks

Each term page gets an **"Ask the Orb about this"** CTA that opens the live widget. On a glossary
about voice AI, the product *is* the demonstration — and it's the natural home for the `to vox it`
verb the Website-voice-agent entry introduces.

Implementation is already solved; copy `components/home/HomeClient.tsx:27-45`: feature-detect
`window.AIWWidget`, call `w.open(e.currentTarget)`, fall back to `DemoModal`. **`open()` must be
called synchronously inside the real user gesture** — no `await` before it, or it throws
(`AIWWidget.open() rejected - must be called synchronously from a real user gesture`). The widget is
already mounted globally at `app/layout.tsx:301`, so there is nothing to add per page.

---

## 8. Structured data, AEO/GEO and discovery

### Schema.org per surface

| Surface | Emit |
|---|---|
| `/glossary` | `DefinedTermSet` (`@id: …/glossary#termset`) + `CollectionPage` + `BreadcrumbList` |
| `/glossary/topic/<cat>` | `CollectionPage` + `BreadcrumbList` + `hasPart` → member terms |
| `/glossary/<term>` | `DefinedTerm` (`inDefinedTermSet` → the set `@id`, `name`, `termCode` = abbr, `description` = `oneLine`) · `BreadcrumbList` (4 levels) · `FAQPage` via `buildFaqLd()` · `WebPage` with `dateModified` from `reviewedAt` · `citation[]` built from `stats[]` · `ImageObject` for the OG card |

Follow the existing `@id`-reference convention rather than inlining entities: blog does
`publisher: { '@id': \`${MARKETING_BASE_URL}/#organization\` }` pointing at the Organization node
declared once in `app/layout.tsx:146-194`. Do the same.

Render via the local `Ld` component pattern from `app/blog/[slug]/page.tsx:105`.

**`BreadcrumbList` resolves a parked item.** CLAUDE.md's SEO audit deferred S-05 —
*"BreadcrumbList deferred until URL structure deepens (today's URLs are 1-level)"*. The glossary is
that deepening. Ship it here.

### `llms.txt` — must become generated

`site-v0/public/llms.txt` is a **hand-maintained 24-line static file**, and it is *already stale*:
it lists `/pricing` (parked in site-v0) and omits `/blog`, `/waitlist`, `/developers`, `/terms`,
`/privacy-policy`. Adding 150 glossary URLs by hand is not viable.

Convert it to a generated route (`app/llms.txt/route.ts`) that emits a `## Glossary` section from the
term dictionary — one `[Term](url): oneLine` per entry. That is precisely the file's purpose, the
build guide explicitly asks for "an llms.txt content map", and it fixes the existing staleness as a
side effect.

### Sitemap

Add glossary entries to `app/sitemap.ts` alongside the existing static + blog blocks. With in-repo
content this is a static map over the dictionary with **real `lastModified` from `reviewedAt`** — no
API call, no 500-post ceiling like the blog loop, and no dependence on the broken git-lastmod path.

Also add `/glossary` to `MARKETING_ROUTES` in `lib/site.ts:97-117` (drives nav automatically, both
desktop and mobile drawer) **and** separately to `SiteFooter.tsx`'s local `cols` array at lines
21-47 — the footer does *not* read `FOOTER_ROUTES`, so it is a second, independent edit. And add a
`ROUTE_FILES` entry in `scripts/generate-lastmod.mjs:45-56`.

### Per-page AEO checklist (mechanical, enforceable)

- `oneLine` answers the question in the **first sentence**, before elaboration
- headings are question-shaped where natural (`How is TTFA different from TTFT?`)
- every statistic carries source + year inline, visible, not in a footnote
- `dateModified` is real and recent
- the figure's text is real text, not an image
- self-contained: the page makes sense lifted out of context

---

## 9. Build sequence

### Phase 0 — decisions + one proof page (½ day)
Lock D1–D6. Build **one** term end-to-end (`time-to-first-audio` — it has the richest visual and the
hardest numbers) with the full template, sidebar, `<BudgetBar>`, and schema. Review it live before
scaling. Nothing else starts until this page is approved.

### Phase 1 — Wave 1, the 10 pages (~3 days)
```
site-v0/  (then copied byte-identical to beta-v1/)
  lib/glossary/types.ts                  the typed model (§3)
  lib/glossary/terms.generated.ts        generated from your markdown
  lib/glossary/categories.ts             the 11 categories (§2)
  lib/glossary/graph.ts                  relation resolution + auto-linker (§5.2)
  app/glossary/page.tsx                  hub: A–Z + category + filter
  app/glossary/[slug]/page.tsx           term page  (generateStaticParams)
  app/glossary/[slug]/opengraph-image.tsx  auto OG card
  app/glossary/topic/[category]/page.tsx   category hub
  components/glossary/GlossaryToc.tsx    'use client' scroll-spy (tier 1)
  components/glossary/CategoryIndex.tsx  server component (tier 2)
  components/glossary/CiteBlock.tsx      the AEO snippet
  components/glossary/StatRow.tsx        provenance badges
  components/glossary/figures/*.tsx      the 8 primitives (§7.2)
  styles/glossary.css                    scoped under .glossary-page

repo root
  scripts/gen-glossary.mjs               markdown → terms.generated.ts
  scripts/lint-glossary.mjs             the link-graph gates (§5.4)
```
Plus: `lib/site.ts` route entry, `SiteFooter.tsx` link, `sitemap.ts` block, `generate-lastmod.mjs`
entry, generated `llms.txt`.

### Phase 2 — Waves 2–5, scale to ~150 (ongoing)
Content-only per wave: extend `terms.generated.ts`, add any new figure primitive, run the lint. No
new architecture.

### Phase 3 — optional CMS promotion
Only if editing-without-deploy becomes the bottleneck. Migration **0036** (`glossary_terms` +
`glossary_categories`), module mirroring `src/modules/blog/`, admin page mirroring the ~640-line blog
block in `admin/index.html`, and a **glossary-specific sanitizer profile** (§5.5).

### Deploy
Marketing-only, per env, one service at a time, disk-headroom preflight:
```bash
cd /opt/aiw/dev && docker compose build marketing && docker compose up -d marketing
```
No migration, no SPA change, no widget change, no nginx change in Phases 1–2.

---

## 10. Conventions this plan must obey (and the traps it avoids)

| # | Constraint | Where it bites |
|---|---|---|
| 1 | **No `'use client'` on the term dictionary** — RSC client-reference Proxy 500'd the homepage on 2026-06-09 (`lib/faqs.ts:22-42`) | The sidebar filter and the page both import it |
| 2 | **Two token layers, two yellows** — `globals.css` `--color-yellow:#feca00` is wordmark-reserved; `/about` + `/how-it-works` actually use page-scoped `--yellow-300:#FFDB6A`. Redeclare the ink+yellow block on `.glossary-page`, as `hiw.css:14-26` does | Every figure and the sidebar active state |
| 3 | **Specs say Poppins; the site is Urbanist + DM Sans** (next/font, `app/layout.tsx:44-55`) | All 150 visuals — D1 |
| 4 | **Plain global CSS scoped by a wrapper class.** No Tailwind, no CSS modules, no PostCSS anywhere in the package | `styles/glossary.css` under `.glossary-page` |
| 5 | **`buildPageMetadata` is mandatory** — shallow metadata merge silently inherits the homepage `og:url` (`lib/site.ts:178-210`) | Every new route |
| 6 | **Build in both `site-v0/` and `beta-v1/`**, byte-identical | Or it vanishes when `MARKETING_PKG` flips |
| 7 | **Sanitizer forces `nofollow` + `target=_blank` on every `<a>`** (`sanitize.ts:49-60`) | Only if D3 → CMS; also affects glossary links placed in blog bodies |
| 8 | **Footer is a second, independent edit** — `SiteFooter.tsx` ignores `FOOTER_ROUTES` | Nav shows up, footer silently doesn't |
| 9 | **`robots.txt` is shadowed on prod cPanel** by an EA-nginx `location = /robots.txt`; the live file is `deploy/cpanel-docroots/asqvox/robots.txt` | If robots ever needs a glossary rule, edit both |
| 10 | **No test suite exists.** The lint script *is* the safety net | §5.4 must be wired into `prebuild` |
| 11 | **Claim discipline** — AsqVox live features only; Hindi/Hinglish is roadmap, never live | The `asqvoxRelevance` block on every page |
| 12 | Next migration index is **0036**; hand-authored SQL only, `db:generate` is forbidden | Phase 3 only |

---

## 11. Open questions for you

1. **D1–D6 above.**
2. **Do you have the remaining ~140 terms already drafted**, or is Stage 1 the only finished batch?
   It changes whether the generator parses one file shape or needs to tolerate drift across five.
3. **Who maintains the quarterly stat refresh** (build guide mandates it for fraud/funding/regulatory
   numbers)? If it's you in a markdown file, in-repo is right. If it's someone non-technical, that
   pulls Phase 3 forward.
4. **Is `to vox it` a public brand play?** If yes it deserves its own glossary entry and a
   deliberate internal-link position, not just a footnote in the Website-voice-agent page.

---

*Plan written 2026-07-30 against `main` @ `f533462`. Source content: `C:\DD\AsqVox\Definitions\`.
Nothing implemented.*
