Skip to content

feat(plugins): @own-created contentAccess marker for plugin-created tables - #335

Merged
DavidBabinec merged 7 commits into
CoreBunch:mainfrom
mostafasadeghidev:feat/plugin-own-created-tables
Oct 12, 2026
Merged

DavidBabinec merged 7 commits into
CoreBunch:mainfrom
mostafasadeghidev:feat/plugin-own-created-tables

Conversation

@mostafasadeghidev

@mostafasadeghidev mostafasadeghidev commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

What

Closes the design gap that made importer/migration plugins impossible to build on the sanctioned api.cms.content.* surface: cms.content.tables.create is allowed without a contentAccess[] entry, but every subsequent entry read/write to the created table failed closed, because a runtime-chosen slug can never be pre-declared in the static manifest.

  • New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER in the plugin SDK): a contentAccess[] entry whose table is @own-created covers every table the plugin itself created through cms.content.tables.create, with the entry's declared modes.
  • Durable creator record: migration 033_data_tables_created_by_plugin (additive, nullable, both dialects) adds data_tables.created_by_plugin_id. The create handler stamps the host-authenticated worker identity (msg.pluginId) — never plugin-supplied input. The marker resolves against this column, so access survives server restarts and admin-side slug renames, and is correctly lost if the table is deleted and recreated by someone else.
  • assertContentTableAccess now takes the resolved DataTable instead of a slug. Every handler resolves the table first (it always did anyway), so the check stays synchronous, adds no extra query, and can see createdByPluginId. Entries combine as a union: an operation is allowed when any matching entry declares the mode. tables.list / search filter through the same matcher (hasContentTableAccess), so own-created tables appear in listings.
  • Marker can never collide with a real table: marker entries never match by slug, the manifest slug pattern requires a leading letter (no static entry can be the literal), and the plugin-facing tables.create slug is now constrained to kebab-case, reserving the @ namespace.
  • Install-time coherence preserved: the marker satisfies the "contentAccess required when any cms.content.* permission is declared" rule (the error message now points at it), and marker modes go through the same modes↔permissions check as slug entries.

Why this design (vs. implicit creator access)

The manifest parser requires a non-empty contentAccess[] whenever entry-level content permissions are declared. Implicit creator access would need a carve-out to that rule, making an importer's manifest claim it touches nothing while it reads/writes its own tables. The explicit marker keeps the manifest the single reviewable declaration surface, keeps mode semantics uniform, and lets a plugin self-narrow modes on its own tables. Host-side registry state (the alternative to the DB column) was rejected because it would not survive restarts.

Impact

  • Importer/migration plugins (operator-chosen table names at runtime) can now be built entirely on the sandboxed api.cms.content.* surface instead of driving the admin HTTP API from unsandboxed admin-app code.
  • No change for existing plugins: slug entries grant exactly what they granted before; tables created by users/imports carry a null creator and are unreachable via the marker. One error-shape change from the resolve-then-assert ordering: probing a nonexistent table slug now yields "Content table not found" (or a null reply from tables.get) instead of the contentAccess error — for existing tables the fail-closed access error is unchanged. Slug existence is not sensitive (any authorized admin UI shows it), and access to data still requires passing the assert.
  • Bundle export/import round-trips preserve plugin ownership (createdByPluginId rides DataTableSchema as an optional field, so pre-existing archives still validate).
  • Stale SDK doc claim fixed: the install consent dialog does not render contentAccess[] verbatim; comments now say the manifest is the reviewable surface. (Rendering it in the consent UI is flagged as a follow-up task.)

Updated for site branches

Resynced with main after #484. Three things changed:

  • The migration is now 033 — site branches took 027–030, feat(plugins): author, build and activate site plugins in a full-screen IDE #495 claims 031_installed_plugins_source, and feat(media): record what depends on an asset, starting with avatars #505 claims 032. An ALTER can only be renumbered while nothing has applied it; nothing upstream has, so it moves now rather than making whoever merges second do it.
  • Every handler takes MAIN_SCOPE, and in every one the access check stays after table resolution, against the table itself. Checked by count: 17 assertContentTableAccess calls on this branch before the merge, 17 upstream, 17 after — none dropped, none left on the slug form.
  • A fork no longer drops the creator. branches/entities/table.ts copies data_tables with an explicit column list, and created_by_plugin_id wasn't in it — so on every branch a plugin-created table stopped belonging to its plugin. It now sits beside created_by_user_id. It is ownership rather than content, so it stays out of the merge diff. The new test in tables.test.ts fails without the change (Received: null) and passes with it.

tables.test.ts, content.test.ts, registry.test.ts: 25 pass. Import round-trip tests: pass.

Verification

  • bun run build (tsc -b + vite build) — clean.
  • bun run lint — clean.
  • bun test — full suite compared against a clean origin/main baseline worktree on the same (Windows) machine: baseline fails 302 tests (environmental — dominated by EBUSY temp-SQLite cleanup on Windows, plus the platform-sensitive plugin-bootstrap-fresh / bundle-budget gates), my branch failed 304. The 2-test delta was mine and is fixed in this PR: the dataCms list-shape expectation gained createdByPluginId: null, and a comment was tightened to keep src/core/plugins/manifest.ts under the 700-line module ceiling. After the fixes the failure set is identical to baseline; every touched-area test file passes (tables.test.ts, registry.test.ts, content.test.ts, contentProjection.test.ts, pluginManifest.test.ts, plugin-content-access-enforced, plugin-cms-content-surface, migration-parity, db-postgres-isms, db-json-column-naming, boundary-validation, module-size-budgets, dataCms).

@mostafasadeghidev
mostafasadeghidev marked this pull request as ready for review August 3, 2026 03:53
…ables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mostafasadeghidev
mostafasadeghidev force-pushed the feat/plugin-own-created-tables branch from ef03591 to 7795898 Compare August 7, 2026 00:03
mostafasadeghidev and others added 2 commits September 4, 2026 15:04
…tants

Three conflicts, and the migration one deserves the explanation.

Upstream took slot `025` for `025_remove_non_owner_role_management` while this
branch adds `025_data_tables_created_by_plugin`. Both entries are kept, shared
prefix and all, rather than renumbering this one to `026`.

That is deliberate, not laziness. `runMigrations` looks up applied migrations
by the FULL id string, so two ids sharing a numeric prefix both run exactly
once and neither masks the other — the prefix is a naming convention, not the
key. And the ALTER here carries no guard, because SQLite has no
`add column if not exists`; migration 010 already documents that the runner's
exactly-once gate is what makes a bare ALTER safe. An id that any installation
has already applied therefore cannot be renumbered: the new id re-runs the
ALTER against a column that exists and fails the boot. Renumber freely before
release, never after. Both dialect files list the pair in the same order,
which is what the parity gate asserts.

`contentSchemas.ts` is an adjacent-insert collision that shares one `/**`
opener, so neither side can simply win: upstream's
`CONTENT_ACCESS_MODE_PERMISSIONS` (which mode consumes which permission) and
this branch's `OWN_CREATED_TABLES_MARKER` (which tables an entry addresses)
are orthogonal, and both are kept with their own doc blocks.

Verified: tsc clean; migration parity, the plugin suites and the
content-access enforcement gate 183/183.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`026_plugin_media_sources` landed upstream while this sat open, and both
sides append to the same point in each dialect file.

Upstream's entry keeps its id. This branch's moves from `025_` — which it
shared with `025_remove_non_owner_role_management` — to `027`, so the
sequence reads cleanly again.

Renumbering is safe here for exactly the reason the comment on it states: no
installation has applied this migration under any id, because the PR is
unmerged. The ALTER carries no guard — SQLite has no `add column if not
exists`, as migration 010 documents — so once this ships, the id is fixed.

Both dialect files list the three in the same order, which is what the parity
gate asserts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 5, 2026
…red migration

Two resolutions worth recording.

`manifest.ts` and `plugin-system.md` collided on the same doc text in two
versions. The stack's is the fuller one — it also describes the consent
screen CoreBunch#336 adds, which the CoreBunch#335 branch alone knows nothing about — so the
stack's copy wins both.

The migrations needed care in both directions. CoreBunch#335 renumbers this fork's
`025_data_tables_created_by_plugin` to `027` for upstream, which is correct
there and wrong here: two live installations have already recorded the `025`
id, and the ALTER carries no guard, so a second id would re-run it against an
existing column and fail the boot. The merge left both entries; the `027`
duplicate is dropped and the fork keeps its own id.

Checking that turned up a real loss. `026_plugin_media_sources` arrived
upstream in e7a27ca and the commit IS in this branch's history, but the
entry was not in either dialect file — an earlier conflict resolution here
dropped it while keeping the code that reads the table
(`server/repositories/pluginMediaSources.ts` queries `plugin_media_sources`).
A fresh install would have created that table nowhere. Restored from upstream
verbatim, after our 025 so both dialects keep the same order.

Verified: tsc, build and lint clean; media + plugins + settings + parity
292/292; db and plugin-handler suites 40/40, which is what actually runs the
migrations; fork-stack-capabilities 12/12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
Site branches took 027–029 and the ISO timestamp rewrite took 030, so the
avatar backfill moves from 028 to 032. It skips 031, which CoreBunch#335 now claims.

Renumbering this one is safe in a way renumbering an ALTER is not: the
backfill inserts only where `not exists`, so an installation that recorded
it under 028 runs it again under 032 and inserts nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev and others added 2 commits September 13, 2026 10:05
Site branches took 027–029 and the timestamp rewrite took 030, so this
migration moves to 031. Nothing has applied 027 under this id upstream,
which is the only condition under which an ALTER may be renumbered.

Site branches also made every repository call take a `BranchScope`. Plugins
only ever act on the live site, so each handler gains `MAIN_SCOPE` — and in
every hunk the access check this branch introduced stays exactly where it
was: after the table is resolved, against the table itself, because the
`@own-created` marker needs the table's creator and a slug cannot carry it.

The count is the check on that: 17 `assertContentTableAccess` calls here,
17 on this branch before the merge, 17 upstream — none dropped, none left
on the slug form.

`tables.list` loses upstream's `allowedSlugs` set; it was the slug-only
filter this branch replaced with `hasContentTableAccess`. The tests that
auto-merged still called the pre-branch repository signatures, and are
scoped here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
CoreBunch#498 landing

Twenty upstream commits, including two of this stack's own PRs (CoreBunch#359, CoreBunch#498)
merged exactly as submitted.

Resolved hunk by hunk, and each file checked against the resolution its PR
branch reached independently:

  - content.ts, import.ts — every handler gains `MAIN_SCOPE`; every
    `@own-created` access check stays on the resolved table. Identical to
    CoreBunch#335's branch.
  - ConfirmDeleteContext — upstream's `tone` beside this stack's `details`.
    Identical to CoreBunch#507's branch.
  - publishSite.ts — only an import collided; the file now matches upstream
    byte for byte, as it should once CoreBunch#359 has landed.

Migrations are the one place the stack deliberately differs from the PRs.
`025_data_tables_created_by_plugin` keeps its id: two installations recorded
it, and the ALTER cannot run twice. Its comment now says what to do when
CoreBunch#335 lands as `031_…`. The avatar backfill takes CoreBunch#505's `032` — safe to
renumber because it inserts only where `not exists`.

`contentUsage.ts` gets `MAIN_SCOPE` to type-check; reading every branch
arrives with CoreBunch#507's merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
…opy stays out

Brings CoreBunch#335's branch-copy fix (`created_by_plugin_id` joins the column list
a fork copies) and the tests its upstream merge re-scoped.

The migration conflict is the case the stack's 025 note describes: CoreBunch#335
carries the ALTER as `031_data_tables_created_by_plugin`, and this branch
already runs it as `025_…` on installations that recorded that id. Keeping
both would run the ALTER twice and stop those servers booting, so the
incoming 031 entry is dropped. Both migration files are byte-identical to
before this merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
…icated

`tables.test.ts` carried the `created_by_plugin_id` describe block twice:
an earlier upstream merge moved the route-base block between them and kept
both copies. The first was the stale one, still on the pre-branch repository
signatures, so it failed as soon as those signatures took a scope. The file
is now identical to CoreBunch#335's branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 13, 2026
… and refuse a doubled ALTER

README: CoreBunch#359 and CoreBunch#498 move to the landed table, the count drops to ten,
and CoreBunch#507's row says what it does now — every page, on every branch.

The fork gate's own rule is that a merged PR's row is deleted, since the
capability then arrives with main. Nine merged rows had never been removed;
they go. CoreBunch#336 was never pinned at all; it is now.

And one new check, for the one merge mistake that takes a server down on
boot: no column may be added by two different migrations. The stack runs
CoreBunch#335's ALTER as `025_…` while upstream will carry it as `031_…`; keeping
both when CoreBunch#335 lands makes every recorded installation run it twice.
Verified by planting that exact `031` entry — the test names the collision
and fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CoreBunch#495 claims `031_installed_plugins_source` and CoreBunch#505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 16, 2026
The note still said CoreBunch#335 claims 031. It now claims 033, because CoreBunch#495
took 031 — the backfill stays at 032 between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 16, 2026
… and 033 (CoreBunch#335)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 16, 2026
… and 033 (CoreBunch#335)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Sep 16, 2026
CoreBunch#335 moved its ALTER from 031 to 033 because CoreBunch#495 claims 031. This branch
runs the same ALTER as `025_…`, recorded by both live installations, so
the incoming entry is dropped exactly as the 031 one was: both migration
files are unchanged by the merge itself. The notes that tell a future merge
what to delete now name 033, and so does the fork gate's comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DavidBabinec
DavidBabinec merged commit 6abafef into CoreBunch:main Oct 12, 2026
5 checks passed
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Oct 16, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Oct 16, 2026
…this one

CoreBunch#335 merged with its own wording for the contentAccess doc comment. Kept
its `@own-created` paragraph and its opening line, and put this PR's
consent-screen paragraph after it in place of CoreBunch#335's shorter "ships in the
manifest so the operator can review" sentence, which it supersedes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mostafasadeghidev
mostafasadeghidev deleted the feat/plugin-own-created-tables branch October 16, 2026 18:42
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Oct 16, 2026
… stays out

Upstream shipped CoreBunch#335 with its ALTER as `031_data_tables_created_by_plugin`.
This branch has run that ALTER as `025_…` on both live installations, so the
incoming 031 entry is dropped from both migration files — keeping it would
re-run the ALTER and fail the boot. The 025 note now also says what an
installation needs before moving to plain upstream (record 031 as applied).
The 032 backfill note names the final ids.

Resolved hunk by hunk:
- import.ts, SettingsModal.{tsx,module.css}: upstream's copy. Only CoreBunch#335 and
  CoreBunch#499 had touched them on this branch, and upstream carries both (the
  import moved into applySiteBundle with createdByPluginId; the keycap now
  uses Button's align/fullWidth). Checked: no other fork line was in them.
- manifest.ts, plugin-system.md: the stack's side inside the conflict hunks
  only — that text is CoreBunch#336's, still in review; the rest of upstream's
  changes to both files merged normally.
- fork gate: CoreBunch#335 and CoreBunch#499 rows removed, the migration note says 031.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mostafasadeghidev added a commit to mostafasadeghidev/Instatic that referenced this pull request Oct 16, 2026
…ch#501 is now three PRs

CoreBunch#335 and CoreBunch#499 move to the landed table (shipped in v0.0.21). CoreBunch#501 was
closed in review and asked to come back split: its rows become CoreBunch#658
(select-all), CoreBunch#659 (upload queue stays closed) and CoreBunch#660 (minimize). The
three parts of CoreBunch#501 that are in none of them — the Uploads progress label,
the queue's bottom-left default, and the Dialog body size — are listed as
carried-but-not-proposed, so the README still says what a server runs.

Also: what an installation needs before moving to plain upstream, since
upstream's 031 repeats an ALTER this branch ran as 025.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DavidBabinec added a commit that referenced this pull request Oct 27, 2026
* fix(import): map unsupported anchor targets to _self or _blank

The anchor import rules copied target="" (and _top or a named frame)
straight into the node. None of those values is in AnchorTargetSchema, so
the publisher's fallback replaced the whole link with module defaults and
the page shipped "Click here" pointing at "#". The schema now lives in
@core/htmlAttributes with a normalizeAnchorTarget() helper the importer
uses for both anchor rules: empty and _top import as _self, a named target
as _blank, keywords match case-insensitively, and href and text are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.

The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.

Closes #223

* fix(data): author reassignment rides the row-access boundary (#625)

PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules

The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.

One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.

* fix(data): drop the row read import the shared access helper made unused

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header

Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.

* test(mcp): assert discovery origins by parsed origin, not string prefix

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: MelmacAlpha <github@201620.de>
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
Co-authored-by: Rick Caves <spcxai2025@gmail.com>
Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
DavidBabinec added a commit that referenced this pull request Oct 27, 2026
…ition (#573)

* fix(site-import): keep a repeated class rule at its later cascade position

A stylesheet that styled the same bare class twice had its second rule
merged into the first, so the merged rule sat before any rule written
between the two occurrences and lost cascade ties it wins in a browser.
Each occurrence is now its own rule at its own source position. Both
stay class-kind through parsing so cross-sheet conflict detection still
sees the file's full definition; the registry demotes repeats at commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.

The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.

Closes #223

* fix(data): author reassignment rides the row-access boundary (#625)

PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules

The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.

One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.

* fix(data): drop the row read import the shared access helper made unused

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header

Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.

* test(mcp): assert discovery origins by parsed origin, not string prefix

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): preserve repeats after empty conditional class rules

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: MelmacAlpha <github@201620.de>
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
Co-authored-by: Rick Caves <spcxai2025@gmail.com>
Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
DavidBabinec added a commit that referenced this pull request Oct 27, 2026
…ks (#560)

* fix(import): close each style block's open @media before joining blocks

A browser parses each <style> element as its own stylesheet, so an
unclosed @media or rule at the end of one block ends at that block. The
importer joins a page's blocks into one source, so one missing `}` pulled
every later block into the media query. Close each block's open braces,
comment, or string before joining.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.

The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.

Closes #223

* fix(data): author reassignment rides the row-access boundary (#625)

PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules

The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.

One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.

* fix(data): drop the row read import the shared access helper made unused

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header

Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.

* test(mcp): assert discovery origins by parsed origin, not string prefix

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): close CSS strings after a trailing escape

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: MelmacAlpha <github@201620.de>
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
Co-authored-by: Rick Caves <spcxai2025@gmail.com>
Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
DavidBabinec added a commit that referenced this pull request Oct 27, 2026
… non-secure origins (#576)

* fix(agent): hash code assets without Web Crypto so AI tools work over non-secure origins

The runtime code-asset tools hashed content with `crypto.subtle.digest`, which
is only defined in a secure context (HTTPS or localhost). When the CMS is reached
over a LAN/Tailscale IP on plain http:// — a non-secure context — `crypto.subtle`
is undefined, so the first code-asset tool call threw "crypto.subtle is undefined"
and the agent hung.

Add a dependency-free `sha256Hex` (src/core/utils/sha256.ts) that produces the
identical 64-char digest in any context, and use it for the read→patch
optimistic-concurrency fingerprint. The hash is a self-consistent, client-only
fingerprint — never cryptographic-security-bearing — so a pure-JS SHA-256 is a
drop-in. New test verifies it byte-for-byte against native WebCrypto across
UTF-8 and all padding boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 78dbb7a63251bca132bb5ed58f0b15e179817acf)

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.

The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.

Closes #223

* fix(data): author reassignment rides the row-access boundary (#625)

PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules

The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.

One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.

* fix(data): drop the row read import the shared access helper made unused

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header

Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.

* test(mcp): assert discovery origins by parsed origin, not string prefix

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@Keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: MelmacAlpha <github@201620.de>
Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>
Co-authored-by: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>
Co-authored-by: Rick Caves <spcxai2025@gmail.com>
Co-authored-by: David Babinec <49069339+DavidBabinec@users.noreply.github.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
DavidBabinec added a commit that referenced this pull request Oct 27, 2026
* fix(import): keep the content a compound submit button wraps

base.submit was a leaf whose only content was the label string, with a
'Submit' fallback for buttons with no text. Submit buttons that carry an
icon instead of text (an inline svg, an icon-font <i>, an <img>) lost the
icon on import and published as the literal word "Submit".

base.submit now accepts children and renders them when it has any, falling
back to the label when it has none (the base.link contract, shared between
the publisher render and the canvas via forms/content.ts). The importer
recurses into submit buttons with element children instead of flattening
them to a label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.

The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.

Closes #223

* fix(data): author reassignment rides the row-access boundary (#625)

PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules

The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.

One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.

* fix(data): drop the row read import the shared access helper made unused

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header

Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.

* test(mcp): assert discovery origins by parsed origin, not string prefix

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(site-import): upload and rewrite imported video sources and posters (#661)

* fix(site-import): upload and re-point imported video sources

The HTML importer stores a <video>/<source> URL in base.video's
`videoUrl` prop, but the site-import asset pipeline only normalised and
rewrote `src`, `href` and `srcset`. Imported videos kept their relative
path and the file was never uploaded.

Add `videoUrl` to the URL-bearing props and make assetPlan.ts the single
owner of that list so applyAssetRewrites can no longer drift from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): retain and rewrite native video posters

* docs(import): group video mapping separately from anchor rules

---------

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(import): map unsupported anchor targets to _self or _blank (#575)

* fix(import): map unsupported anchor targets to _self or _blank

The anchor import rules copied target="" (and _top or a named frame)
straight into the node. None of those values is in AnchorTargetSchema, so
the publisher's fallback replaced the whole link with module defaults and
the page shipped "Click here" pointing at "#". The schema now lives in
@core/htmlAttributes with a normalizeAnchorTarget() helper the importer
uses for both anchor rules: empty and _top import as _self, a named target
as _blank, keywords match case-insensitively, and href and text are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed …
DavidBabinec added a commit that referenced this pull request Oct 27, 2026
* fix(collab): publish presence for users without a display name

"Your name" is optional at setup and in the profile, but the editor only
published presence when the user had a display name, and the relay derived
the session identity from the raw display name. A user without one
published no presence at all: peers saw no cursor or selection ring, and
the "someone else is editing" guards never fired for them.

userDisplayLabel (src/core/users/displayName.ts) returns the trimmed
display name, else the email address. The relay's session identity and the
editor's published presence both use it, so they keep matching the
anti-spoofing check; collaborators now see the email of a user without a
display name. The account menu, profile tab, activity widget, users table,
audit labels and UserAvatar use the same helper, replacing displayUserName
and the inline fallbacks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(site-import): upload and rewrite imported video sources and posters (#661)

* fix(site-import): upload and re-point imported video sources

The HTML importer stores a <video>/<source> URL in base.video's
`videoUrl` prop, but the site-import asset pipeline only normalised and
rewrote `src`, `href` and `srcset`. Imported videos kept their relative
path and the file was never uploaded.

Add `videoUrl` to the URL-bearing props and make assetPlan.ts the single
owner of that list so applyAssetRewrites can no longer drift from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): retain and rewrite native video posters

* docs(import): group video mapping separately from anchor rules

---------

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(import): map unsupported anchor targets to _self or _blank (#575)

* fix(import): map unsupported anchor targets to _self or _blank

The anchor import rules copied target="" (and _top or a named frame)
straight into the node. None of those values is in AnchorTargetSchema, so
the publisher's fallback replaced the whole link with module defaults and
the page shipped "Click here" pointing at "#". The schema now lives in
@core/htmlAttributes with a normalizeAnchorTarget() helper the importer
uses for both anchor rules: empty and _top import as _self, a named target
as _blank, keywords match case-insensitively, and href and text are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.

The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.

Closes #223

* fix(data): author reassignment rides the row-access boundary (#625)

PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules

The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.

One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.

* fix(data): drop the row read import the shared access helper made unused

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header

Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.

* test(mcp): assert discovery origins by parsed origin, not string prefix

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (…
DavidBabinec added a commit that referenced this pull request Oct 27, 2026
* fix(media): let the upload queue window stay closed mid-upload

The close button worked, then an effect undid it. The effect depended on
both `uploadQueue.active` and the window's open state, so closing the
window re-ran it, `active && !open` was true, and it opened again on the
next commit. While a transfer was in flight the window could not be shut.

The open state moves into `useUploadQueueWindow(active)`, which opens the
window on the transition into an active upload — detected during render
against the last `active` it saw — and on nothing else. Closing hides the
transfer; it doesn't cancel it. The next upload opens the window again,
and a finished transfer still leaves it open until dismissed. Doing it in
render rather than an effect also drops the set-state-in-effect disable
and the one-commit open lag.

The test drives the real hook through what a user does — upload starts,
close, progress re-renders — and fails against the old effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(site-import): upload and rewrite imported video sources and posters (#661)

* fix(site-import): upload and re-point imported video sources

The HTML importer stores a <video>/<source> URL in base.video's
`videoUrl` prop, but the site-import asset pipeline only normalised and
rewrote `src`, `href` and `srcset`. Imported videos kept their relative
path and the file was never uploaded.

Add `videoUrl` to the URL-bearing props and make assetPlan.ts the single
owner of that list so applyAssetRewrites can no longer drift from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): answer conditional requests for static files and uploads with 304 (#653)

* fix(server): answer conditional requests for static files and uploads with 304

serveStaticFile sent `Cache-Control: no-cache` for /uploads and the admin
shell files but no ETag or Last-Modified, so the browser had nothing to
revalidate with and re-downloaded every file in full. The editor preview
iframe reloads every image and font on each rebuild: about 117 requests
and 3.7 MB per preview load on one production site.

Responses now carry a weak ETag (size + mtime) and Last-Modified, and
If-None-Match / If-Modified-Since get a bodiless 304 (If-None-Match
wins, per RFC 9110). A media replace that keeps the path changes the
mtime, so the validator changes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call's 90s timeout started at push time,
so calls queued behind slow ones (render snapshots, a background tab) timed
out before the editor reached them. The 120s idle lease was only re-armed by
pushes, so a batch whose serial execution outlasted it closed the stream
mid-batch: the in-flight call was rejected ("closed with 1 pending tool
result(s)") and the editor's late result POST got a 404.

- createBridge gains an opt-in `serial` option: the next request is emitted
  only after the previous one settles, and its timeout starts on emit.
  Queued calls still reject on abort/destroy without being emitted. The chat
  bridge is unchanged.
- The editor bridge uses serial delivery, and its idle lease re-arms instead
  of closing while any call is pending. Each pending call is bounded by the
  per-call timeout, so an orphaned stream still drains and closes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collab): preserve initial sync delivery under backpressure (#590)

* fix(collab): preserve initial sync delivery under backpressure

* fix(collab): packetize large stored document delivery

* fix(server): preserve upload MIME when revalidating cached files

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: rodrigoc-neu-Lab <rodrigo.c@neuwrk.com>
Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(preview): rebuild runtime previews after the draft settles, keep the last build meanwhile (#646)

Each runtime preview build POSTs the whole site document and the server
re-validates it, re-bundles the page scripts and renders the page. The
Preview overlay and the Site Explorer hover card rebuilt on every `site`
change, so typing or a collaborator's edits aborted the in-flight build and
started another — production logs show bursts of 8 cancelled requests within
two seconds — and the preview blanked to "Building…" each time.

- useRuntimePreviewDocument builds from the draft only once it has stayed
  unchanged for 400ms (PREVIEW_SITE_SETTLE_MS).
- While a newer build runs, `html` keeps the previous build for the same page
  and context; both callers show the loading state only when there is
  nothing to show yet.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): read :not() and :is() correctly when tree-shaking class rules (#572)

The shaker read every registry class in a selector as a requirement,
including classes inside :not() and each alternative of :is()/:where().
A negated class is the opposite of a requirement and :is() alternatives
are each sufficient, so rules like `.nav > li > a:not(.cart-button)`
were dropped whenever no node carried the negated class.

selectorPartCanMatch now splits a part into the text whose classes are
all required and the alternation groups where one alternative is enough;
:has() and :nth-child(n of ...) stay required. For selectors the shared
lexer reads correctly, drops are a subset of before.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep style and class injection out of data-style and data-class (#561)

The root-tag lookups used /\bstyle="/ and /\bclass="/, and \b matches
after the hyphen in data-style and data-class. Node styles or classes
were merged into the data attribute and the real attribute was never
emitted. Both regexes now require whitespace before the attribute name,
and the replacements use a function so $ sequences in values stay inert.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): repair invalid props one by one instead of resetting the node (#574)

When Value.Parse rejected a node's props, validateNodeProps replaced every
declared prop with the module default, so a link with one unsupported
target published as "Click here" pointing at "#" while its href and text
sat valid in the store. The catch branch now parses each declared prop on
its own and falls back to the default only for the props that still fail.
Fast path, successful slow path, optional props and injected keys are
unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): retain and rewrite native video posters

* docs(import): group video mapping separately from anchor rules

---------

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Diogenes Pelisson <diogenes.pelisson@gmail.com>
Co-authored-by: kenny-ish <114967656+kenny-ish@users.noreply.github.com>
Co-authored-by: Norbert Gocht <ngocht@users.noreply.github.com>
Co-authored-by: Emmanuel Ruiz <emmanuel.ruiz@arthrex.com>
Co-authored-by: Emmanuel Ruiz <ehnruiz@gmail.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Marco D'Antonio <m.dantonio@netsons.com>
Co-authored-by: tommy230 <tommy@godigitalalchemy.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(import): map unsupported anchor targets to _self or _blank (#575)

* fix(import): map unsupported anchor targets to _self or _blank

The anchor import rules copied target="" (and _top or a named frame)
straight into the node. None of those values is in AnchorTargetSchema, so
the publisher's fallback replaced the whole link with module defaults and
the page shipped "Click here" pointing at "#". The schema now lives in
@core/htmlAttributes with a normalizeAnchorTarget() helper the importer
uses for both anchor rules: empty and _top import as _self, a named target
as _blank, keywords match case-insensitively, and href and text are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(editor): apply classes to all selected elements (#511)

Assisted-by: OpenAI Codex

Co-authored-by: MelmacAlpha <260151560+MelmacAlpha@users.noreply.github.com>

* fix(settings): make the Esc keycap close the modal it advertises (#499)

* fix(settings): make the Esc keycap close the modal it advertises

The footer reads `[Esc] close`, and the keycap looks like a button because
every `Kbd` depresses on `:active`. Clicking it played that press animation
and did nothing; the pointer over the word "close" turned into a text caret
and a click just selected the text. An operator reasonably concluded the
modal could not be dismissed with the mouse.

It could — the backdrop has always closed it, and Escape works — but a
control that animates under the cursor and then ignores the click is worse
than no control, because it answers the question wrongly.

So the hint becomes the button it already looked like. `Kbd` itself is
untouched: six other surfaces render it as a genuine keyboard hint where the
press styling is right, and only this one sits next to the word "close".

The file header claimed there was no dedicated close button. There is one
now, so it says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(settings): let the Button primitive own the Esc keycap's alignment and width

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* feat(plugins): @own-created contentAccess marker for plugin-created tables (#335)

* feat(plugins): @own-created contentAccess marker for plugin-created tables

Plugins could create tables via cms.content.tables.create but never read
or write their entries: the static contentAccess[] allowlist matches by
exact slug, and a runtime-chosen slug cannot be pre-declared. This made
importer/migration plugins impossible on the sanctioned surface.

- Migration 025 (both dialects, additive): data_tables.created_by_plugin_id,
  stamped by the create handler with the host-authenticated worker identity.
- New @own-created contentAccess marker (OWN_CREATED_TABLES_MARKER): covers
  every table the declaring plugin created, resolved against the stored
  creator (never by slug), with the entry's declared modes; entries combine
  as a union. Marker modes go through the same install-time modes<->permission
  coherence check as slug entries.
- assertContentTableAccess now takes the resolved DataTable (handlers
  resolve-then-assert; tree callbacks already had the table). tables.list /
  search filter through the same matcher, so own-created tables appear.
- tables.create slug constrained to kebab-case, reserving the @ namespace;
  bundle export/import preserves createdByPluginId.
- Docs, capability descriptions, SDK comments updated; stale "consent screen
  renders contentAccess verbatim" claim corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(branches): a fork keeps which plugin created a table

Forking a branch copies `data_tables` with an explicit column list, and
`created_by_plugin_id` was not in it. The branch's copy of a plugin-created
table came out with no creator — so the `@own-created` contentAccess marker
no longer matched it, and the table stopped belonging to the plugin that
made it. Nothing reported it; the column was simply null on the copy.

It is ownership, not content, so it does not belong in the merge diff — it
belongs in the copy, beside `created_by_user_id`, which was already there.

The test fails without the change (`Received: null`) and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): move the migration to 033 — 031 and 032 are claimed

#495 claims `031_installed_plugins_source` and #505 claims `032`, so this
ALTER moves to `033` before either lands. The runner keys on the full id,
so two `031_…` entries would both run — but a shared number still makes
whoever merges second renumber, and an ALTER can only be renumbered while
no installation has applied it. That is true today, so it moves now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(db): number the created_by_plugin_id migration 031, next after 030 on main

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(site-import): resolve cache-busted asset URLs (#539)

* fix(site-import): resolve cache-busted asset URLs

A query string or fragment was never stripped before looking a path up in the
FileMap, so `assets/site.css?v=8f3a1c` resolved to a key that cannot exist. The
reference was reported as `missing-stylesheet` / `missing-script` while the file
sat in the archive, and every style rule, colour token and font in that sheet was
silently dropped from the import.

Static site generators commonly emit a content hash on stylesheet, script and
media URLs, so this affects a whole class of input. Measured on one real 6-page
export: 401 style rules and 18 colour tokens imported, against 659 and 26 for the
byte-identical site without the queries — a 39% and 31% loss, reported only as
warnings a user can click past.

The handling was inconsistent rather than absent, which is why it survived:
`linkRewrite` stripped the query itself before calling `resolveHref`, so internal
page links resolved correctly while stylesheets did not, and `assetPlan` stripped
one for MIME sniffing but not for resolution.

Fixed at both resolution primitives:

  - `resolveHref` (stylesheets, scripts, CSS @import, and page links via
    linkRewrite)
  - `resolveRelativePath` (CSS url(), node src/href/srcset)

and removed the now-redundant strip in `linkRewrite`. Its fragment split stays —
the fragment is re-attached to the page ref and is not addressing.

This cannot affect the rewrite step. Node props are replaced by the resolved
FileMap key and `rewriteProps` keys on that key, while CSS passes the original
`rawUrl` to `replaceRawUrlInValue` as a separate argument, so stripping changes
only which entry is found.

Tests: a unit group on `resolveHref` covering query, fragment, both, and
empty-after-strip, plus an assertion that a query does not change traversal
handling; and an end-to-end group over `buildImportPlan` asserting that a
cache-busted site produces a plan identical to the same site without queries.
The end-to-end shape matters here — the unit returned a plausible-looking key and
every consumer dutifully failed to find it, so the defect was only ever visible
downstream. Verified the new tests fail without the source change (11 failures)
and pass with it.

bun run build, bun test (7067), bun run lint all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(site-import): drop the dead query strip in fontFormatForPath

resolveRelativePath now strips the query before the FileMap lookup, so the
only input this helper receives can no longer carry one.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: DavidBabinec <hello@davidbabinec.com>

* fix(dashboard): onboarding step 03 follows the Home page being published (#624)

Setup seeds one empty, unpublished Home page. The checklist step for it
was "Create your first page", completed only when a second page existed,
so a user who edited and published Home still saw "Not started" next to
a Pages widget reporting 1 published. A one-page site could never
complete the step at all.

The step is now "Edit the Home page". It reads In progress from first
boot and completes once the dashboard pages stats report at least one
published page, which is the moment the site is actually on the
frontend. The rules move into a pure deriveOnboardingFacts so they are
unit-tested without the network; the hook feeds it the same pages stats
endpoint the Pages widget already uses.

Closes #223

* fix(data): author reassignment rides the row-access boundary (#625)

PATCH /data/rows/:id/author read the target row with a bare getDataRow
instead of loadRowForAccess, so a persona holding content.edit.any but
not data.system.tables.read could read the full contents of any
system-table row (unpublished pages and posts, author identity) and
re-author it, while GET on the same row correctly answered 404. The
handler now resolves the row through the same table-family plus
ownership check every other row write uses.

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules (#626)

* fix(ai): read tools and the MCP relay decide row access with the HTTP rules

The content read tools (content_get_document, content_list_documents,
content_search_documents) called the row repositories directly, so a
caller holding ai.chat plus content.edit.own could read every other
user's drafts and any system-table row through the tool surface, while
GET /data/rows/:id for the same caller answered 404. The MCP relay
carried its own copy of the ownership rule and skipped the table-family
boundary altogether.

One module now owns the decision. server/auth/dataAccess.ts holds the
table-family and ownership predicates plus resolveRowAccess and
rowVisibilityFor; the HTTP handlers, the read tools, and the MCP relay
all call it with the identity behind the request. An architecture gate
fails any direct row read under server/ai that bypasses it, because
this class was fixed on the HTTP side once (GHSA-x69h) and came back
through the next reader.

* fix(data): drop the row read import the shared access helper made unused

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header (#627)

* fix(mcp): derive OAuth discovery from the configured public origin, never the Host header

Without PUBLIC_ORIGIN the MCP OAuth issuer fell back to the inbound Host
header, so an unauthenticated client could make the 401 challenge, both
.well-known discovery documents, and the resource comparisons echo an
attacker-controlled origin (GHSA-hgjv-q732-4phj). The issuer now comes
only from the configured public origin; without one, loopback requests
use their own origin so local development keeps working, and every other
request has no issuer: discovery and the token endpoint answer 503, the
401 challenge drops resource_metadata, and OAuth grants are not looked
up while personal access tokens keep working. The server warns at boot
when no public origin is configured.

* test(mcp): assert discovery origins by parsed origin, not string prefix

* chore(release): prepare 0.0.21 (#628)

* fix(framework): set the root font size the rem scale assumes (#636)

The framework converts every type and spacing token to rem against
rootFontSize (10 in the Core preset), but nothing set html font-size, so at
the 16px browser default every token rendered 1.6x larger than the scale
says. A new emitRootFontSize preference makes the framework emit
html { font-size: 62.5% } (rootFontSize / 16 as a percentage) in both the
published framework.css and the canvas iframes. The Core preset turns it on
for new frameworks; existing sites have no stored value, which means off, so
they keep rendering exactly as before. Site settings > Publishing gets a
toggle to opt in.

* fix(framework): make theme-inverted sections take the alt colors (#635)

The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on <html>, so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class.

* feat(fonts): seed heading, body and mono font role tokens on new sites (#638)

* feat(fonts): seed heading, body and mono font role tokens on new sites

New sites had no font tokens and the codebase disagreed on names (docs said
font-primary, the AI prompt said font-heading), so content made for one site
did not resolve in another. Every brand-new site now starts with fallback-only
font-heading, font-body and font-mono tokens, the Core preset adds any a site
lacks without touching existing ones, and docs, schema comments and the AI
prompt use the role names. Existing sites are not migrated.

* fix(site-import): fill an untouched font role token instead of renaming

Seeding font-heading, font-body and font-mono on new sites made an imported
static site that defines one of those variables collide with the seed and get
renamed to font-body-2, leaving the role token on the system stack. A role
token that is still exactly as seeded is a placeholder, so the conflict now
defaults to overwrite; a role token the user edited keeps the rename default.

* docs(fonts): describe role tokens in terms of shared content

The rationale now names imported pages, copied sections and AI output as the content the role variables serve.

* test(framework): read the framework from the preset result in root font size tests

applyFrameworkPreset now returns { framework, fonts }, so the root font size tests read .framework; one of them passed only because it read an absent property.

* fix(import): replace import removes the previous site media (#637)

A replace import wiped rows, folders and redirects but kept the old
media_assets rows, so a second site shipping a file at the same storage
path (two sites that embed the same font file) failed on
media_assets.public_path after the data had already committed. Replace
now deletes the library inside the import transaction and removes the
bytes after commit, before the bundle's media is written. It only does so
when the import carries media and the shell on main with no other
branch, and it spares avatars and plugin-managed media, since those
survive a replace. The archive endpoint now shares the JSON endpoint's
apply path instead of a synthetic request, and the custom-table delete
binds its boolean so replace runs on Postgres.

* fix(editor): stop canvas chrome tokens shadowing site custom properties (#639)

The editor-chrome stylesheet in the canvas iframe is unlayered and copied
admin tokens such as --radius and --text onto :root under their own names.
Unlayered beats the site's @layer user-authored, so a site defining those
properties rendered with editor values in the canvas and its own values when
published. Every forwarded token now lives under --chrome-*, derived from the
var(--chrome-*) references in the chrome rules.

* fix(ai): serialize ai_messages content before insert on Postgres (#592)

On Postgres `ai_messages.content_json` is `text`, and Bun SQL binds a plain
array to a text parameter via String(), storing '[object Object]'. Replaying
that history yields empty user content and provider calls fail (Anthropic:
400 "user messages must have non-empty content").

JSON.stringify the content at the insert. SQLite passes strings through
unchanged, and reads on both dialects JSON.parse `_json` strings, so
behavior there is identical. Kept narrow on purpose: stringifying every
object param in the PG adapter would turn values bound to jsonb columns
into JSON string scalars and break jsonField() queries.

Fixes #593.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(forms): accept the option value a form control submits for select fields (#667)

A CMS-native form whose target table has a select or multiSelect field
could never be submitted. The base.option nodes the setup block inserts
from the table carry option.value (the slug authors edit in the Data
tab), but validateCoercedValue only accepted option.id, the generated
nanoid authors never see. Every choice came back as invalid_option.

Resolve the submitted string by option id or value while coercing the
field and store the option id, which is the documented cell shape for
these fields. The id-only checks in validateCoercedValue are removed
since coercion now guarantees a known id.

Fixes #629

* fix(publish): prune stored runtime assets of superseded page versions (#647)

Every full publish stores each page's bundled runtime files in
published_runtime_assets under a fresh version path, and nothing ever
deleted them. On a busy production site the table reached 206k rows / 1.5 GB
(696 MB written in one day), bloating the database and its backups for bytes
that are only a fallback behind the disk-baked copies.

persistSitePublish now deletes, per page, the stored assets of every version
other than the one being activated and the one it supersedes. Nothing
re-activates an old version, and keeping the previous one covers visitors
still holding its HTML — the same window the disk's two-slot swap covers.
Version rows are untouched. The first publish after upgrading drops the
backlog for the published pages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): resolve custom media fields on entry routes (#662)

On a post-type entry route, `{currentEntry.<field>}` for a custom media
field rendered the raw media id, while the same binding inside a
data.rows loop rendered the /uploads/ path: the entry frame spread raw
cells and only resolved the built-in featuredMedia.

publishedDataRowToEntryItem now resolves the row's media cells with the
same helpers the loop source uses, and every entry-stack caller (public
entry route, branch preview, draft preview) goes through it.

Co-authored-by: RC <rodrigoc-neu-Lab@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(publisher): keep custom properties with underscores or a leading --_ (#617)

The property-name gate rejected any custom property containing `_` or
starting with `--_`, so the importer and the publisher dropped them and
every var() on them resolved to nothing. Custom properties are now checked
against the spec's identifier rule; standard property names keep the
stricter pattern and the denylist still applies.

Custom property names also pass through toKebab unchanged, so
`--brandColor` is no longer emitted as `--brand-color`.

Fixes #616

* fix(siteImport): rewrite var() references to the slug a colour token is emitted under (#618)

Site Import turned root colour custom properties into framework tokens,
which the framework emits under the normalised slug
(`--brand---neutral--700` -> `--brand-neutral-700`), but left the imported
`var()` references on the authored name, so they no longer resolved.

The slug is now settled at plan time by `normalizeImportedColorTokens`,
and every changed name is rewritten in the imported style rules and node
inline styles before conflict detection, so the conflict step and the
commit see the emitted name. Two names that normalise to the same slug
both survive with a numeric suffix instead of one being dropped at commit.

Token renames (normalisation and conflict renames) now also rename
declarations of the variable in other rules, their priorities, and raw
@keyframes CSS, so a `.dark { --bg: ... }` override follows its token.
Conflict rename targets skip the import's other token names and any
custom property the imported CSS declares, and a colour custom rename is
normalised like the slug it becomes.

The rename helpers move from conflicts.ts into siteImport/varRefs.ts,
which keeps conflicts.ts under the 700-line module budget.

Fixes #615

* fix(siteImport): rewrite var() references to the variable a font token is emitted under (#620)

Site Import normalised a root `--font-*` variable at extraction
(`--font-Heading` -> `font-heading`), and the store emits that form, but
the imported `var(--font-Heading)` references kept the authored name and
no longer resolved. Two authored names that normalise alike were deduped
into one token, losing a font.

Extraction now keeps the authored variable, and `normalizeImportedTokens`
(new `siteImport/tokenNames.ts`) settles the emitted name of every
imported colour and font token at plan time, in one pass against one set
of claimed names, since both kinds share one CSS namespace. The
`authored -> emitted` renames go through the existing var() and
declaration rewrite, before conflict detection.

`normalizeImportedColorTokens` is replaced by `normalizeImportedTokens`.

Fixes #619

* fix(publisher): keep safe images in published entry bodies (#664)

* fix(publisher): keep safe images in published entry bodies

Fixes #595

* fix(publisher): route content render paths through the image profile

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(loops): sort numeric row cells by value (#601)

* fix(publisher): emit one merged rel on links and buttons instead of two (#570)

base.link and base.button emitted the author's rel from the htmlAttributes
bag and their own security rel as two separate attributes. Browsers keep the
first, so a nofollow new-tab link published without noopener noreferrer, and
the canvas showed a different rel than the page. The HTML importer also
dropped every source rel on anchors as if the module regenerated it.

The two anchor modules now merge the authored tokens with the security rel
through one shared helper on both render paths, and the importer keeps rel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mcp): deliver editor-bridge tool calls serially and hold the lease while calls are pending (#644)

The editor runs relayed MCP tools strictly one at a time, but the server
pushed a whole batch at once. Every call…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants