# SPA architecture

The client-rendered path: a static host, Vite or vanilla. The framework is SPA-native — components register at load, the page is one document, routing and state live in the browser. Inside Next/Nuxt/SvelteKit/Astro instead? That's the **SSR** path — [ssr-integration.md](ssr-integration.md); the two diverge sharply on registration, routing, and state. (Claims re-verified against the kit's live source 2026-08-26; re-verify on a MINOR cut.)

## The host document

One static `index.html` whose job is to load CSS in cascade order and register components once. npm-consumer paths, which is what `adia-scaffold spa` emits (pre-wired for Vite — `vite.config.js` + `package.json` included):

```html
<!doctype html>
<html lang="en" data-theme="auto">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="stylesheet" href="./index.css" />                              <!-- page framing: sizes + centers the surface -->
  <link rel="stylesheet" href="./components/my-surface/my-surface.css" />   <!-- the surface's own chrome -->
  <!-- its FIRST imports are the foundation + barrel CSS, then the registration barrel -->
  <script type="module" src="./components/my-surface/my-surface.js"></script>
</head>
<body>
  <my-surface scale="ui-sm"></my-surface>
</body>
</html>
```

```js
// components/my-surface/my-surface.js
import '@adia-ai/web-components/styles/host.css';   // foundation: tokens + resets + page frame
import '@adia-ai/web-components/styles/index.css';  // barrel: every component's CSS (both, in order)
import '@adia-ai/web-components/styles/scale.css';  // opt-in sizing register (only if a surface uses [scale])
import { defineIfFree } from '@adia-ai/web-components/core/register';
// … the rest of the registration barrel + your surface's custom-element class
```

**A raw `/node_modules/...` `<link>`/`<script src>` 404s silently.** Vite's own consumer
`vite.config.js` sets `root: 'src'` (`adia-scaffold spa`'s emitted config) — a *static* URL
resolves against that root, where `node_modules` doesn't exist, and Vite's SPA-fallback
middleware serves `index.html` back with a 200 instead of a real 404 (gh#1149). Bare
`@adia-ai/*` specifiers in an actual `import` statement don't have this problem: Vite's MODULE
resolver (not its static file server) walks `node_modules` the normal Node way, root-independent
— which is why CSS, like JS, is imported from a `.js` file, never linked by a raw path. Two
alternatives:

- **Import-map / CDN (no bundler):** a `<script type="importmap">` mapping `"@adia-ai/web-components"` to a CDN URL (e.g. `https://esm.sh/@adia-ai/web-components`); there's no module graph to carry a JS-imported CSS file without a bundler, so link the CSS directly via real `<link>` tags pointing at the same CDN location instead.
- **Monorepo dev server (framework contributors only):** `/packages/web-components/styles/*.css` + `/packages/web-components/index.js` paths resolve because the monorepo's own vite dev server roots at the repo itself — not a consumer deployment mode.

## Registration & cascade invariants

**Cascade order is load-bearing** (later wins): foundation → barrel → register → page → component.

- Import **both** `host.css` and `styles/index.css` (or the combined `@adia-ai/web-components/css` barrel, which `@import`s them in that order). The styles barrel is split: `host.css` carries only the foundation (tokens + resets + page frame), so importing it alone renders primitives unstyled.
- CSS arrives via a JS side-effect **import**, mirroring how the registration barrel itself resolves — a raw `<link href="/node_modules/...">` 404s silently under Vite's own SPA fallback (gh#1149); import it from the same `.js` file that does the registration instead.
- **One registration script** — the side-effecting barrel `import '@adia-ai/web-components'`. Don't piecemeal-import primitives: composites render internal `*-ui` tags (e.g. `chat-input-ui` internally renders `textarea-ui` + `select-ui`) that stay unregistered and collapse to 0px unless the barrel ran.
- Bespoke shell children need the **cluster barrel** (`@adia-ai/web-modules/shell`, `/chat`, `/editor`, `/simple`) — importing `admin-shell.js` alone registers only the host tag, not `admin-sidebar` / `admin-page` / the other children.
- Never hand-roll `:where(html,body){}` — the foundation owns the page frame; re-rolling it drifts from the system (the classic serif-leak bug).
- A `[scale]` register has two halves: link `scale.css` **and** put `scale="…"` (one of the six tiers — `ui-sm | ui-md | ui-lg | content-sm | content-md | content-lg`) on the surface. One without the other is a no-op.
- `themes.css` (named palettes) is **not** in the styles barrel — link it separately. `data-scheme` switches light/dark; `data-theme` picks the named palette.
- Guards: `defineIfFree(tag, ctor)` (`core/register.js`) for defines; a `#booted` flag in `connected()` — the callback re-fires on DOM moves. And `customElements.whenDefined(name)` never rejects: a `Promise.all([...whenDefined])` boot gate hangs forever on one unimported tag — chrome renders (tag-keyed CSS), the page stays "empty", zero console errors.

## The surface container

An app surface is a self-booting custom element — it fetches its data and renders its own subtree in `connected()`:

```js
connected() { if (this.#booted) return; this.#booted = true; this.#load(); }
```

Standalone demo/playground pages use the page-trio instead; the trio/DUO decision table, the four-axis layout, and the structure rubric live in [project-shapes.md](project-shapes.md) (owned by `project-scaffolding`).

## Routing — content-less `<router-ui>`

`router-ui` (registered by the barrel) drives in-DOM tabs. Give it routes **without** `content` so it leaves your stamped children intact and only reflects `data-route-path` on itself; CSS shows the active view:

```js
router.routes = [{ path: '/live' }, { path: '/briefing' }];   // content-LESS
router.navigate('/live');
```

```css
my-surface .view { display: none; }
my-surface router-ui[data-route-path="/live"] .live { display: flex; }
```

A **content-mode** route (one carrying `content`) makes the router fetch and `innerHTML`-replace — which wipes stamped views, scroll, and focus. That's the wrong tool for in-DOM tabs. Never `innerHTML` a view on switch; show/hide.

## Data & state

Owned by `data-wiring` — see [data-and-hydration.md](data-and-hydration.md) for the six data-flow patterns, the DataClient → projection pipeline, the attribution gate (`mutate` throws without an `action_source`), and single-owner state (the route owns the active view; a control mutates the route, an observer/CSS reflects it back — never a second source of truth).

## Exit gate

A surface isn't done when it compiles — it's done when it passes the **browser gate**: zero console errors on load, non-zero bounding boxes, and the screenshot actually read. That gate is the `surface-qa` skill.
