diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index d3f1f94..c98e4fe 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -999,12 +999,12 @@ Plans:
**Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content — beyond the current markdown field or a plain rich editor — that edits imported HTML posts losslessly.
**Requirements**: TBD
**Depends on:** Phase 16.1
-**Plans:** 1/6 plans executed
+**Plans:** 2/6 plans executed
Plans:
**Wave 1**
- [x] 16.2-01-PLAN.md — Framework (summercms.go): pact field-type API (human API checkpoint), cabana registry, boot option checks, Decode/Load/list in the save transaction, Decode notices as meta.notices (D-10 strip report), untouched skip, FieldWritesFromContext, field-types and field upload routes, OpenAPI and TS types, SPA PluginField/MLPluginField, global loader and notice display, dist, READMEs and docs
-- [ ] 16.2-02-PLAN.md — Journal Blog Editor element (sm-journal-plugin assets/src to assets/dist): TipTap package check (human), untouched value contract, lossless schema and serializer, policy-safe links, uploads and YouTube embeds, the committed editor-output corpus, JS build, vitest, check-dist, embed
+- [x] 16.2-02-PLAN.md — Journal Blog Editor element (sm-journal-plugin assets/src to assets/dist): TipTap package check (human), untouched value contract, lossless schema and serializer, policy-safe links, uploads and YouTube embeds, the committed editor-output corpus, JS build, vitest, check-dist, embed
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 16.2-03-PLAN.md — Journal Go integration: blogeditor and mlblogeditor types, strip-and-report Decode per locale (SanitizeHTMLReport), D-07 Load, post and category fields (category 422 gate retired), content_html companion writes without goldmark, bluemonday policy extended for the editor's own output (corpus test), editor uploads through the extracted media storage
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 9221084..76acf44 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -4,16 +4,16 @@ milestone: v1.0
current_phase: "16.2"
current_phase_name: Plugin-provided field types and the Blog Editor formwidget
status: executing
-stopped_at: Completed 16.2-01-PLAN.md
-last_updated: "2026-10-10T09:28:16.322Z"
+stopped_at: Completed 16.2-02-PLAN.md
+last_updated: "2026-10-10T09:55:19.276Z"
last_activity: 2026-10-10
last_activity_desc: Phase 16.2 execution started
-state_head: 8237b7a19f82e480825493cf0b4426c8ea440319
+state_head: 5600c02a730d9ca18ff1324314778f1cc3db83a3
progress:
total_phases: 34
completed_phases: 13
total_plans: 157
- completed_plans: 148
+ completed_plans: 149
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -30,7 +30,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
## Current Position
Phase: 16.2 (Plugin-provided field types and the Blog Editor formwidget) — EXECUTING
-Plan: 2 of 6
+Plan: 3 of 6
Status: Ready to execute
Last activity: 2026-10-10 — Phase 16.2 execution started
@@ -203,6 +203,7 @@ Progress: [██████████] 100%
| Phase 16.1 P02 | 1h 26m | 3 tasks | 4 files |
| Phase 16.1 P03 | 12 min | 3 tasks | 8 files |
| Phase 16.2 P01 | 34min | 4 tasks | 51 files |
+| Phase 16.2 P02 | 25min | 3 tasks | 24 files |
## Accumulated Context
@@ -660,6 +661,10 @@ Recent decisions affecting current work:
- [Phase 16.2]: Plugin field type registry is built per Activate by compileFieldTypes and passed down the compile functions; existing compile functions stay nil-registry wrappers
- [Phase 16.2]: Only pact.FormFieldError from a plugin field type's Decode or Upload is a 422; every other plugin error, Load included, is the opaque 500
- [Phase 16.2]: Untouched plugin field values are skipped on update by canonical encoding/json comparison with the served value (stored value through Load), per field and locale
+- [Phase 16.2]: TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (packages approved)
+- [Phase 16.2]: Blog Editor bundle built through the Vite application pipeline (single ES entry, fixed names, exports kept) because library mode never minifies ES whitespace
+- [Phase 16.2]: Blog Editor link parsing, rendering and commands use isPolicyUrl (http, https, mailto, relative); on* attributes are never captured; external values load outside the undo history
+- [Phase 16.2]: Uncaptioned editor images are inline (
); captioned ones are figure with figureImage and figcaption
### Pending Todos
@@ -729,6 +734,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
-Last session: 2026-10-10T09:28:15.731Z
-Stopped at: Completed 16.2-01-PLAN.md
+Last session: 2026-10-10T09:55:18.559Z
+Stopped at: Completed 16.2-02-PLAN.md
Resume file: None
diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md
index 493ceea..491ae2b 100644
--- a/.planning/WINDOWS.md
+++ b/.planning/WINDOWS.md
@@ -1,10 +1,10 @@
---
schema_version: 1
-open_count: 6
+open_count: 8
waived_count: 0
fixed_count: 6
-total_count: 12
-last_updated: 2026-10-09T20:51:27.671Z
+total_count: 14
+last_updated: 2026-10-10T09:54:57.583Z
---
# Broken Windows Ledger
@@ -27,6 +27,8 @@ last_updated: 2026-10-09T20:51:27.671Z
| 10 | 12.1 | unmet-truth | .planning/phases/12.1-user-plugin-admin-screens/deferred-items.md | | fonoteka.go: four application tests pin lists the user plugin's admin work changes (TestAdminMetadataFiltering nav list, TestHiddenNeverMarshals user model count, parity TestMigrateSeedsCanonicalGenres and TestRollbackLastIsolatesFonoteka migration ids); with the two from plan 12.1-02 they keep plan 12.1-03's application verify red until plan 12.1-04 updates them | fixed | | 2026-10-05T11:31:36.781Z | 2026-10-05T12:07:04.853Z |
| 11 | 16.1 | unrun-verify | modules/cabana/query_relation_test.go | | TestListRelationSearchPostgres not run here (no Docker); run go test -count=1 ./modules/cabana on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:22.934Z | |
| 12 | 16.1 | unrun-verify | .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md | | sm-journal-plugin TestPostsAdminResaveKeepsHTML (posts_admin_smoke_test.go) not run here (no Docker); run go -C ../sm-journal-plugin test -count=1 ./... on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:27.671Z | |
+| 13 | 16.2 | deviation | tests/blogeditor/fixtures/imported-post.html | | sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted | open | | 2026-10-10T09:54:57.288Z | |
+| 14 | 16.2 | unrun-verify | plugin_test.go | | sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06) | open | | 2026-10-10T09:54:57.583Z | |
````json
[
@@ -185,6 +187,32 @@ last_updated: 2026-10-09T20:51:27.671Z
"recorded_at": "2026-10-09T20:51:27.671Z",
"resolved_at": null,
"milestone": "v1.0"
+ },
+ {
+ "id": 13,
+ "kind": "deviation",
+ "phase": "16.2",
+ "file": "tests/blogeditor/fixtures/imported-post.html",
+ "line": null,
+ "description": "sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted",
+ "status": "open",
+ "reason": "",
+ "recorded_at": "2026-10-10T09:54:57.288Z",
+ "resolved_at": null,
+ "milestone": "v1.0"
+ },
+ {
+ "id": 14,
+ "kind": "unrun-verify",
+ "phase": "16.2",
+ "file": "plugin_test.go",
+ "line": null,
+ "description": "sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06)",
+ "status": "open",
+ "reason": "",
+ "recorded_at": "2026-10-10T09:54:57.583Z",
+ "resolved_at": null,
+ "milestone": "v1.0"
}
]
````
diff --git a/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
new file mode 100644
index 0000000..65887b2
--- /dev/null
+++ b/.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
@@ -0,0 +1,298 @@
+---
+phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
+plan: 02
+subsystem: journal-admin-editor
+tags: [sm-journal-plugin, tiptap, prosemirror, custom-elements, vite, vitest, blog-editor, embeds, uploads]
+
+requires:
+ - phase: 16.2-01
+ provides: the field type element contract (value property, attributes, summer-change, summer-upload, summer-upload-result) and the SPA host that loads plugin field type scripts as module scripts
+provides:
+ - custom element golem15-journal-blogeditor (BlogEditorElement), committed bundle assets/dist/blogeditor.js and blogeditor.css embedded through admin.go
+ - preservationExtensions, isPolicyUrl, RawHtml, Embed, Figure, FigureImage, Figcaption, Span, HtmlAttributes (extensions.ts)
+ - serializeDocument and captureBlocks (serializer.ts), parseEmbedUrl and EMBED_PROVIDERS (embed.ts), labelsFor (labels.ts), buildToolbar (toolbar.ts)
+ - tests/blogeditor/fixtures/editor-corpus.json, the editor's own output for the plan 03 Go policy test and the plan 04 layer copy
+ - sm-journal-plugin JS build (package.json, package-lock.json, vite.config.ts, tsconfig.json) and the scripts/check-dist.sh drift gate
+affects: [16.2-03, 16.2-04, 16.2-05, 16.2-06, journal]
+
+actuals:
+ tokens: 29500
+ tasks: 2
+ commits: 3
+plan_head_before: c60abc6d6220d5f9f15659d1cc5ec7d8dd1f5020
+plan_head_after: 086e485
+commits_repo: ../sm-journal-plugin
+
+tech-stack:
+ added:
+ - "@tiptap/core 3.31.4"
+ - "@tiptap/pm 3.31.4"
+ - "@tiptap/starter-kit 3.31.4"
+ - "@tiptap/extension-table 3.31.4"
+ - "@tiptap/extension-image 3.31.4"
+ - "vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3 (dev, same pins as the admin SPA)"
+ patterns:
+ - "Untouched value: the element returns the exact served string until a user-originated docChanged transaction or a Source tab input"
+ - "Top-level block reuse: the original HTML is split by source position, each piece parsed alone, and runs of nodes still Node.eq to the captured ones are emitted as their original bytes (LCS alignment after prefix/suffix trimming)"
+ - "Stored HTML never reaches the live admin DOM: raw blocks are text cards, serializer and raw renderHTML work in an inert document, on* attributes are never captured"
+ - "Committed, minified plugin bundle with a temp-dir rebuild and diff -r drift gate"
+
+key-files:
+ created:
+ - ../sm-journal-plugin/.gitignore
+ - ../sm-journal-plugin/package.json
+ - ../sm-journal-plugin/package-lock.json
+ - ../sm-journal-plugin/tsconfig.json
+ - ../sm-journal-plugin/vite.config.ts
+ - ../sm-journal-plugin/scripts/check-dist.sh
+ - ../sm-journal-plugin/assets/src/blogeditor/index.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/element.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/embed.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/labels.ts
+ - ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
+ - ../sm-journal-plugin/assets/dist/blogeditor.js
+ - ../sm-journal-plugin/assets/dist/blogeditor.css
+ - ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html
+ - ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json
+ - ../sm-journal-plugin/tests/blogeditor/helpers.ts
+ - ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
+ - ../sm-journal-plugin/tests/blogeditor/corpus.test.ts
+ modified:
+ - ../sm-journal-plugin/admin.go
+ - ../sm-journal-plugin/plugin_test.go
+ - ../sm-journal-plugin/README.md
+
+key-decisions:
+ - "TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (reply: packages approved)"
+ - "The bundle is built through Vite's application pipeline with a single ES entry, fixed names and preserved exports, not build.lib: library mode never minifies whitespace in ES output (587 KB vs 466 KB)"
+ - "Images are inline (Image inline: true), so an uncaptioned image is
; a captioned one is through a block figureImage node"
+ - "Link parsing, rendering and commands all use isPolicyUrl: a stored link with another scheme (tel:, ftp:) keeps its text but loses the link when its block is edited, the same result the server policy gives on an edited save"
+ - "Raw HTML cards cover the plan's tags plus article, main, address, dl, iframe, video, audio, picture, object, embed, form, svg, math, script and noscript, so more stored markup survives an edit; the server still strips what its policy does not allow"
+ - "Event handler attributes (on*) are never captured by the global attribute map, so they never reach the live admin DOM"
+ - "External values (value setter, Source tab re-parse) are loaded outside the undo history"
+
+patterns-established:
+ - "Plugin-owned JS build: package.json with exact pins, committed lockfile, committed dist, scripts/check-dist.sh"
+ - "Editor corpus: entries built only through editor commands and typing, committed as JSON, rewritten with UPDATE_CORPUS=1"
+
+requirements-completed: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
+
+coverage:
+ - id: D1
+ description: "Element defined once; untouched imported post returns byte for byte; no summer-change on set or echo; one summer-change per user edit; Source tab shows and edits the exact string; disabled and invalid; value kept across detach"
+ requirement: D-03
+ verification:
+ - kind: unit
+ ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#blog editor element (tracer)"
+ status: pass
+ human_judgment: false
+ - id: D2
+ description: "Raw HTML cards (text only, no live img/script), unmodelled attributes and spans kept on edited blocks, block reuse leaves every other top-level block byte-identical (and undo restores the exact fixture), tables with thead and bare cell text, links keep target/rel and gain none, no trailing paragraph"
+ requirement: D-04
+ verification:
+ - kind: unit
+ ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#preservation schema"
+ status: pass
+ human_judgment: false
+ - id: D3
+ description: "parseEmbedUrl accepts watch?v=, youtu.be/, shorts/, embed/ and refuses everything else; the embed node renders exactly the placeholder, shows a card and never an iframe; a malformed placeholder stays raw HTML; toolbar URL input"
+ requirement: D-05
+ verification:
+ - kind: unit
+ ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#embeds"
+ status: pass
+ human_judgment: false
+ - id: D4
+ description: "Picker and drop send summer-upload {id, file}; summer-upload-result {id, url} opens the alt/caption form and inserts img or figure; {id, error} and the 60 s timeout show the error and insert nothing; non-matching ids are ignored"
+ requirement: D-18
+ verification:
+ - kind: unit
+ ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#image uploads"
+ status: pass
+ human_judgment: false
+ - id: D5
+ description: "isPolicyUrl, setLink and the link input accept only http, https, mailto and relative URLs; javascript:, vbscript:, data:, tel: and tab/newline-split schemes are refused; created links have href only"
+ requirement: D-05
+ verification:
+ - kind: unit
+ ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#links and the URL rule"
+ status: pass
+ human_judgment: false
+ - id: D6
+ description: "Toolbar offers every D-04 feature, en and pl labels, formatting, undo/redo and table editing, raw HTML block opens Source at the insert, raw card opens its source"
+ requirement: D-13
+ verification:
+ - kind: unit
+ ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#toolbar"
+ status: pass
+ human_judgment: false
+ - id: D7
+ description: "editor-corpus.json (15 entries) equals a fresh build through editor commands and holds no iframe, handler, target, rel or style and only policy URLs"
+ requirement: D-05
+ verification:
+ - kind: unit
+ ref: "../sm-journal-plugin/tests/blogeditor/corpus.test.ts"
+ status: pass
+ human_judgment: false
+ - id: D8
+ description: "Committed bundle matches a fresh build, is embedded, has no eval/new Function; go vet clean"
+ requirement: D-16
+ verification:
+ - kind: other
+ ref: "bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./..."
+ status: pass
+ human_judgment: false
+ - id: D9
+ description: "The element in the real admin SPA: typing, selection, toolbar, drag and drop, uploads through the cabana route, CSS in light and dark mode"
+ verification: []
+ human_judgment: true
+ rationale: "happy-dom covers the logic, not browser selection or layout; plan 03 registers the field type and plan 05/06 run the UAT browser check"
+
+duration: 25min
+completed: 2026-10-10
+status: complete
+---
+
+# Phase 16.2 Plan 02: Blog Editor element Summary
+
+**`golem15-journal-blogeditor`, a TipTap 3.31.4 WYSIWYG editor with an exact-HTML Source tab, returns an untouched imported post byte for byte and, after an edit, keeps every unedited top-level block's original bytes; it ships raw HTML cards, figures, YouTube embed placeholders, policy-only links, SPA-mediated image uploads, a committed policy corpus and a minified bundle embedded in sm-journal-plugin with a drift gate.**
+
+## Performance
+
+- **Duration:** about 25 min
+- **Started:** 2026-10-10T09:31:47Z
+- **Completed:** 2026-10-10T09:56Z
+- **Tasks:** 3 (Task 1 resolved by the orchestrator, Tasks 2 and 3 executed)
+- **Files:** 24 in sm-journal-plugin (21 created, 3 modified)
+
+## Task 1 checkpoint (resolved): package report
+
+The orchestrator ran the plan's registry verify command for the five editor packages at 3.31.4. Result: `registry-ok`.
+
+| Package | Version | License | Repository | Install scripts |
+|---------|---------|---------|------------|-----------------|
+| @tiptap/core | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
+| @tiptap/pm | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
+| @tiptap/starter-kit | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
+| @tiptap/extension-table | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
+| @tiptap/extension-image | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
+
+Maintainers are on the tiptap.dev and ueber.io domains plus tiptap-bot; 3.31.4 is the current `latest` dist-tag.
+
+Human reply, verbatim:
+
+> packages approved
+
+Version Task 2 installed: **3.31.4**, pinned exactly (`npm install --save-exact`) for all five packages. Installed devDependencies: vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. npm 12 blocked esbuild 0.27.7's `postinstall` (not in `allowScripts`); esbuild works without it through its optional platform package `@esbuild/linux-x64`, so no script was approved.
+
+## Accomplishments
+
+- **Build (D-16):** private ESM `package.json` with exact pins and a committed `package-lock.json`; `vite.config.ts` builds `assets/src/blogeditor/index.ts` into `assets/dist/blogeditor.js` and `blogeditor.css` (fixed names, minified, exports kept) and configures vitest on happy-dom; `scripts/check-dist.sh` rebuilds into a temp dir and fails with `check-dist: assets/dist is stale; run npm run build and commit assets/dist`; `node_modules/` ignored; `admin.go` embeds both files.
+- **Element (D-01, D-03, D-13):** light-DOM `BlogEditorElement` with the value/dirty logic of the interfaces skeleton, `emitUpdate: false` loads outside the undo history, `summer-change` on user edits only, an exact-string Source tab, `disabled`, `invalid`, `locale` (as `lang`) and `ui-locale` (labels); `injectCSS: false` with the ProseMirror base rules in `blogeditor.css`, every selector under the tag and only `--c-*` colours.
+- **Preservation (D-03, D-04):** `rawHtml` text cards with an "Edit as HTML" action, `figure`/`figureImage`/`figcaption`, `embed`, `span` mark, the `htmlAttributes` global attribute map, tables without colgroup or min-width, cells without TipTap's align rewrite, StarterKit without the trailing node and with links that gain nothing.
+- **Serializer:** `captureBlocks` splits the source by position, validates that the pieces parse to the same DOM as the whole, and parses each alone; `serializeDocument` aligns the captured blocks with the document (prefix, suffix, LCS) and emits original bytes for unchanged runs, raw HTML verbatim and the rest through `DOMSerializer` in an inert document, with tables in thead/bare-cell form. `editor.getHTML()` is never the value.
+- **Toolbar, embeds, links, uploads (D-04, D-05, D-18):** `buildToolbar` with every listed control; `parseEmbedUrl` and `EMBED_PROVIDERS` (YouTube only); `isPolicyUrl` as Link's `isAllowedUri` and in the link input; uploads by picker or drop as `summer-upload`, insert on `summer-upload-result` with a required alt and optional caption, error and 60 s timeout in an `aria-live` status line.
+- **Corpus (D-05):** `editor-corpus.json` with 15 entries (`embed-youtube`, `image-without-caption`, `image-with-caption`, `table-header-row`, `links`, `headings`, `bullet-list`, `ordered-list`, `blockquote`, `ordered-list-start-3`, `inline-marks`, `code-block`, `horizontal-rule`, `hard-break`, `combined`), built only through editor commands and typing.
+- **README:** a "Blog Editor" section (element, contract, untouched values, embeds, link rule, upload flow, JS build, corpus) plus Dependencies and Testing lines.
+
+## Task Commits
+
+All code commits are in `../sm-journal-plugin` (branch main):
+
+1. **Task 1: package check** - resolved by the orchestrator (no commit)
+2. **Task 2: tracer** - `234e616` build(editor): blog editor JS build and drift gate; `47f3032` feat(editor): blog editor element with exact untouched value
+3. **Task 3: expansion** - `086e485` feat(editor): preservation schema, block reuse, toolbar, uploads and embeds
+
+## Bundle
+
+- `assets/dist/blogeditor.js`: 466,051 bytes minified (147,281 bytes gzip -9)
+- `assets/dist/blogeditor.css`: 7,480 bytes
+- `grep -cE "eval\(|new Function\(" assets/dist/blogeditor.js` prints 0; the only `createElement("style")` is TipTap's unused `createStyleTag` (injectCSS is false).
+
+## Verification
+
+- Task 2 verify (tracer gate re-run before expansion): smoke tests, `check-dist.sh`, `go vet ./...` and `go test ./classes/ ./controllers/...` passed.
+- Task 3 verify: `tsc --noEmit`, the full vitest run (2 files, 33 tests), `check-dist.sh` and `go vet ./...` pass.
+- Acceptance greps: the tag is in the bundle, `admin.go` has one embed line for the bundle, `git check-ignore node_modules` prints `node_modules`, `serializeDocument`/`parseEmbedUrl`/`isPolicyUrl` are exported, the corpus has 15 entries with the embed, figcaption, table and `start="3"` and 0 matches of `