diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 81188d0c..00000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,144 +0,0 @@
----
-version: 2
-
-references:
-
- filter_all: &filter_all
- filters:
- branches:
- only: /.*/
- tags:
- only: /.*/
-
- filter_stg: &filter_head
- filters:
- branches:
- only: master
- tags:
- only: stg
-
- filter_prd: &filter_release
- filters:
- branches:
- ignore: /.*/
- tags:
- only: /v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?/
-
-jobs:
-
- test_node10:
- docker:
- - image: circleci/node:10
- working_directory: ~/src
- steps:
- - checkout
- - restore_cache:
- keys:
- - v2-node10-dependencies-{{ checksum "package.json" }}
- - v2-node10-dependencies-
- - run: npm install
- - save_cache:
- key: v2-node10-dependencies-{{ checksum "package.json" }}
- paths:
- - node_modules
- - run: npm test
-
- test_node12:
- docker:
- - image: circleci/node:12
- working_directory: ~/src
- steps:
- - checkout
- - restore_cache:
- keys:
- - v2-node12-dependencies-{{ checksum "package.json" }}
- - v2-node12-dependencies-
- - run: npm install
- - save_cache:
- key: v2-node12-dependencies-{{ checksum "package.json" }}
- paths:
- - node_modules
- - run: npm test
- - run: cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js
-
- test_node14:
- docker:
- - image: circleci/node:14
- working_directory: ~/src
- steps:
- - checkout
- - restore_cache:
- keys:
- - v2-node14-dependencies-{{ checksum "package.json" }}
- - v2-node14-dependencies-
- - run: npm install
- - save_cache:
- key: v2-node14-dependencies-{{ checksum "package.json" }}
- paths:
- - node_modules
- - run: npm test
-
- deploy_docs:
- docker:
- - image: circleci/node:14
- working_directory: ~/src
- steps:
- - checkout
- - restore_cache:
- key: v1-website-dependencies-{{ checksum "website/package.json" }}
- - run:
- name: Build
- command: |
- sudo apt-get -y install awscli
- bash ./.circleci/scripts/deploy-docs.sh
- - save_cache:
- key: v1-website-dependencies-{{ checksum "website/package.json" }}
- paths:
- - website/node_modules
-
- deploy_package:
- docker:
- - image: circleci/node:12
- working_directory: ~/repo
- steps:
- - checkout
- - restore_cache:
- keys:
- - v2-node12-dependencies-{{ checksum "package.json" }}
- - v2-node12-dependencies-
- - run: npm install
- - run: |
- echo "$NPMRC" > ~/.npmrc
- chmod 600 ~/.npmrc
- if [[ "$CIRCLE_TAG" = *-* ]]; then
- npm publish --tag=prerelease
- else
- npm publish
- fi
-
-workflows:
- version: 2
- test:
- jobs:
- - test_node10:
- <<: *filter_all
- - test_node12:
- <<: *filter_all
- - test_node14:
- <<: *filter_all
- - deploy_docs:
- <<: *filter_head
- context:
- - Documentation
- requires:
- - test_node10
- - test_node12
- - test_node14
- - deploy_package:
- <<: *filter_release
- context:
- - npm-publish
- requires:
- - test_node10
- - test_node12
- - test_node14
diff --git a/.circleci/scripts/deploy-docs.sh b/.circleci/scripts/deploy-docs.sh
deleted file mode 100644
index 85fd8a85..00000000
--- a/.circleci/scripts/deploy-docs.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/bin/bash
-set -e -o pipefail
-
-BUCKET=docs.clayplatform.io
-
-green(){
- printf "\e[32m$1\e[39m\n"
-}
-
-red(){
- >&2 printf "\e[31m$1\e[39m\n"
-}
-
-export PROJECT_NAME="${PROJECT_NAME:-$CIRCLE_PROJECT_REPONAME}"
-if [[ -z "$PROJECT_NAME" ]]; then
- red "PROJECT_NAME not set and could not be derived from CIRCLE_PROJECT_REPONAME"
- exit 1
-fi
-green "PROJECT_NAME: $PROJECT_NAME"
-
-export BUILD_DIR="${BUILD_DIR:-website}"
-green "BUILD_DIR: $BUILD_DIR"
-
-green "Building documentation..."
-cd "$BUILD_DIR"
-npm install --quiet
-npm run build
-
-green "Uploading documentation to $BUCKET..."
-aws s3 sync --delete --acl=public-read "build/$PROJECT_NAME/" "s3://$BUCKET/$PROJECT_NAME/"
-
-green "Documentation updated."
diff --git a/.circleci/scripts/release.sh b/.circleci/scripts/release.sh
deleted file mode 100755
index 9f04adf2..00000000
--- a/.circleci/scripts/release.sh
+++ /dev/null
@@ -1,45 +0,0 @@
-#!/bin/bash
-
-# This script will increment the package version in package.json.
-# It will then tag the HEAD of the master branch with the version number
-# and push the tag to the origin (GitHub)
-
-set -e
-
-OPTIONS="(prepatch|patch|preminor|minor|premajor|major)"
-USAGE="usage: npm release $OPTIONS"
-SEMVAR="$1"
-
-# Releases should only be cut from the master branch.
-# They can be manually cut from other branches, but that should be a very
-# intentional process.
-BRANCH="$(git rev-parse --abbrev-ref HEAD)"
-if [[ "$BRANCH" != "master" ]]; then
- >&2 echo "ERROR: Not on the master branch, will not release."
- exit 1
-fi
-
-case $SEMVAR in
- minor)
- ;;
- major)
- ;;
- patch)
- ;;
- premajor)
- ;;
- preminor)
- ;;
- prepatch)
- ;;
- *)
- SEMVAR=prepatch
- >&2 echo "WARNING: No $OPTIONS provided, defaulting to PREPATCH."
- >&2 echo "$USAGE"
- ;;
-esac
-
-git pull --rebase origin master
-version="$(npm version $SEMVAR)"
-git push origin master
-git push origin "tags/$version"
diff --git a/.github/main.workflow b/.github/main.workflow
deleted file mode 100644
index f982d661..00000000
--- a/.github/main.workflow
+++ /dev/null
@@ -1,28 +0,0 @@
-workflow "Deploy to GitHub Pages" {
- on = "push"
- resolves = ["Build and push docs"]
-}
-
-action "Filter branch" {
- uses = "actions/bin/filter/@master"
- args = "branch master"
-}
-
-action "Install" {
- needs = ["Filter branch"]
- uses = "actions/npm@master"
- args = "install --prefix ./website"
-}
-
-action "Update version" {
- needs = ["Install"]
- uses = "clay/docusaurus-github-action@master"
- args = "version"
-}
-
-action "Build and push docs" {
- needs = ["Update version"]
- uses = "clay/docusaurus-github-action@master"
- args = "deploy"
- secrets = ["DEPLOY_SSH_KEY", "ALGOLIA_API_KEY"]
-}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..1e3f8624
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,30 @@
+name: CI
+
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ branches: ['**']
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [20.x]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
diff --git a/.gitignore b/.gitignore
index a4e323dc..a6a10357 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,4 @@ website/build/
website/yarn.lock
website/node_modules
website/i18n/*
+*.tgz
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..2bd5a0a9
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+22
diff --git a/CLAY-BUILD.md b/CLAY-BUILD.md
new file mode 100644
index 00000000..c7f21fd7
--- /dev/null
+++ b/CLAY-BUILD.md
@@ -0,0 +1,1496 @@
+# clay build — New Asset Pipeline
+
+> This document covers the **`clay build`** command introduced in claycli 5.1. It explains what changed from the legacy `clay compile` command, why, how they compare, and how to run both side-by-side.
+
+## Table of Contents
+
+1. [Why We Changed It](#1-why-we-changed-it)
+2. [Commands At a Glance](#2-commands-at-a-glance)
+3. [Architecture: Old vs New](#3-architecture-old-vs-new)
+4. [Pipeline Comparison Diagrams](#4-pipeline-comparison-diagrams)
+5. [Feature-by-Feature Comparison](#5-feature-by-feature-comparison)
+6. [Configuration](#6-configuration)
+7. [Running Both Side-by-Side](#7-running-both-side-by-side)
+8. [Code References](#8-code-references)
+9. [Performance](#9-performance)
+10. [Learning Curve](#10-learning-curve)
+11. [For Product Managers](#11-for-product-managers)
+12. [Tests](#12-tests)
+13. [Migration Guide](#13-migration-guide) _(includes optional per-site rollout strategy)_
+14. [amphora-html Changes](#14-amphora-html-changes)
+15. [Bundler Comparison: esbuild vs Webpack vs Vite](#15-bundler-comparison-esbuild-vs-webpack-vs-vite)
+
+## 1. Why We Changed It
+
+The legacy `clay compile` pipeline was built on **Browserify + Gulp**, tools designed for the 2014–2018 JavaScript ecosystem. Over time these became pain points:
+
+| Problem | Impact |
+|---|---|
+| Browserify megabundle (all components in one file per alpha-bucket) | Any change = full rebuild of all component JS, slow watch mode |
+| Gulp orchestration with 20+ plugins | Complex dependency chain, hard to debug, slow npm install |
+| Sequential compilation steps | CSS, JS, templates all ran in series — total time = sum of all steps |
+| No shared chunk extraction | If two components shared a dependency, each dragged it in separately via the Browserify registry |
+| No tree shaking | Browserify bundled entire CJS modules regardless of how much was used; no support for ESM dependency tree shaking |
+| No source maps | Build errors in production pointed to minified line numbers, not source |
+| No content-hashed filenames | Static filenames (`article.client.js`) forced full cache invalidation on every deploy |
+| Babelify transpilation overhead | Slow even on small changes |
+| `_registry.json` + `_ids.json` numeric module graph | Opaque, hard to inspect or extend |
+| `_prelude.js` / `_postlude.js` custom runtime | Browserify's own module system loaded on every page, adding baseline overhead |
+| `browserify-cache.json` stale cache risk | Corrupted/out-of-sync cache produced builds where old module code was silently served |
+| 20+ npm dependencies just for bundling | Large attack surface, slow installs, difficult version management |
+
+The new `clay build` pipeline replaces Browserify/Gulp with **esbuild + PostCSS 8**:
+
+- esbuild bundles JS/Vue in **milliseconds** (not seconds) with native code-splitting and tree shaking for ESM dependencies
+- PostCSS 8's programmatic API replaces Gulp's stream-based CSS pipeline
+- All build steps (JS, CSS, fonts, templates, vendor, media) run **in parallel**
+- A human-readable `_manifest.json` replaces the numeric `_registry.json`/`_ids.json` pair
+- Watch mode starts instantly — no initial build, only rebuilds what changed
+- **Source maps** generated automatically — errors point to exact source file, line, and column
+- **Content-hashed filenames** (`article/client-A1B2C3.js`) — browsers and CDNs cache files forever; only changed files get new URLs on deploy
+- **Native ESM** output — no custom `window.require()` runtime, browsers handle imports natively
+- **Build-time `process.env.NODE_ENV`** — dead branches like `if (process.env.NODE_ENV !== 'production')` are eliminated at compile time, not runtime
+- Dependency footprint reduced from 20+ bundler packages to a handful
+
+## 2. Commands At a Glance
+
+Both commands co-exist. You choose which pipeline to use.
+
+### Legacy pipeline (Browserify + Gulp)
+
+```bash
+# One-shot compile
+clay compile
+
+# Watch mode
+clay compile --watch
+```
+
+### New pipeline (esbuild + PostCSS 8)
+
+```bash
+# One-shot build
+clay build
+
+# Aliases (backward-compatible)
+clay b
+clay pn # ← kept so existing Makefiles don't break
+clay pack-next # ← kept for the same reason
+
+# Watch mode
+clay build --watch
+
+# Minified production build
+clay build --minify
+```
+
+Both commands read **`claycli.config.js`** in the root of your Clay instance, but they look at **different config keys** so they never conflict (see [Configuration](#6-configuration)).
+
+## 3. Architecture: Old vs New
+
+### Old: `clay compile` (Browserify + Gulp)
+
+```
+clay compile
+│
+├── scripts.js ← Browserify megabundler
+│ ├── Each component client.js → {name}.client.js (individual file)
+│ ├── Each component model.js → {name}.model.js + _models-{a-d}.js (bucket in minified mode)
+│ ├── Each component kiln.js → {name}.kiln.js + _kiln-{a-d}.js (bucket in minified mode)
+│ ├── Shared deps → {number}.js + _deps-{a-d}.js (bucket in minified mode)
+│ ├── _prelude.js / _postlude.js ← Browserify custom module runtime (window.require, window.modules)
+│ ├── _registry.json ← numeric module ID graph (e.g. { "12": ["4","7"] })
+│ ├── _ids.json ← module ID to filename map
+│ └── _client-init.js ← runtime that calls window.require() on each .client module
+│
+├── styles.js ← Gulp + PostCSS 7
+│ └── styleguides/**/*.css → public/css/{component}.{styleguide}.css
+│
+├── templates.js← Gulp + Handlebars precompile
+│ └── components/**/template.hbs → public/js/*.template.js
+│
+├── fonts.js ← Gulp copy + CSS concat
+│ └── styleguides/*/fonts/* → public/fonts/ + public/css/_linked-fonts.*.css
+│
+└── media.js ← Gulp copy
+ └── components/**/media/* → public/media/
+```
+
+**Key runtime behaviour:** `getDependencies()` in view mode walks `_registry.json` for only the components amphora placed on the page — it is page-specific. `_client-init.js` then calls `window.require(key)` for every `.client` key in `window.modules`, which is populated only by the scripts that were served. The subtle issue is that it mounts every loaded `.client` module regardless of whether that component's DOM element is actually present on the page.
+
+### New: `clay build` (esbuild + PostCSS 8)
+
+```
+clay build
+│
+├── scripts.js ← esbuild (JS + Vue SFCs, code-split)
+│ ├── Entry points: every components/**/client.js, model.js, kiln.js
+│ │ (global/js/*.js excluded — bundled into _globals-init)
+│ ├── Code-split chunks: shared dependencies extracted automatically
+│ ├── _manifest.json ← human-readable entry→file+chunks map
+│ ├── .clay/_view-init.js ← generated bootstrap (mounts components, sticky events)
+│ ├── .clay/_kiln-edit-init.js ← generated edit-mode aggregator (models + kiln.js)
+│ │ built with splitting:false — single self-contained file
+│ └── .clay/_globals-init.js ← generated globals bundle (all global/js/*.js)
+│ built with splitting:false — single self-contained file,
+│ avoids the 70-100 tiny chunks esbuild would otherwise produce
+│
+├── styles.js ← PostCSS 8 programmatic API (parallel, p-limit 50)
+│ └── styleguides/**/*.css → public/css/{component}.{styleguide}.css
+│
+├── templates.js← Handlebars precompile (parallel, p-limit 20, progress-tracked)
+│ └── components/**/template.hbs → public/js/*.template.js
+│
+├── fonts.js ← fs-extra copy + CSS concat
+│ └── styleguides/*/fonts/* → public/fonts/ + public/css/_linked-fonts.*.css
+│
+├── vendor.js ← fs-extra copy
+│ └── clay-kiln/dist/*.js → public/js/
+│
+├── media.js ← fs-extra copy
+│ ├── components/**/media/* → public/media/components/
+│ ├── layouts/**/media/* → public/media/layouts/
+│ ├── styleguides/**/media/* → public/media/styleguides/
+│ └── sites/**/media/* → public/media/sites/ ← site-level SVGs, logos, etc.
+│
+└── client-env.json ← generated by generateClientEnv()
+ └── scans source files for process.env.VAR references → client-env.json
+ (required by amphora-html's addEnvVars() at render time)
+```
+
+**Key runtime behaviour:** `_view-init.js` loads a component's `client.js` **only when that component's element exists in the DOM**. When `stickyEvents` is configured, a sticky-event shim ensures those events are received even by late subscribers.
+
+## 4. Pipeline Comparison Diagrams
+
+Both pipelines share the same source files and produce the same `public/` output. The differences are in *how* the steps are wired together, *how* the JS module system works at runtime, and *how* scripts are resolved and served per page.
+
+### 4a. Build step execution (what runs and in what order)
+
+The most immediately visible difference: sequential vs parallel execution.
+
+**🕐 Legacy — `clay compile` (Browserify + Gulp, ~90s)**
+
+```mermaid
+%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%%
+flowchart LR
+ SRC(["📁 Source Files"]):::src
+
+ L1["📦 JS Bundle
Browserify + Babel
30–60 s"]:::slow
+ L2["🎨 CSS
Gulp + PostCSS 7
15–30 s"]:::slow
+ L3["📄 Templates
Gulp + Handlebars
10–20 s"]:::med
+ L4["🔤 Fonts + 🖼 Media
Gulp copy · 2–5 s"]:::fast
+
+ OUT(["📂 public/"]):::out
+
+ SRC --> L1 -->|"waits"| L2 -->|"waits"| L3 -->|"waits"| L4 --> OUT
+
+ classDef src fill:#1e293b,color:#94a3b8,stroke:#334155
+ classDef out fill:#1e293b,color:#94a3b8,stroke:#334155
+ classDef slow fill:#7f1d1d,color:#fca5a5,stroke:#991b1b
+ classDef med fill:#78350f,color:#fcd34d,stroke:#92400e
+ classDef fast fill:#14532d,color:#86efac,stroke:#166534
+```
+
+**⚡ New — `clay build` (esbuild + PostCSS 8, ~33s)**
+
+```mermaid
+%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%%
+flowchart LR
+ SRC(["📁 Source Files"]):::src
+
+ N0["🖼 Media
fs-extra · ~0.7 s"]:::fast
+ N1["📦 JS + Vue
esbuild · ~3 s"]:::vfast
+ N2["🎨 CSS
PostCSS 8 · ~32 s"]:::slow
+ N3["📄 Templates
Handlebars · ~16 s"]:::med
+ N4["🔤 Fonts + 📚 Vendor
fs-extra · ~1 s"]:::fast
+
+ OUT(["📂 public/"]):::out
+
+ SRC --> N0 -->|"all at once"| N1 & N2 & N3 & N4 --> OUT
+
+ classDef src fill:#1e293b,color:#94a3b8,stroke:#334155
+ classDef out fill:#1e293b,color:#94a3b8,stroke:#334155
+ classDef slow fill:#7f1d1d,color:#fca5a5,stroke:#991b1b
+ classDef med fill:#78350f,color:#fcd34d,stroke:#92400e
+ classDef fast fill:#14532d,color:#86efac,stroke:#166534
+ classDef vfast fill:#052e16,color:#4ade80,stroke:#166534
+```
+
+**Color guide:** 🔴 slow (>15s) · 🟡 medium (10–20s) · 🟢 fast (<5s) · 🌿 very fast (<3s)
+
+| | `clay compile` | `clay build` | Δ |
+|---|---|---|---|
+| **Total time** | ~60–120s | ~33s | **~2–3× faster** |
+| **Execution** | Sequential — each step waits for the one before it | Parallel — all steps run simultaneously after media | ⚠️ Different shape; same end result |
+| **JS tool** | Browserify + Babel (megabundles) | esbuild (code-split per component) | 🔄 Replaced; esbuild is ~10–20× faster than Browserify |
+| **CSS tool** | Gulp + PostCSS 7 | PostCSS 8 programmatic API | 🔄 Replaced; same PostCSS plugin ecosystem, newer API |
+| **Module graph** | `_registry.json` + `_ids.json` | `_manifest.json` (human-readable) | ⚠️ Different format; same purpose (maps components → files) |
+| **Component loader** | `_client-init.js` — mounts every loaded `.client` module, even if its DOM element is absent | `.clay/_view-init.js` — mounts only components whose DOM element is present | ✅ Better; avoids executing component code when the component isn't on the page |
+| **JS output** | Per-component files + individual dep files, page-scoped via registry walk | Per-component files + `chunks/` (shared deps extracted once) | ✅ Better; shared deps are downloaded once even when multiple components use them |
+
+### 4b. JS module system architecture (the core architectural shift)
+
+This is the diagram that explains *why* so many other things had to change. The entire difference in `resolve-media.js`, `_view-init`, `_kiln-edit-init`, and `_globals-init` flows from this single architectural difference.
+
+**🕐 Legacy — `clay compile` (Browserify runtime module registry)**
+
+```mermaid
+%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%%
+flowchart TB
+ OS["Source files
components/**/client.js · model.js · kiln.js
global/js/*.js"]:::src
+
+ OB["Browserify megabundler + Babel
_prelude.js / _postlude.js
custom window.require runtime"]:::tool
+
+ OR["_registry.json — numeric dep graph
_ids.json — module ID → filename map"]:::artifact
+
+ OI["_client-init.js
calls window.require(key) for every .client
regardless of DOM presence"]:::loader
+
+ OG["_deps-a.js _deps-b.js … (alpha-bucketed shared deps)
_models-a.js _kiln-a.js … (alpha-bucketed edit files)"]:::output
+
+ OS -->|"one big bundle per alpha bucket"| OB
+ OB -->|"writes"| OR
+ OB -->|"generates"| OI
+ OB -->|"outputs"| OG
+
+ classDef src fill:#1e3a5f,color:#93c5fd,stroke:#1d4ed8
+ classDef tool fill:#3b1f6e,color:#c4b5fd,stroke:#7c3aed
+ classDef artifact fill:#422006,color:#fcd34d,stroke:#b45309
+ classDef loader fill:#1c2b4a,color:#93c5fd,stroke:#2563eb
+ classDef output fill:#14532d,color:#86efac,stroke:#166534
+```
+
+**⚡ New — `clay build` (esbuild static module graph)**
+
+```mermaid
+%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%%
+flowchart TB
+ NS["Source files
components/**/client.js · model.js · kiln.js
global/js/*.js"]:::src
+
+ NG["Pre-generated entry points
.clay/_view-init.js
.clay/_kiln-edit-init.js
.clay/_globals-init.js"]:::gen
+
+ NE["esbuild — native code splitting
no transpile · no runtime registry
ESM import/export wiring"]:::tool
+
+ NM["_manifest.json
{ 'components/article/client':
{ file: 'client-A1B2.js', imports: ['chunks/shared-C3D4.js'] } }"]:::artifact
+
+ NV["_view-init-[hash].js
mounts client.js via dynamic import()
only when the component's DOM element exists"]:::loader
+
+ NK["_kiln-edit-init-[hash].js
registers all model.js + kiln.js
on window.kiln.componentModels
splitting:false — single self-contained file"]:::output
+
+ NGL["_globals-init-[hash].js
all global/js/*.js in one file
splitting:false — 1 request instead of 70–100"]:::output
+
+ NC["public/js/chunks/
content-hashed shared chunks
cacheable forever"]:::output
+
+ NS -->|"entry points"| NG
+ NG -->|"feeds"| NE
+ NE -->|"writes"| NM
+ NE -->|"outputs"| NV
+ NE -->|"outputs"| NK
+ NE -->|"outputs"| NGL
+ NE -->|"extracts shared deps"| NC
+
+ classDef src fill:#1e3a5f,color:#93c5fd,stroke:#1d4ed8
+ classDef gen fill:#1f3b2a,color:#6ee7b7,stroke:#059669
+ classDef tool fill:#3b1f6e,color:#c4b5fd,stroke:#7c3aed
+ classDef artifact fill:#422006,color:#fcd34d,stroke:#b45309
+ classDef loader fill:#1c2b4a,color:#93c5fd,stroke:#2563eb
+ classDef output fill:#14532d,color:#86efac,stroke:#166534
+```
+
+**🔁 Same in both pipelines:** CSS (PostCSS plugins → `public/css/`) · Templates (Handlebars precompile → `public/js/*.template.js`) · Fonts (copy + concat → `public/fonts/`) · Media (copy → `public/media/`)
+
+**What this diagram shows:**
+
+| Concern | `clay compile` | `clay build` | Why it matters |
+|---|---|---|---|
+| **Module registry** | Runtime: `window.modules` populated as scripts evaluate on every page load | Build-time: `_manifest.json` written once; no runtime registry | Old: any file could call `window.require('components/article/model')` at any time. New: wiring is static — esbuild traces it at build time. |
+| **Component mounting** | `_client-init.js` calls `window.require()` for every `.client` module whose script was served — runs regardless of DOM | `_view-init.js` scans the DOM first; only `import()`s a component if its element exists | New: component code never runs for components not on the page |
+| **Shared dependency handling** | Alpha-bucketed dep files (`_deps-a.js`…) — all or nothing per bucket, static filenames | Named content-hashed chunks in `public/js/chunks/` — exact code extracted by the static graph | New: unchanged shared chunks stay cached across deploys |
+| **Edit mode aggregator** | `window.modules` was the aggregator — clay-kiln called `window.require('components/article/model')` at any time | `_kiln-edit-init.js` pre-registers all model/kiln files on `window.kiln.componentModels` at page load; also shims `window.modules = window.modules \|\| {}` for clay-kiln compatibility | New: explicit pre-wiring replaces implicit runtime lookup; `window.modules` shim ensures any published clay-kiln version works without modification |
+| **Global scripts** | Individual `