diff --git a/.gitignore b/.gitignore
index c31aebb..d799a1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,6 +74,13 @@ __screenshots__
.claude/tools/node_modules
/libs/ui/scripts/output/*
/libs/ui/.size-limit.json
+/libs/web-components/bundle-size-report.json
+
+# Generated by `npm run check:web-components-ssr` — regenerated on every run, so tracking them
+# makes the working tree dirty for anyone who runs the check. The command that produces them is
+# step 6 of the walkthrough in the root README.
+/libs/web-components/harness/ssr-dsd-static.html
+/libs/web-components/harness/ssr-dsd-hydrated.html
# Vitest artifacts
.vitest-artifacts
@@ -84,3 +91,10 @@ plans/
.mcp.json
AGENTS.md
/stats.html
+/libs/web-components/screenshots/
+
+# CTORNDSD-646b platform fixtures — build output and generated types only; the fixture
+# sources themselves are tracked. See docs/webcomponents-migration/08-react-and-nextjs.md.
+/fixtures/*/.next/
+/fixtures/*/next-env.d.ts
+.vitest-attachments
diff --git a/.nxignore b/.nxignore
new file mode 100644
index 0000000..fe0f9e3
--- /dev/null
+++ b/.nxignore
@@ -0,0 +1,5 @@
+# CTORNDSD-646b: throwaway platform fixtures live here. Next.js generates
+# `fixtures/next-ssr-check/.next/dev/package.json` with no `name` field, which makes Nx fail to
+# process the project graph for the ENTIRE repo ("The projects in the following directories have no
+# name provided"). Every `nx` command — lint, build, test — breaks until this is excluded.
+fixtures/**
diff --git a/.prettierignore b/.prettierignore
index 4c6aead..6d5d6c8 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -8,6 +8,8 @@ dist/
libs/ui/src/stories
libs/ui/scripts/
libs/ui/stats.html
+libs/web-components/harness/ssr-dsd-static.html
+libs/web-components/harness/ssr-dsd-hydrated.html
package.json
package-lock.json
.github/
diff --git a/README.md b/README.md
index e1d418f..57d956a 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,13 @@ Nx monorepo containing the GridKit design system packages.
| `gd-form-configurator` |  | JSON-Schema form engine (AJV + Zustand) |
| `gd-form-configurator-react` |  | React bindings for gd-form-configurator |
+Not published, and under active investigation:
+
+| Package | Description |
+| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `gd-design-core` (`libs/design-core`) | Framework-agnostic state stores + token resolution. No React, no Lit, no `gd-design-library` dependency |
+| `web-components` (`libs/web-components`) | Lit custom-element port of 5 GridKit atoms. `private: true` — see [Web Components spike](#web-components-spike-ctorndsd-646) |
+
## Quick start
Install the primary package:
@@ -82,3 +89,197 @@ npm run build:form-configurator && npm run publish:form-configurator
```bash
npm run crc ComponentName
```
+
+## Web Components spike (CTORNDSD-646)
+
+An investigation into porting GridKit from React to Lit custom elements. **Every demo below runs from
+the repo root — you never need to `cd` into a package or fixture.**
+
+### One-time setup
+
+```bash
+npm install # if you haven't already
+npm run demo:setup # builds dist/ + installs the two fixtures (~2-3 min)
+```
+
+`demo:setup` builds `gd-design-library`, `gd-design-core`, and `web-components`, then installs
+`fixtures/react19-check` and `fixtures/next-ssr-check`. Some demos need those build artifacts; the
+demo index tells you which.
+
+### Test it step by step
+
+Twelve steps, in order, all from the repo root. Each one lists the command, what you should see, and
+which finding it proves. **Steps 1–6 are non-interactive** — run them and read the terminal. **Steps
+7–11 open a browser.** Step 12 runs the whole automated set at once.
+
+If you only have five minutes, run **step 12**.
+
+#### 1. Type-check
+
+```bash
+npm run type-check:web-components
+echo $? # 0
+```
+
+**Success is silent** — `tsc` prints nothing and exits `0`. Any output at all means a failure. This
+checks two projects: the shipped library and the harness. The harness one was broken for a while and is
+now covered.
+
+#### 2. Lint
+
+```bash
+npx nx lint web-components
+npx nx lint design-core
+```
+
+Expect `Successfully ran target lint` from both, with no rule violations listed above it. `nx` prints a
+problem count only when there are problems.
+
+#### 3. Framework-agnostic core tests
+
+```bash
+npm run test:design-core
+```
+
+Expect **73 passed (6 files)**. Includes the `resetTo` action added because form reset was silently
+keeping a checkbox checked — see `FINDINGS.md` §17.1.
+
+#### 4. Component tests in a real browser
+
+```bash
+npm run test:web-components
+```
+
+Expect **35 passed (4 files)**, running in real Chromium. This is the suite that found the `gd-input`
+accessibility bug on its first run (§18.2). It covers the Input cursor guard, the checkbox
+`attribute: false` constraint, form participation, the shared-stylesheet cache, and axe.
+
+> Real Chromium is a constraint, not a preference — jsdom does not reliably implement Constructable
+> StyleSheets, the `popover` attribute, or Declarative Shadow DOM.
+
+#### 5. Bundle size and the regression gate
+
+```bash
+npm run check:web-components-size
+```
+
+Prints a per-atom Lit-vs-React table, then expect `✓ no bundle-size regressions`. Ballpark: 5 atoms at
+**~11 kB** gzip (**~18 kB** including the `lit` runtime) against **~106 kB** for React+Emotion —
+§3, §18.5.
+
+Exact totals drift by a few dozen bytes between builds because Rollup redistributes shared-helper bytes
+between chunks whenever any one chunk changes. That is why the gate's tolerance is 10% rather than 0 —
+see [`10-performance-report.md`](./docs/webcomponents-migration/10-performance-report.md).
+
+To prove the gate actually fails, edit a number down in
+`libs/web-components/bundle-size-baseline.json` and re-run: it should exit `1`.
+
+#### 6. Server rendering with zero client JavaScript
+
+```bash
+npm run check:web-components-ssr
+```
+
+Generates `libs/web-components/harness/ssr-dsd-static.html` (**0** `
+```
+
+A real theme is **mandatory**, not cosmetic: the components resolve the real token files, whose own
+fallbacks are debug placeholder strings, so a themeless render produces invalid CSS (**measured**,
+§§13, 16).
+
+### Lit
+
+```ts
+html`Save`;
+```
+
+### React
+
+```tsx
+import { createComponent } from '@lit/react';
+import * as React from 'react';
+import { GdCheckbox as GdCheckboxElement } from 'gd-design-web';
+
+export const GdCheckbox = createComponent({
+ tagName: 'gd-checkbox',
+ elementClass: GdCheckboxElement,
+ react: React,
+ events: { onGdChange: 'gd-change' },
+});
+
+ setChecked(e.detail.checked)}>
+ Accept terms
+;
+```
+
+The `events` map is required on **both** React 18 and 19.
+
+### Next.js (App Router)
+
+```tsx
+// app/page.tsx — server component: renders tags, imports nothing from the package
+Save
+
+```
+
+```tsx
+// app/client-island.tsx
+'use client';
+import { defaultTheme } from 'gd-design-library/tokens';
+import 'gd-design-web';
+```
+
+`'use client'` is **mandatory** — the token barrel cannot be imported in a server component
+(**measured**, §17.4). See `08-react-and-nextjs.md`.
+
+### Vue
+
+```vue
+
+ Save
+
+```
+
+Vue handles custom elements natively; `.prop` forces property assignment for objects, and `@gd-change`
+binds custom events directly. **Reasoned, not verified** — no Vue fixture was built.
+
+### Angular
+
+```html
+Save
+```
+
+Requires `CUSTOM_ELEMENTS_SCHEMA` in the consuming module. **Reasoned, not verified** — no Angular
+fixture was built.
diff --git a/docs/webcomponents-migration/05-native-html-guidelines.md b/docs/webcomponents-migration/05-native-html-guidelines.md
new file mode 100644
index 0000000..34bb71d
--- /dev/null
+++ b/docs/webcomponents-migration/05-native-html-guidelines.md
@@ -0,0 +1,158 @@
+# 05 — Native HTML Versus Custom Element Guidelines
+
+**Owner:** CTORNDSD-646a · **Answers:** CTORNDSD-646 acceptance criterion 6 · **Status:** Delivered
+
+CTORNDSD-646 asks whether _all_ existing GridKit components should become custom elements. The
+answer is **no**, and the dividing line is empirical rather than stylistic.
+
+This analysis uses only components that already exist. No component was built for it.
+
+## The question that actually decides it
+
+Shadow DOM is not free. It buys style isolation — the measured fix for CTORNDSD-286 (**measured**,
+`FINDINGS.md` §1) — and it charges three things:
+
+1. **Light-DOM discoverability.** `document.querySelector('h1')` cannot reach a heading rendered
+ inside a shadow root (**measured**, §5). The accessibility tree is unaffected — screen readers see
+ a real heading — so this is a DOM-query gap, not an a11y regression. But SEO crawlers, link
+ checkers, browser extensions, analytics selectors, E2E selectors, and testing-library shortcuts
+ all use light-DOM queries.
+2. **A containing block per element.** A percentage width on a shadow-DOM child resolves against the
+ host's box, and `:host` defaults to `auto`. This is not theoretical: `gd-select` collapsed to
+ icon-only width and clipped its dropdown text for exactly this reason, fixed by setting an
+ explicit width on the host (**measured**, §10).
+3. **Per-instance setup cost.** Custom-element upgrade plus shadow-root attachment, paid per node. At
+ 300 instances this is measurable (**measured**, §14).
+
+So the decision rule is not "is this a component?" but **"does this element own behavior worth paying
+isolation for, and does anything outside need to find its internals?"**
+
+## Decision rule
+
+Apply in order; stop at the first match.
+
+| Ship | When |
+| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Lit custom element** | It owns interaction state or behavior the browser does not provide natively, **and** it renders a visual surface that must survive a hostile host reset, **and** nothing external needs to query its internals by light-DOM tag or attribute |
+| **Native element + shared token CSS** | It renders a real semantic element whose **discoverability matters** (SEO, crawlers, link checkers, analytics, E2E selectors) — even if it carries a little behavior. Behavior small enough to express as a documented pattern or a thin hook does not justify a shadow root |
+| **No abstraction — shared utility CSS** | It carries no behavior **and** no visual surface of its own. Pure layout and spacing wrappers |
+| **Documentation only** | The value is a naming convention over CSS that already exists |
+| **React wrapper** | Orthogonal, not exclusive — applies **on top of** a Lit element whenever consumers are React. See `08-react-and-nextjs.md` |
+
+## Verdicts
+
+Behavior signals below are **measured** — line counts and hook/state/effect counts read from each
+component's `.tsx`. Bundle figures are **measured** from `FINDINGS.md` §3.
+
+| Group | Existing component(s) | Behavior signal | Verdict | Confidence |
+| --------------- | ----------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- | --------------- |
+| Button | `atoms/Button` | 82 lines, 0 state | **Lit custom element** | High |
+| Input | `atoms/Input` | 106 lines, 0 state, 7 hooks | **Lit custom element** — conditional | High |
+| Select | `atoms/Select` | 533 lines, 10 state, 6 effects, 5 context | **Lit custom element** | High |
+| Checkbox | `atoms/Checkbox` | 97 lines, 2 state, 3 effects | **Lit custom element** — conditional | High |
+| Link | `atoms/Link` | 58 lines, 0 state | **Native `` + shared token CSS** | High |
+| Image | `atoms/Image` | 91 lines, 3 state | **Native `` + shared token CSS** | Medium |
+| Typography | `atoms/Typography` | 46 lines, 0 state | **Native element + shared token CSS** | High — measured |
+| Layout and grid | `layout/Row`, `layout/Column`, `layout/FlexContainer` | 38 / 41 / 30 lines, 0 state each | **No abstraction — shared utility CSS** | High — measured |
+| Containers | `atoms/Box`, `atoms/Wrapper` | 41 / 22 lines, 0 state | **No abstraction — shared utility CSS** | High |
+| Containers | `layout/ChatContainer` | 163 lines, 2 state, `useMediaQuery`, `useImperativeHandle` | **Lit custom element** — it is not a container | Medium |
+
+### Ship as Lit custom elements
+
+**Button.** The most-used interactive primitive, and CTORNDSD-286 was a button bug — style isolation
+is the whole point here. It carries variant, loading, and focus-ring states that native `