# @actuate-media/cms-admin

## 0.52.0

### Minor Changes

- 9f5e5dc: Add the AI crawler activity panel (llms.txt measurement layer):
  - **`cms-core`** — new `AiCrawlerHit` model (`actuate_ai_crawler_hits`) + migration, aggregated per `(bot, surface)` so storage stays bounded. `detectAiBot()` classifies known AI user-agents (training vs retrieval) from the existing taxonomy; `llms.txt`, `llms-full.txt`, and `.md` requests from a known crawler are recorded best-effort. New `GET /ai-crawlers` (admin) returns aggregated activity, and an opt-in honeypot (`seo.llms.honeypot`) appends a trap link to `llms.txt` served by `GET /ai-crawlers/trap`.
  - **`cms-admin`** — new "AI crawler activity" panel under SEO → Technical showing per-bot hits, type, surface, and last-seen, plus totals. Frames the numbers honestly (visibility, not a traffic channel).
  - **`create-actuate-cms`** — scaffolded Prisma assets include the new migration so fresh projects get crawler tracking out of the box.

  Recording is always best-effort and gracefully no-ops on databases that predate the model.

## 0.51.0

### Minor Changes

- 47bed20: Add per-document and site-level curation for the `/llms.txt` AI discovery file:
  - **Per-doc curation** — `llmsInclude` (AUTO/ALWAYS/NEVER), `llmsDescription`, `llmsSection`, `llmsOptional`, and `llmsPriority` are read by the generator and round-tripped through the `/seo/content/:type/:id` API. ALWAYS pins an entry in regardless of the eligibility heuristic; NEVER excludes it.
  - **Spec correctness** — entries flagged optional are emitted under the reserved `## Optional` section; explicit section overrides and per-section priority sorting are honored; an optional reviewed intro block can follow the summary.
  - **Noindex exclusion** — noindexed documents are now excluded from `llms.txt` (the selection filter is the privacy boundary).
  - **Site config** — new `seo.llms.summary` (the reviewed blockquote, falling back to `defaultMetaDescription`) and `seo.llms.intro`.
  - **Admin UX** — the Edit SEO drawer gains an "AI discovery (llms.txt)" section: include mode, curated agent description with a character counter, section, and an optional toggle.

## 0.50.0

### Minor Changes

- bf00622: Add a GEO "key takeaway" field — a concise, citable 1-2 sentence page summary
  for generative answer engines.
  - Editable in the document SEO panel and the SEO Operations Center, each with an
    AI Generate button.
  - New `generateKeyTakeaway()` in plugin-ai and a `keyTakeaway` branch on
    `POST /ai/seo/generate-field`.
  - Rendered automatically as the Schema.org `abstract` on the page/article node,
    as a meta-description fallback, and as the preferred `llms.txt` description.
  - Stored as `data.keyTakeaway` (or `pageSettings.keyTakeaway`).

- 2f1f398: Add authoring guardrails to the page and post editors. The slug field now shows
  inline validation (malformed/reserved) using the same `validateSlug` rules the
  server publish gate enforces, and the client pre-publish check runs that same
  validation so failures appear in the validation summary instead of only as a
  post-publish toast. SEO title/description fields gain accessible character
  counters with quality bands (icon + label, not color alone) and smart
  placeholders that preview the title/excerpt fallback.

  `@actuate-media/cms-core/client` now re-exports the browser-safe slug helpers
  (`validateSlug`, `normalizeSlug`, `isValidSlug`, `isReservedSlug`, etc.).

- ee53cfc: Unify image alt-text analysis behind a single decorative-aware analyzer
  (`analyzeImageAlts` / `countMissingAltImages` / `isDecorativeImgTag`) so the SEO
  score, audit engine, content-health scanner, and cron scan report consistent
  "missing alt" counts. Decorative images (`alt=""`, `role="presentation"`,
  `aria-hidden="true"`) are correctly excluded from the missing-alt count.

  The media picker now seeds an inserted image's alt text from the asset's curated
  library alt text instead of the file name.

### Patch Changes

- 218c5b0: Add canonical slug rules and server-side validation. A new `content/slug`
  module (`normalizeSlug`, `isValidSlug`, `isReservedSlug`, `validateSlug`,
  `SLUG_PATTERN`, `RESERVED_SLUGS`) is the single source of truth for slug format.
  The publish gate now rejects malformed (uppercase, spaces, underscores,
  leading/trailing/double hyphens) and reserved (`admin`, `api`, `sitemap.xml`, …)
  slugs server-side, and the admin `SlugField` normalizes input to the same rules.

  Unify SEO field-name reads so a value set in any editor surface is honored on
  the public page: canonical now accepts `canonicalUrl` (plugin-seo / SEO
  workspace) in addition to `canonical`; robots now accepts `noindex`/`nofollow`
  (SEO workspace) and `robotsNoindex`/`robotsNofollow` (plugin-seo) alongside
  `noIndex`/`noFollow`; the SEO analysis endpoint accepts `focusKeyword` alongside
  `focusKeyphrase`.

## 0.49.4

### Patch Changes

- 63e474e: Normalize admin typography weights to the design system's type scale. Headings,
  labels, and buttons now consistently use `font-medium` and body text
  `font-normal` instead of the off-scale `font-semibold`, `font-bold`, and
  `font-light` weights that had crept into ~33 components. The section preview
  renderers are intentionally left alone since they mirror the consumer site's
  own typography rather than the admin chrome.

## 0.49.3

### Patch Changes

- 4fdf42f: Make the Posts and Pages list tables scroll horizontally on narrow viewports
  instead of crushing their fixed-width columns. The tables are now wrapped in an
  `overflow-x-auto` container with a sensible `min-width`, so on tablet and mobile
  widths the content stays legible and scrolls sideways rather than truncating.

## 0.49.2

### Patch Changes

- 3d997f2: Fix dark-mode rendering in the Media Library, folder tree, Redirects, and
  Script Tags views. These screens hardcoded raw Tailwind palette colors
  (`bg-white`, `text-gray-*`, `bg-blue-600`, status badge colors, etc.) that
  ignored the admin theme, so they rendered as light-on-light (or invisible) in
  dark mode. All raw colors now map to semantic theme tokens
  (`bg-card`, `text-foreground`, `bg-primary`, `bg-destructive`, `bg-warning`,
  `bg-success`, `bg-accent`, …) so both light and dark themes render correctly.

## 0.49.1

### Patch Changes

- 92ce1e5: Make admin dialogs accessible. The shared `Modal` primitive and the previously
  hand-rolled overlays (Schedule publishing, Share preview link, Version history,
  Media picker, AI page generator, Block picker, Keyboard shortcuts) now build on
  Radix `Dialog`, which provides focus trapping, focus restoration on close,
  `role="dialog"` + `aria-modal`, Escape-to-dismiss, and title/description
  labelling. Icon-only close buttons get accessible names and form inputs are
  programmatically associated with their labels. Colors in the migrated dialogs
  were also moved onto theme tokens so they render correctly in dark mode.

## 0.49.0

### Minor Changes

- a6a17e9: Split login: serve the auth screens on their own non-obvious path while the admin app stays at `/admin`.
  - **cms-core**: new `admin.loginPath` config. `createCMSMiddleware` applies the same guards (IP allowlist, CMS_SECRET check, setup bypass) to the login mount and redirects expired sessions there. Emailed password-reset and invite links point at the login mount when configured.
  - **cms-admin**: new `LoginRoot` component mounts login (+ MFA), the first-run setup wizard, and forgot/reset-password on a separate route (e.g. `/securelogin`, with setup at `/securelogin/setup`), then hands off to the admin app after sign-in. `<AdminRoot loginPath>` makes the admin mount render a 404 lookalike for signed-out visitors instead of a login form — no redirect, so the login location is never leaked.
  - **create-actuate-cms**: new `--login-path` flag and prompt (default `/securelogin`). New projects scaffold the split-login layout by default: login/setup at the login path, admin app at the admin path, config + proxy wired accordingly. Pass `--login-path` equal to `--admin-path` for the classic inline login.

## 0.48.0

### Minor Changes

- 3e2daa4: Inline branded preview — see the draft through your real site without leaving the editor.
  - cms-admin: the page and post editors gain a "Site preview" toggle in the top bar that swaps the structure canvas for an iframe of the public site rendering the draft (same signed `?preview=<token>` contract as "View on site"). Unsaved edits are persisted first, the frame reloads after every save, and the existing viewport controls size the frame.
  - cms-core: `createSiteClient().resolveDocument(path, { previewToken })` forwards a preview token to `/resolve` so consumer routes can render drafts at their real URL.
  - create-actuate-cms: the scaffolded catch-all route now forwards `?preview=` to the resolver and shows a draft banner, so new projects support "View on site" and inline preview out of the box.

## 0.47.0

### Minor Changes

- 135d0c7: Taxonomy Phase B — admin facet filters and taxonomy columns on collection lists.
  - cms-core: `GET /collections/:slug` now accepts `filter.<field>=<value>` query params for fields declared in the collection config. Text/select fields filter by exact match; tag-shaped array fields filter by containment, matching both the canonical `[{ tag }]` row shape and legacy plain-string arrays. Undeclared fields are silently ignored. `listDocuments` gains a `dataFilters` option and `DataFieldFilter` is exported.
  - cms-admin: the collection list grows a facet filter bar (select fields use config options; free-text `category` and tag-shaped arrays load the collection's in-use values via the field-values API) plus taxonomy columns showing category/tag values per row, with a one-click "Clear filters" action.

## 0.46.0

### Minor Changes

- e3da3bc: Taxonomy Phase A — structured categories and tag autocomplete (UpChat platform feedback P1 + P2).

  **cms-core**
  - New `GET /collections/:slug/field-values?field=&q=` endpoint returning distinct in-use values for a declared text/select/array field (case-insensitive merge, most-used first, capped at 25). Powers admin tag autocomplete and category suggestions. Auth + collection read scope enforced; undeclared or long-form fields are rejected.
  - New `categoryField({ options })` field preset — renders as a structured `select` when options are provided, free text otherwise.
  - `blogPostCollection({ categoryOptions: [...] })` (and every collection preset with a category field) upgrades the free-text category to a select without hand-rolling field overrides. Plain strings are slugified into `{ label, value }` pairs; explicit `fields.category` overrides still win.

  **cms-admin**
  - New `TagInput` chip editor with autocomplete from the collection's existing vocabulary. Dedupes case-insensitively and resolves to the canonical casing already in use — no more `AI` / `ai` / `Artificial Intelligence` drift.
  - Post details modal is now field-config aware: a `select` category renders a dropdown (legacy free-text values stay selectable), and an `array` tags field renders the chip input. Free-text configs keep their current inputs.
  - `EditorPost` gains `tags`, round-tripped as canonical `[{ tag }]` rows; legacy plain-string arrays are still read.

## 0.45.0

### Minor Changes

- 0f9c510: Editor & scaffold improvements from Upchat feedback.

  **cms-admin** — Repeater rows now derive a scannable header from their values
  ("Pro · $79/mo", "99.99% · Uptime") instead of only the positional label
  ("Plan 2"), so long repeaters read like a list instead of a spreadsheet. Blank
  rows still fall back to "Plan N".

  **create-actuate-cms** — The `marketing-custom` template now ships the full
  custom-section experience out of the box:
  - `src/sections/admin-renderers.tsx` is scaffolded and pre-wired into
    `<AdminRoot sectionRenderers={...}>`, so the editor canvas renders real
    components for custom section types instead of structural placeholders.
  - A pre-filled `actuate.seed.json` ("Marketing homepage" with hero, stats,
    pricing, and CTA sections) loads via `npm run populate`, so the first visit
    to the Page editor shows a fully-authored page instead of an empty canvas.
  - `SECTIONS.md` and the project README document both workflows.

## 0.44.0

### Minor Changes

- d6b21e7: Add Settings > Email: configure the transactional email provider from the admin UI.
  - **cms-admin**: new Email tab in Settings with a provider picker (Resend, SMTP.com, Amazon SES, generic SMTP), per-provider credential fields, masked stored secrets (last-4 only, write-only inputs), a "Send test email" button, and notices for environment/platform-managed configuration.
  - **cms-core**: new `__email_config__` store with API keys and SMTP passwords encrypted at rest (`CMS_ENCRYPTION_KEY`), `GET/PUT /email/settings` + `POST /email/settings/test` admin routes (test sends only to the signed-in admin's own address, rate-limited), and a DB-aware `resolveEmailSenderAsync()` used by invites, password resets, and form notifications. Resolution order: platform adapter → admin-configured settings → environment variables.
  - **plugin-email**: registers a provider factory with cms-core so admin-configured settings build real Resend/SMTP.com/SES/SMTP senders without env vars.

## 0.43.0

### Minor Changes

- 6027f23: Branding uploads now accept WebP, JPEG, GIF, and AVIF in addition to SVG and PNG. Favicons accept SVG, PNG, and WebP (lossy photographic formats stay excluded for crisp small-size rendering). The file-picker accept strings are derived from the validation allow-lists so they can't drift, validation errors show readable format names (e.g. "SVG, PNG, WebP"), and logo shape detection can no longer block an upload if image decoding fails.

## 0.42.4

### Patch Changes

- 0c3a6eb: Posts UX improvements from external-consumer feedback:
  - New `admin.excludeFromPostsAggregate: true` collection flag keeps a post-shaped collection out of the admin's aggregate Posts area (cross-collection list, type tabs, New Post picker) while leaving its own admin list and API routes fully accessible. Enforced server-side in `GET /posts` and mirrored in the admin client.
  - `blogPostCollection()` now satisfies the admin Post Editor's full field contract out of the box: it gains `sections` (json), `category`, and `publishDate` (replacing `publishedDate` — update seeds/queries that referenced the old key), and `body` is no longer hard-required so section-driven posts can publish.
  - `create-actuate-cms` scaffolds now define `posts: blogPostCollection()` instead of an inline collection, so new projects always start with an editor-compatible posts schema.

## 0.42.3

### Patch Changes

- 854152d: Developer experience improvements from external-consumer feedback:
  - Every published package now ships its `CHANGELOG.md` in the npm tarball, and packages that were missing a `README.md` (cli, cms-admin, cms-core, import, platform-vercel, all plugins, sections-react) now have one — so release notes and usage docs are visible on npmjs.com and in `node_modules`.
  - New client-safe form validation entry point: `@actuate-media/cms-core/forms/client` exports `validateFormFieldClient()`, `validateFormValuesClient()`, visibility/conditional helpers, and the exact `FORM_EMAIL_RE` / `FORM_PHONE_RE` regexes the server enforces — zero Node dependencies, so public-site forms can mirror server rules without reimplementing them. The server validator now imports the same primitives, so client and server can never drift.

## 0.42.2

### Patch Changes

- 39a2972: Bring the Post editor to parity with the Page editor's data-safety guarantees. Posts and post-type templates now preserve sections whose type has no registered definition (flagged unmanaged, round-tripped verbatim on save) instead of silently dropping them on open; publish validation skips unmanaged sections instead of hard-failing; an "externally managed sections" banner mirrors the Page editor's. Added a schema guard that refuses to open a post whose collection is missing the fields the editor writes (preventing silent content loss on save), and the Page editor's role check now fails closed (read-only) when no role is identifiable, matching the Post editor.

## 0.42.1

### Patch Changes

- 211a9ee: Fix unstyled Radix overlays: all admin theme tokens are scoped under `.actuate-admin`, but Radix portals (dialogs, dropdown menus, drawers, selects) defaulted to `document.body` — outside the scope — so portaled surfaces rendered with no tokens (transparent card backgrounds, colorless destructive buttons, broken dark mode). Most visible on the "Block this production site from search engines?" confirmation in Settings. Every `*.Portal` now mounts into the admin root via a shared `adminPortalContainer()` helper, restoring theming, dark mode, and density for all overlays.

## 0.42.0

### Minor Changes

- 915f97c: Pluggable admin canvas renderers for custom section types. Pass `sectionRenderers` to `<AdminRoot>` to render custom (config-declared) section types with real React components in the page/post editor canvas and the eye preview, instead of structural placeholders. The renderer signature structurally mirrors `SectionRenderer` from `@actuate-media/sections-react`, so client-safe public-site renderers can be reused as-is. Throwing renderers fall back to the placeholder per section; types with a registered renderer no longer trigger the "Structure view" notice.

## 0.41.0

### Minor Changes

- f192376: Page/post editor UX for custom-section installs and revision history:
  - The canvas now shows a "Structure view" notice when a document contains section types rendered as placeholders (custom/unmanaged types), making clear the canvas is structural — not WYSIWYG.
  - "View on site" is promoted to a labeled button (the primary design preview) when custom section types are present, and the eye preview is relabeled "Preview structure".
  - Version history (with restore) is now accessible directly from the page and post editor top bars via a History button. Restoring reloads the editor with the restored content.

## 0.40.1

### Patch Changes

- db4c47e: Settings → General clarity fixes for the robots toggles: the "Noindex Non-Production Environments" row now shows an inline note when the current deployment is the production environment (where the toggle cannot change served pages, pointing to Default No Index instead), and the settings save bar shows an "Unsaved changes" hint so a flipped toggle is never mistaken for an applied change.

## 0.40.0

### Minor Changes

- 8e73cf5: The admin now loads section types from `GET /page-sections/types` on boot (with the config as a synchronous fallback), so custom types registered server-side are always editable without a separate bundled registry.

  The section inspector renders a unified `RepeaterField` for `repeater`, `stats`, and `gallery` fields, with drag-and-drop row reordering via `@dnd-kit`, replacing the bespoke stats/gallery editors.

## 0.39.0

### Minor Changes

- 99565a1: Page Editor: stop dropping externally-managed sections, support custom types, fix breadcrumb ids (Opal feedback).
  - The flat Page Editor now reads with `preserveUnknown` and round-trips sections whose type isn't registered (e.g. seed/code-owned `opal-*`) on every save, so editing a page never silently overwrites them with `[]`. A banner lists the externally-managed types, the Sections panel shows them locked (no drag/duplicate/delete/visibility), the inspector shows a read-only notice, and the canvas renders a neutral placeholder instead of a blank gap.
  - `AdminRoot` registers `config.sections.customTypes` into the client section registry, so config-declared custom types appear in the Add Section gallery and get a field-driven inspector (editable like built-ins). Publish validation skips unmanaged sections.
  - Breadcrumbs `isId()` now recognizes CUID/CUID2 ids (Prisma `@default(cuid())`), so document routes render as "Edit ..." instead of showing the raw id.

- 0158abe: Settings → Appearance branding: add a "Media library" picker to every logo/favicon field so you can reference an existing media asset instead of only uploading. Selecting from the library points the brand field at the existing asset id (no re-upload, no duplicate media). The shared `MediaPickerModal` gained a `libraryOnly` mode, real image thumbnails, and a normalized `url` on the selected item.

## 0.38.1

### Patch Changes

- 74662c0: Fix Next.js 16 admin routing and the client/server bundle boundary.
  - The admin router now marks `history.pushState` state with `__NA: true` so Next.js 16's patched `pushState` passes it through natively instead of throwing "Invalid URL" on in-admin navigation.
  - Page-builder runtime utilities are now imported from the client-safe `@actuate-media/cms-core/page-builder` subpath instead of the main barrel, so bundlers (Turbopack) no longer pull server-only dependencies (`pg`, `sharp`, codegen) into the client admin bundle. A regression test guards against reintroducing bare-barrel value imports.

## 0.38.0

### Minor Changes

- aa4bd1a: Unify the page content model at write time (ADR 0002). Visual Page-Builder pages now compile their `_layout` tree to the canonical `data.sections[]` on every save, so every reader (SDK, MCP, search, content graph, `/resolve`) sees one contract instead of the lazy read-time bridge.
  - **cms-core**: `createDocument`/`updateDocument` derive `sections` from a `layout` envelope; the `/page-sections/*` write routes refuse a builder-owned page (one with `_layout`) with HTTP 409 to prevent silently-overwritten edits; the `/resolve` bridge is now a legacy fallback for un-migrated pages. New `migratePageBuilderSections()` export backfills legacy pages (idempotent).
  - **cms-admin**: the flat Page Editor opens a builder-owned page read-only with an "Open in Page Builder" action; `EditorPage` gains a `builderOwned` flag.
  - **cli**: new `actuate migrate:sections` command (with `--dry-run` / `--batch-size`) to backfill canonical sections for legacy page-builder documents.

## 0.37.2

### Patch Changes

- 2f18c56: WS-E (E2): public form rendering — a built site can now render and submit a CMS form end to end.
  - **sections-react:** new `<ActuateForm>` Client Component fetches the public form
    schema (`GET /api/cms/public/forms/:slug`), renders the fields (text/email/
    url/tel/number/date, textarea, select, checkbox, radio), and submits to the
    schema's `submitPath`. Spam protection is wired automatically: a hidden
    honeypot (keyed from `spam.honeypotFieldKey`), an `elapsedMs` "time to submit"
    timer, and an optional consumer-supplied `captchaToken`. Pre-fetch a schema via
    the `form` prop to skip the round-trip (SSR/streaming). Adds a `baseUrl`
    `RenderOptions` field (defaults to same-origin). Exposes pure, tested helpers
    (`coercePublicForm`, `buildSubmitBody`, `fieldControl`, `firstMissingRequired`).
  - **cms-core:** new canonical `form` section type. Set `content.formSlug` (plus
    optional heading/intro) on any page or post and the matching renderer embeds
    the form. The registry-drift guard now covers it (renderer ↔ type).
  - **cms-admin:** the form Embed panel now shows the real public endpoints
    (`/api/cms/public/forms/:slug` + `/submit`) and a copy-paste `<ActuateForm>`
    snippet, replacing the previously-advertised `/api/forms/*`, iframe, and
    `embed.js` routes that were never implemented. Adds a `mail` section icon.

## 0.37.1

### Patch Changes

- a4f7b5d: The login screen now shows the same brand logo as the admin sidebar. Previously
  the login page only read the static `config.admin.branding.logo`, so a logo
  uploaded in Settings → Appearance appeared in the sidebar but not on sign-in.
  `Login` now resolves its logo with the same precedence as the sidebar: the
  uploaded appearance brand (from the cached admin brand the sidebar paints from,
  then the unauthenticated `GET /public/brand` endpoint for fresh browsers), then
  `config.admin.branding.logo`, then the bundled Actuate wordmark. Passing
  `branding.logo === null` still hides the logo entirely for whitelabel installs.

  The brand lockup's logo-less fallback also changed. The sidebar previously
  rendered a filled site-initials badge when no logo was set, which read as a user
  avatar (the real account avatar already sits top-right). That badge is replaced
  with a neutral "logo slot" placeholder — a dashed outline with a muted image
  icon — shared by the sidebar and login via a new `LogoPlaceholder` component. It
  is automatically superseded the moment a logo is uploaded. The bundled Actuate
  wordmark/mark remains the default for a fully un-branded install; the
  placeholder only appears once the install is branded (a site/brand name is set)
  but has no logo yet. On a fresh browser with no cached brand, the login also
  reads the site initials from `GET /public/brand` so a site-title-only install
  (no static `branding.name`) is still detected as branded pre-auth.

## 0.37.0

### Minor Changes

- 7d11b8d: Production-ready Settings → Updates tab with real release intelligence.
  - **cms-core**: Fix a critical permission bug where `GET/PUT /updates/config` and
    `POST /updates/apply` rejected real admins (they compared `session.role` to the
    lowercase `'admin'` while roles are stored uppercase). They now use
    `requireAdminScope`, so OWNER/ADMIN sessions (and admin-scoped API keys) can
    manage updates. `GET /updates/check` no longer leaks the GitHub repo to
    non-admins and reports a `canManage` flag. Update checks now run through a new
    hybrid resolver `getUpdateStatus()` that queries the Actuate update server
    first (rich, real changelog/severity/migration/breaking-change data derived
    from the published CHANGELOG) and transparently falls back to a direct npm
    registry check. New exports: `getUpdateStatus`, `checkForUpdatesViaServer`, and
    the `ChangeType` / `ChangelogItem` / `PluginCompatibilityInfo` types; richer
    `VersionInfo`. `fetchChangelog()` now targets the real `/api/changelog`
    endpoint (overridable via `ACTUATE_UPDATE_SERVER_URL`).
  - **cms-admin**: Rebuild the Updates tab as a dedicated `UpdatesTab` + `useUpdates`
    hook (extracted from `Settings.tsx`). The GitHub integration now uses the shared
    `SettingsControlRow` + sticky `SettingsSaveBar` dirty-state pattern, and the tab
    surfaces real release notes (categorized changelog), a security-update badge,
    "migrations required" and breaking-change callouts, and plugin compatibility.
    Write actions are permission-aware (hidden for non-admins).
  - **cli**: `update-check` / `upgrade` now resolve the update-server base URL from
    `ACTUATE_UPDATE_SERVER_URL` (defaulting to the production host), so the
    `/api/versions` endpoint is configurable for self-hosted update servers.

### Patch Changes

- 5ac1d16: Make the Appearance → Branding section visually uniform with the rest of
  Settings. Logo fields (Primary logo, Favicon, and the advanced/admin variants)
  now render as consistent rows with the Upload/Replace control aligned to the
  right edge instead of stacked beneath the description, and the admin override is
  presented as a labelled "Brand source" row with the toggle on the right. Add a
  shared `SettingsControlRow` primitive (leading visual + label/description on the
  left, control on the right) so this rhythm is reusable, route the Branding card
  through `SettingsCard`/`SettingsSubsection`, and sentence-case the Appearance
  section titles ("Admin theme", "Typography & motion", "Brand preview") so the
  whole tab is consistent.

## 0.36.1

### Patch Changes

- e92dd1e: Unify the Settings design language across every tab. Introduce a shared
  `SettingsCard` section primitive (title + description + optional header actions)
  plus a `SettingsSubsection` helper, and route the General, Security, SEO, AI,
  Layout, and Updates tabs through it so each section matches the Appearance tab:
  `p-6` cards, a `text-lg` medium title, and a muted `text-sm` description. Tokenize
  the Settings shell header and the Updates panel so they use semantic theme tokens
  (no raw gray/blue/green/red palette classes) and render correctly in dark mode.
  The persistent save bar behavior is unchanged.
- e92dd1e: Bring the Users, API Keys, and Profile screens onto the shared Settings design
  language. The API Keys view is now wrapped in the `SettingsCard` primitive
  (title + description + header action) and fully tokenized — every raw
  gray/blue/red/green/amber palette class in the table, status pills, empty state,
  and both dialogs was replaced with semantic theme tokens so it renders correctly
  in dark mode. The Users "Team members" section title is bumped to the shared
  `text-lg` section scale, and the Profile page header now uses `text-foreground` /
  `text-muted-foreground` tokens with the standard medium weight.

## 0.36.0

### Minor Changes

- 4e6ead2: Add Brand Voice. Admins can define a reusable brand voice (tone, audience, guidelines, and
  sample copy) in Settings > AI. When enabled, the governed AI runtime injects it into the system
  prompt of content-generation features — co-author and SEO titles/descriptions — so every AI
  surface writes in one consistent, on-brand voice.
  - `cms-core`: new `BrandVoiceProfile` type on `AISettings`, a `brand-voice` module
    (`buildBrandVoiceSystemPrompt`, `applyBrandVoice`, `getActiveBrandVoice`, `normalizeBrandVoice`),
    a `useBrandVoice` option on `generateTextForFeature`, and `PUT /ai/settings` persistence
    (length-clamped, no secrets).
  - `cms-admin`: a Brand Voice card in the AI settings tab wired through `useAiSettings`.

## 0.35.0

### Minor Changes

- fcede90: Settings > AI: per-feature model routing. Each AI feature can now run on a
  specific model (e.g. a cheap model for alt-text, a frontier model for content
  writing) or inherit the default. Adds `resolveFeatureModel` /
  `resolveAllFeatureModels` to cms-core as the authoritative feature→model
  resolver (capability-aware, with capability-typed and fallback defaults), and
  extends `PUT /ai/settings` to persist + validate per-feature `modelId`/
  `providerId` overrides. The admin AI Features card gains a per-feature model
  picker that filters to capability-compatible models and warns when a routed
  model is missing a required capability (e.g. a text-only model on a vision
  feature).

## 0.34.0

### Minor Changes

- 89eebe0: Add Settings > AI (Phases 1-2): DB-backed provider connections + dynamic model catalog.

  **cms-core**
  - New `src/ai` module: provider connections (OpenAI, Anthropic, xAI, custom OpenAI-compatible), a normalized model catalog, feature flags, and budget settings, all stored in an `__ai_config__` config document (no Prisma migration, mirroring the SEO config-store).
  - Provider API keys are encrypted at rest with `encryptSecret` and never returned to the client — responses expose only a masked `apiKeyLast4` and a source flag. When a provider's env var (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `XAI_API_KEY`) is set, that key wins and is surfaced as env-managed/read-only.
  - SSRF-safe adapters: built-in providers use fixed hostnames; custom base URLs are validated and DNS-resolved via the existing webhook guards.
  - Dynamic catalog merges live `/v1/models` listings with a curated capability/pricing map, with a curated fallback when live listing is unavailable and `unavailable` marking for configured-but-missing models.
  - Monthly usage summary aggregated from existing audit-log token totals (real counts; 0 until per-run metering lands).
  - New ADMIN-gated, audited routes: `GET/PUT /ai/settings`, `POST/PUT/DELETE /ai/providers(/:id)`, `POST /ai/providers/:id/test`, `POST /ai/providers/:id/sync-models`, `GET /ai/models`, `GET /ai/usage/summary`.

  **cms-admin**
  - Replaced the read-only AI status panel with a full `AISettingsTab`: AI Assistant card (default provider + model selector with capability badges and Refresh), provider connection management (add/test/disconnect, masked or env-managed key handling with confirm dialogs), global AI feature toggles, and a real monthly usage meter with a budget limit.
  - Feature enforcement across modules and per-run usage metering are deferred to later phases; toggles and budget persist now.

## 0.33.0

### Minor Changes

- 8dfeb94: Add a **Settings > SEO** tab for site-wide SEO defaults, backed by the shared SEO configuration service (no duplicate state with `/seo` or Settings > General).
  - **cms-core**: extend `SiteSEOConfig` with `titleTemplate`, `defaultMetaDescription`, `defaultSocialImageAssetId`, and a `verification` block; add title-template token validation/application, search-engine verification meta-tag rendering, and wire all three into the public page-meta renderer. New `notifySitemapOnPublish` service performs production-gated, SSRF-safe sitemap regeneration + Google/Bing pings, fired as a best-effort effect when public content is published, updated, unpublished, or deleted. Cache invalidation runs on every change while the timestamp write + outbound ping are debounced (default 60s) so bursts of publishes don't hammer the shared `__seo_config__` row or spam search engines. `PUT /seo/config` and `PUT /seo/crawl-settings` now validate new fields and write field-level audit logs (verification token values redacted).
  - **cms-admin**: new `SeoSettingsTab` (Meta Defaults with SERP/social preview, Search Engine Verification, Sitemap Defaults, full-workspace callout, Advanced disclosure) driven by a `useSeoSettings` hook that reads/writes the shared `/seo/config` and `/seo/crawl-settings` endpoints with dirty tracking, validation, permission-aware read-only states, and dangerous-change confirmation. `MediaPickerModal` gains an `onSelectItem` callback so the default social image stores its asset id.

## 0.32.0

### Minor Changes

- 7336b5f: Add content-ownership transfer to Settings > Users.

  cms-core: new `transferContentOwnership` / `countOwnedContent` helpers reassign
  a member's authored documents, uploaded media, and created templates to another
  active user (immutable version/audit history is never touched). New admin-only,
  audited `POST /users/:id/transfer-content` endpoint validates the recipient
  (must exist and be active) and applies the same Owner/last-Owner guards as the
  other per-user actions. `contentOwnedCount` in the user detail now reflects all
  three content types, not just documents.

  cms-admin: the user detail drawer Overview now offers "Transfer content" with an
  active-member picker before revoking access. Auxiliary dialogs (transfer,
  confirm, reauth) now render inside the Drawer's Radix focus scope so they are
  keyboard-reachable and no longer marked `aria-hidden` behind the drawer.

## 0.31.0

### Minor Changes

- 951963a: Build the Settings > Users access-control system: real invites, roles, seats, and access review.

  **@actuate-media/cms-core**
  - Schema: add `OWNER` and `VIEWER` to the `UserRole` enum, a new `InviteStatus` enum, a new `Invite` model (`actuate_invites`), and additive user-lifecycle columns (`suspendedAt`, `lockedAt`, `forcePasswordReset`, `mfaRequired`, `failedLoginCount`, `lastActiveAt`, `invitedById`, `acceptedInviteAt`). Two hand-authored migrations (`0011_user_roles_owner_viewer`, `0012_invites_and_user_lifecycle`) — enum values are added in their own migration so Postgres can use them as defaults afterward. All user columns are nullable/defaulted so existing rows migrate cleanly.
  - Add a `roles` module (also exported as the `@actuate-media/cms-core/roles` subpath): display mapping (`OWNER/ADMIN/EDITOR/VIEWER` plus legacy `AUTHOR→Editor`, `CLIENT→Viewer`), assignable roles, privilege ranking, and per-role permission derivation.
  - Add an `Invite` lifecycle service (`createInvite`, `listInvites`, `resendInvite`, `cancelInvite`, `updateInviteRole`, `extendInvite`, `expireStaleInvites`, `markInviteAcceptedForEmail`). Invite tokens are hashed at rest, single-use, and expire (7/14/30 days). Completing the password-reset flow for an invited email now marks the invite `ACCEPTED` atomically.
  - Add a `team` module (`listTeamMembers`, `getUserStats`, `computeAccessReview`) that derives seat usage and access-review recommendations (inactive 90d+, external domain, admin/owner without MFA, stale invite) from real user/security signals.
  - New admin endpoints: enriched `GET /users` (search/role/status filters), `GET /users/stats`, `PATCH /users/:id/role`, hardened `DELETE /users/:id` (revoke = suspend, last-Owner protection), `GET /invites` + `POST /invites/:id/resend|cancel` + `PATCH /invites/:id`, and `GET /access-review` + `POST /access-review/:id/dismiss`. Role assignment/removal of Owner is Owner-only, self-escalation is blocked, role downgrades revoke sessions, and seat limits are enforced. All sensitive actions are audit-logged.

  **@actuate-media/cms-admin**
  - Rebuild the Settings > Users tab to match the design: dynamic summary cards (members, pending invites, admins, seats used), an actionable access-review banner, a combined team table (active members + pending invites) with role-filter pills and search, per-row role dropdowns and revoke/resend/cancel actions, and an invite modal with role radio cards and advanced options (custom message, expiry). Includes loading, empty, filtered-empty, and error states; destructive and Owner-assignment actions require confirmation.

  **create-actuate-cms**
  - Sync the scaffold's Prisma schema and migrations with the new Users-management schema so newly created projects start with the `Invite` model, new roles, and user-lifecycle columns.

- 951963a: Settings > Users phase 2: per-user detail, sessions, activity, and security actions.

  **@actuate-media/cms-core**
  - Add a `user-admin` read-model module (`getUserDetail`, `listUserSessions`, `listUserActivity`, `getUserPermissionView`, `parseUserAgent`) that shapes a single user's overview, security posture (MFA / password / lock / SSO-only), sessions (with device parsing + "this device" marker), audit activity (events where the user is actor or target), and derived permissions.
  - New admin-only, audited per-user endpoints: `GET /users/:id`, `GET /users/:id/sessions`, `DELETE /users/:id/sessions/:sessionId`, `POST /users/:id/sessions/revoke-all`, `GET /users/:id/activity`, `GET /users/:id/permissions`, `POST /users/:id/password-reset`, `POST /users/:id/force-password-reset`, `POST /users/:id/mfa-reset`, `POST /users/:id/require-mfa`, `POST /users/:id/suspend`, `POST /users/:id/reactivate`, `POST /users/:id/lock`, `POST /users/:id/unlock`. Owner actions are Owner-only, self-actions and last-Owner removal are blocked, MFA reset and Admin/Owner password resets require password reauthentication, and session-affecting actions revoke sessions.
  - Login now rejects suspended (`suspendedAt`) and locked (`lockedAt`) accounts so an admin Suspend/Lock takes effect on the next sign-in attempt — distinct from full deactivation (`isActive=false`), which preserves the account's content and history.

  **@actuate-media/cms-admin**
  - Add the User detail drawer opened from a Team members row (and the row "View details" action): Overview, Security, Sessions, Activity, and Permissions tabs. Security actions (send/force password reset, reset/require MFA, lock/unlock, suspend/reactivate, revoke one/all sessions) are wired to the new endpoints with confirmation dialogs and an inline re-authentication prompt that retries with the `x-reauth-password` header when the server requires it. Includes loading, empty, and error states across all tabs.

## 0.30.0

### Minor Changes

- f043e18: Fix day-one auth dead-ends and wire up transactional email + user invites.

  **@actuate-media/cms-core**
  - Add a transactional email sender registry (`setEmailSender`, `getRegisteredEmailSender`, `resolveEmailSender`). Password-reset and form-notification email now resolve a sender via this registry, so apps that configure email purely through `emailPlugin()` actually send (previously only `config.platform.email` was consulted, so reset/notification mail silently never sent).
  - Password reset links are now built from the configured admin path instead of a hardcoded `/admin`, so resets work on apps mounted elsewhere.
  - Add an admin-only `POST /users/invite` endpoint plus `createUserInvite` / `normalizeInviteRole` / `VALID_INVITE_ROLES`. Invites provision the user with an unusable password and a single-use, 7-day token, then email a "set your password" link (reusing the existing `/reset-password` page). When no email sender is configured the response returns the link so an admin can share it manually. Rate-limited per admin.

  **@actuate-media/cms-admin**
  - `AdminRoot` gains an `onLogout` prop. The account-menu "Logout" item now revokes the server session (`POST /auth/logout`), clears local session state, and returns to the login screen — previously it navigated to a non-existent `/logout` route (404) and left the session valid.
  - The "Invite User" dialog is now a real flow backed by `POST /users/invite` (loading state, error handling, valid roles, optional name) instead of a fake success toast.

  **create-actuate-cms**
  - The scaffolded admin page now wires `onVerifyTotp` (so MFA users can complete the second-factor step) and `onLogout`, matching the dev app.

  **@actuate-media/plugin-email**
  - `emailPlugin()` now registers its resolved provider with cms-core's email sender registry, so configuring email via the plugin is sufficient for password-reset, invite, and form-notification email.

- 05537a8: Replace the decorative AI settings tab with an honest, read-only status panel.

  The previous AI tab shipped a fake API-key field (the key was never saved), a no-op "Verify" button, a misleading "encrypted at rest" claim, hardcoded zero usage metrics, and nine feature toggles that were read by nothing. None of it configured AI — that happens server-side via `@actuate-media/plugin-ai` and environment variables.
  - `cms-core`: new admin-only `GET /ai/status` endpoint reporting `{ installed, configured, provider }`. It detects whether the AI plugin is installed and a provider is configured, and hints at which provider env var is present — never exposing key values.
  - `cms-admin`: the AI tab now renders a truthful status panel (active / installed-but-unconfigured / not-installed) plus setup guidance, and no longer pretends to store credentials.

### Patch Changes

- e6f0257: Fix the misleading global "Save Changes" footer in Settings. It previously rendered on every tab not in a denylist — including the SEO and Updates tabs, which manage their own persistence. Clicking it there toasted "Settings saved successfully!" while saving none of the tab's edits.

  The footer is now gated by an explicit allowlist (`GLOBAL_SAVE_TABS`) so it only appears on the Layout tab, which is the only tab that actually persists through the shared `handleSave`. New tabs default to self-managed.

## 0.29.0

### Minor Changes

- 008544f: Complete the MFA loop: account enrollment, login challenge, and backup-code recovery.

  Previously the TOTP backend existed but had no user-facing plumbing — there was no
  way to enroll from the admin, the login screen couldn't complete a second-factor
  challenge, and backup codes were generated but never consumed anywhere (a lost
  authenticator meant permanent lockout). The "Require MFA" policy was therefore
  impossible to satisfy.

  **cms-core**
  - `POST /auth/totp/login` now accepts a one-time backup code as well as a TOTP code.
    Matches are constant-time and single-use (the code is removed and remaining codes
    re-encrypted); `login_success` audit records whether a backup code was used.
  - New pure helpers `normalizeBackupCode` / `findBackupCodeMatch` in `auth/totp`.
  - `GET /auth/me` now returns `totpEnabled` so the admin can show enrollment state.

  **cms-admin**
  - New `/profile` view (fixes the previously dead account-menu link) with an
    **Account security** card: password reauth → QR code + manual key + downloadable
    one-time backup codes → verify, plus a reauth-gated disable flow.
  - The login screen now renders a **two-factor challenge** step (authenticator code
    with a backup-code fallback). `AdminRoot`/`Login` gain an `onVerifyTotp` prop and
    `onLogin` may now return `{ requiresTOTP, mfaPendingToken }`.
  - The Security tab's "MFA not set up" warning now links to the enrollment flow.
  - Adds `qrcode.react` for QR rendering.

## 0.28.0

### Minor Changes

- 06940a1: Add the Settings → Security tab as a real Security Center.

  **cms-core**
  - New `@actuate-media/cms-core/security-center` subpath export with pure, client-safe
    helpers: `computeSecurityStatus`, `parseSecuritySettings`, `validateSecuritySettings`,
    `validateIpRange`, `mfaEnableLockoutError`, `summarizeUsers`, `summarizeApiKeys`, and types.
  - New API routes: `GET /security` (computed posture + cross-module summaries +
    secret/encryption verdicts, never secret values), `PUT /security` (persists enforced
    MFA + session policy with server-side lockout-safe MFA enabling, audited as
    `security_settings_changed`), and `POST /security/sessions/revoke-all` (revokes the
    caller's other sessions, audited).
  - Concurrent-session enforcement now honors the stored `security.session.maxConcurrentSessions`
    policy (config/default precedence preserved when unset; `0` = unlimited).
  - Login responses include `mfaSetupRequired` when org-wide MFA is required but the account
    has not enrolled past its grace window.
  - `settings` global writes now audit nested `security` changes.

  **cms-admin**
  - New Security tab with a computed Security Status card, Authentication & MFA, Session
    Policy (with revoke-other-sessions), Access & Request Protection, and Secrets/Encryption/Audit
    cards. Wired to the real `/security` endpoints with source badges, dangerous-change
    confirmations, lockout-safe MFA toggle, validation, and read-only states for non-admins.
    Replaces the previous decorative security toggles.

## 0.27.0

### Minor Changes

- 3667ac2: Add the Settings → Appearance tab: a production-ready brand + admin-theme system.

  **cms-core**
  - New `@actuate-media/cms-core/appearance` subpath module with pure utilities:
    logo-shape classification, WCAG contrast checks, admin accent-token
    generation, and brand/favicon resolution (admin + public fallback chains).
  - `GET /public/brand` (unauthenticated) resolves public logos + favicon
    metadata for the public frontend; `GET /appearance` (authenticated) returns
    stored appearance/brand settings plus resolved admin/public/favicon views and
    per-asset URLs for editor previews.
  - `PUT /globals/:slug` audit logging now records nested `appearance`/`brand`
    changes.

  **cms-admin**
  - New Appearance tab (Branding, Admin Theme, Typography & Motion, live Brand
    Preview) wired to the Media Center for logo/favicon uploads (assets referenced
    by ID, never raw paths), with shape warnings, dark-logo warnings, accent
    contrast validation, source badges, and read-only/permission states.
  - `ThemeProvider` now applies color mode, accent color, sidebar density, content
    font, and reduce-motion live, and feeds the resolved admin brand to the
    sidebar logo (with site-initials fallback).

## 0.26.0

### Minor Changes

- 58eff9b: Build a production-ready General Settings tab with a computed Site Index Status card.

  **cms-core**
  - Add `getSiteIndexStatus()` / `computeSiteIndexStatus()` (exported from the SEO module) that derives the effective crawl/index state (indexable / protected / blocked / partially_blocked / unknown) from the SAME effective SEO config the public renderer uses — environment, site URL, robots, sitemap, and last audit — plus a `robotsTxtBlocksEntireSite()` helper.
  - Add `GET /seo/index-status` (ADMIN) returning the computed status, per-check rows, and actions.
  - Audit `PUT /globals/settings` with a per-key before/after diff (`settings_changed`, including source + environment).
  - Migrate the `GET /seo/meta/:documentId` preview to read robots defaults from the effective SEO config instead of the legacy `settings` global, so preview, public render, and General Settings share one source of truth.

  **cms-admin**
  - Replace the General tab with a fully wired implementation: Site Index Status card, Site Information (with config-managed source badges + live Site URL validation), Language & Region, and SEO & Robots Defaults — the SEO toggles now read/write the shared `/seo/config` (no duplicate store).
  - Add dirty-state Save/Reset bar, validation summary, confirmation dialogs for dangerous indexing changes (production noindex on; non-production noindex off), loading skeletons, error/retry, and read-only gating for non-admins.
  - Use semantic theme tokens (purple `brand` accent, status colors) so the tab matches the design system in light and dark modes.

## 0.25.0

### Minor Changes

- 8e38837: Integrate the form block into the page builder (PR7).
  - **Form picker**: block `relationship` fields (the form block's `formId`) now render a real dropdown populated from the linked collection via the admin API, with loading/empty states and a graceful fallback to an id input when the collection can't be loaded. Extracted as a reusable `RelationshipField` component.
  - **Live canvas previews**: the page-builder canvas now renders the rich block-preview registry (with a labelled fallback for unknown block types) instead of a generic dashed placeholder, so editors see what each block will look like.
  - **Form preview**: the form block preview fetches the linked form's name and fields and renders a realistic preview, with a clear "No form selected" empty state prompting the editor to pick a form in block settings.

## 0.24.0

### Minor Changes

- b328ec8: Add the global **All Entries** inbox (`/forms/entries`) — a cross-form
  submissions view with a Form column, per-form filter, the same search / status
  pills (All / Unread / Starred / Archived), bulk actions, CSV export, pagination,
  and detail drawer as the per-form Submissions view. The shared submissions
  table is now extracted into `components/forms/EntriesTable.tsx` and reused by
  both views.

  Expand the **Forms sidebar item into a submenu**: an "All Entries" child plus
  one child per active form, each carrying its own unread badge, with the total
  unread surfaced on the parent (and as a dot on the collapsed rail / in the
  hover flyout).

## 0.23.0

### Minor Changes

- 3a641fd: Rebuild the per-form Submissions view as a table (was a card list). Entries
  now load from the real entries API via `forms-service.ts` instead of mock
  attribution data, with summary chips (total / unread / starred / this week),
  search, status pills (All / Unread / Starred / Archived), row selection with
  bulk actions (mark read, star, archive, delete), per-row star toggle, and CSV
  export. Clicking a row opens a token-only detail drawer that renders the
  submission against the schema version it was captured with, surfaces sender
  and attribution, marks the entry read on open, and supports star / archive /
  mark-unread / delete. Adds entries client helpers (`fetchEntries`,
  `fetchEntryCounts`, `fetchEntry`, `updateEntry`, `deleteEntry`,
  `bulkUpdateEntries`) and replaces the previous raw-palette styling with theme
  tokens throughout.

  Add a reusable token-only `FormsPagination` footer (matching the Posts/Pages
  list controls) and wire it into both Forms tables: the Submissions view now
  paginates server-side (25 per page), and the Forms list paginates the filtered
  results (10 per page). Both reset to page 1 when search or status filters
  change.

  The per-form notification toggles (Forms list "Notify" column and the schema
  drawer Notifications tab) now read green when enabled and red when disabled
  using the semantic success / destructive tokens.

## 0.22.0

### Minor Changes

- 9ed05b2: Forms admin — list view, schema drawer, and live sidebar badge (PR 3/9).
  - Rebuilt the Forms list view to match the design: dynamic summary chips
    (forms / active / total entries / unread) sourced from `/forms/stats`,
    search across name/slug/description/field labels, Active/Draft/Archived
    filter pills, and a compact table (Form / Fields / Status / Entries / Last
    Entry / Notify / Schema) with field-type and status badges. The notify
    toggle persists optimistically and rolls back on error.
  - Added the right-side Form Schema drawer with Fields, Embed, and
    Notifications tabs. The Fields tab is a keyboard-accessible drag-to-reorder
    (dnd-kit) editor; saving creates a new schema version server-side so
    historical submissions are never rewritten. Embed exposes copyable
    public/schema endpoints, iframe/script snippets, and domain restrictions.
    Notifications persists per-form recipients, templates, field inclusion, and
    autoresponder settings (delivery wiring lands in a later phase).
  - Added a typed `forms-service.ts` client (re-using `@actuate-media/cms-core`
    domain types) and a tiny `forms-events` bus so the sidebar refreshes counts
    after a mutation.
  - The Forms sidebar item now shows a live unread badge (total unread across
    active forms), rendered as a pill when expanded and a dot in the collapsed
    rail. `NavItem` gained an optional `badge` field.

## 0.21.1

### Patch Changes

- 8891547: Scope the SEO surfaces to real front-end content and keep the admin out of search indexes.
  - **cms-core**: The Content SEO list, audit, scan, internal-link candidates, **and the XML sitemap** now only include documents from collections explicitly typed `page` or `post` (visible and sitemap-eligible). Structural/utility collections (navigation menus, footers, tags, templates) — which have no `type` — and ad-hoc rows whose collection isn't declared in the config (e.g. benchmark/test data) are excluded, so a menu can no longer leak into the sitemap as an indexable URL. Adds the `frontEndContentCollectionTypes(config)` helper and threads a `collectionTypes` option through `gatherAuditEntities` / `runAndPersistAudit`; `processSeoScan` gains an `allowedCollections` option. The `POST /seo/scan` route now reuses `processSeoScan` (also fixing its case-sensitive `alt=`/`<h1` checks).
  - **cms-admin**: `AdminRoot` now ensures a `noindex, nofollow` robots meta tag while the admin is mounted, so private admin UI is never indexed regardless of how the consumer renders the admin page.

- 97df7f4: Improve sidebar submenu UX. When the sidebar is collapsed, parent items with children (Posts, Templates) now reveal their sub-pages in a hover/focus flyout (portaled via Radix so it escapes the rail's overflow), keeping the 80px rail clean while staying fully reachable by mouse and keyboard. When expanded, child rows now connect to their parent with curved-elbow tree connectors for clearer hierarchy. Expanded parent sections are now collapsible — a chevron toggle contracts/expands the submenu (with `aria-expanded`/`aria-controls`), while the active route's section stays open so the current page is never hidden.

## 0.21.0

### Minor Changes

- aa9797d: Consolidate Users and API Keys into the Settings view as dedicated tabs. The
  sidebar Settings group now shows only "Settings"; Users and API Keys are
  reachable as tabs (`/settings?tab=users`, `/settings?tab=api-keys`). The legacy
  `/users` and `/api-keys` routes still resolve and land on the matching tab, and
  Settings tabs are now deep-linkable via the `?tab=` query param (with
  browser back/forward support). The `Users` and `ApiKeys` views gain an optional
  `embedded` prop that drops page padding and demotes the heading when rendered
  inside the Settings shell.

### Patch Changes

- a8976e2: SEO admin UI reliability and accessibility fixes:
  - `useSeoResource` now ignores stale, out-of-order fetch responses (request-id
    guard + unmount guard), so rapidly switching SEO views no longer flashes data
    from a superseded request.
  - `SeoEditorDrawer` Field wrapper auto-associates each `<label>` with its child
    control via `useId`, fixing WCAG label/`for` associations for the SEO meta
    inputs.

- 1bd4a49: SEO production-readiness Polish tier — post-launch nice-to-haves from the SEO review.

  cms-core:
  - **Markdown-aware heading analysis** — `analyzeContent` and `countH1` now detect
    Markdown ATX (`#`/`##`) and Setext (`===`/`---`) headings in addition to HTML
    tags, so markdown-bodied posts are no longer wrongly flagged "No H1 found" /
    "no subheadings".
  - **Correct default OG image URL** — the auto-generated `og:image` now points at
    `/api/cms/og.png` (where the endpoint is actually mounted) instead of `/og.png`,
    which 404'd for social crawlers.
  - **Consistent environment resolution** — `resolveSeoEnvironment()` centralises the
    production gate, preferring `VERCEL_ENV` over `NODE_ENV` everywhere (preview
    deploys report `NODE_ENV=production`), so `noIndexNonProduction` reliably
    noindexes preview deployments.
  - **Admin route boundary** — the CMS middleware matches `/admin` and `/api/cms` on
    a path-segment boundary, so sibling public routes like `/administrators` or
    `/admin-news` are no longer treated as admin routes.
  - **AI status semantics** — SEO AI endpoints now return `503` when the provider is
    installed-but-unconfigured (vs `501` when the plugin is genuinely missing).
  - Removed a duplicated `gradeForScoreBand` helper in favour of the canonical
    `gradeForSeoScore`.

  cms-admin:
  - SEO filter chips use accessible toggle-button semantics (`aria-pressed`) instead
    of misused `role="tab"`.
  - Crawl-settings toggles use the Radix `Switch` primitive and are disabled while a
    save is in flight.
  - Score progress bars expose an accessible name and `aria-valuetext`; drawers
    without a description no longer trigger Radix's missing-description warning.
  - Redirect CSV export defers object-URL revocation (and mounts the anchor) so the
    download isn't cancelled before it starts.
  - The SEO AI service treats both `501` and `503` as the graceful "AI unavailable"
    state.

- 90f53a6: Fix SEO Operations Center production-readiness blockers.
  - **SSRF (`GET /seo/summary`)**: the link-health probe now uses `safeFetch`
    (blocks private/loopback IPs, no redirect following), only probes same-origin
    internal links, skips all probing when `NEXT_PUBLIC_SITE_URL` is unset, and is
    gated to EDITOR+ — matching `/seo/link-health`.
  - **`GET /llms.txt` leak**: now excludes soft-deleted documents and
    internal/system collections (`__seo_config__`, `__*`, etc.) via the shared
    `isInternalCollection` helper, so "deleted" and internal content can no longer
    appear on a public surface.
  - **`PUT /seo/content` data-layer bypass**: SEO field edits now route through the
    new `updateDocumentSeoFields` action, which enforces the collection's
    `access.update` rule, snapshots a `Version` row, recomputes the search index,
    and fires the `afterUpdate`/cache-invalidate effects — while preserving the
    undeclared SEO convention keys the generic field-access layer would strip.
    Inbound SEO fields are now type- and length-validated.
  - **cms-admin**: SEO read fetchers in `seo-service` now re-throw on `cmsApi`
    errors so the error/retry UI works instead of rendering a misleading empty
    fallback (e.g. a fake score of 0 / grade "Critical") on a backend failure.

## 0.20.0

### Minor Changes

- ea35f7b: Ship only the canonical **Blog** Quick Post Type preset on the New Post Type screen. The previous demo-oriented presets (Case Studies, News, Portfolio, Events, Team) were specific to the reference site and are not appropriate npm defaults — equivalent content types remain available as opt-in collection factories in `@actuate-media/cms-core` (`collections/presets.ts`).
- 5fa84d3: Media Library improvements:
  - Grid cards now show a persistent footer (filename, type · size, SEO-status dot) instead of a hover-only overlay, and rows pack to their natural height (fixes the large vertical gaps).
  - Alt Tag and Title "AI Generate" now call the real `/ai/media-metadata` endpoint, falling back to a basic filename-derived suggestion when AI isn't configured (replacing the previous fake `setTimeout` generators).
  - Removed the focal-point picker from the detail drawer to reduce clutter (the `focalPointX/Y` columns are retained in the schema).

- 5fa84d3: SEO Operations Center — a full detect → prioritize → explain → route → fix → audit → generate workflow.

  **cms-core**
  - Additive `0009_seo` migration (mirrored to `apps/dev`): `SEOAuditRun`, `SEOIssue` (+ severity/status/category enums), `Redirect` extensions (`hits`, `lastHitAt`, `enabled`, `priority`, `preserveQuery`, `source`), `Redirect404Hit`, and `RedirectSuggestion`. All columns are nullable or defaulted, so it is safe to apply to an existing database.
  - Pure, explainable scoring model (`seo/score.ts`): `calculatePageSeoScore` / `calculateSiteSeoScore` with documented weights and breakdowns.
  - Persistent audit engine (`seo/audit-engine.ts`) with cross-run issue lifecycle (open / resolved / ignored / recurring) and a redirect engine (chain/loop detection, safe flatten, hit tracking, 404 logging, gated AI 404 recovery).
  - New endpoints: `POST /seo/audit`, `GET /seo/audits[/:id]`, `GET /seo/issues[/:id]`, issue `resolve`/`ignore`/`reopen`/`fix`, `GET /seo/overview`, redirect CRUD + import/export + chains/loops/flatten/suggest, technical settings (`/seo/sitemap/*`, `/seo/crawl-settings`, `/seo/robots`), and approval-gated `/ai/seo/*` (generate-field, explain-issue, generate-schema, summarize) that degrade gracefully with 501 when `plugin-ai` is unconfigured.
  - `/resolve` now returns Next.js-ready `metadata` plus validated JSON-LD; public `robots.txt` honors the admin-edited body.

  **cms-admin**
  - Rebuilt `SEO.tsx` into a five-tab Operations Center (Overview / Content / Technical / Redirects / Audit) with `?tab=` deep-link routing, dynamic badge counts, a typed `seo-service.ts` client, reusable token-only primitives and a focus-trapped drawer, editor/fix/copilot drawers, and full loading / empty / error / permission states.

  **mcp-server**
  - New SEO tools: `seo_overview`, `run_seo_audit`, `list_seo_audit_runs`, `list_seo_issues`, `get_seo_issue`, `set_seo_issue_status`, `list_redirect_rules`, `seo_redirect_overview`, `seo_redirect_chains`.

  **create-actuate-cms**
  - Scaffolded projects now ship a redirect-resolving `middleware.ts` backed by the database redirect table.

## 0.19.0

### Minor Changes

- 9c38720: Remove the unrouted legacy `Posts` and `PostEditor` view exports. These were superseded by the section-based `PostsListView` / `PostSectionEditor` (wired into `AdminRoot`), were never reachable through the admin router, and their internal navigation was broken. The live Posts surface is unaffected.

  Note: although removing public exports is technically breaking, these components were pre-1.0 and non-functional in practice, so this is released as a minor rather than forcing a 1.0. Consumers importing `Posts` / `PostEditor` directly (none expected) should switch to the routed admin shell.

### Patch Changes

- d95c8e7: Post editor: edit the body as rich text, pick a featured image from the media library, and keep the body WYSIWYG.
  - The post details modal now edits the `richText` Body with the shared rich-text editor (was a plain textarea, which lost formatting/newlines since the body renders as HTML) and picks the `media` Featured image via the media library browser (was a raw URL field only).
  - The admin Article Body section now renders the body as sanitized HTML, matching the public site, so the editor canvas and preview are true WYSIWYG.
  - The preview tab's Close button now falls back to the editor when the tab can't be closed programmatically (in-app navigation), instead of doing nothing.

- 5567daf: Post templates: seed sensible defaults and route the admin through the dedicated endpoint.
  - A post type with no saved template now returns the default starting template (centered header + article body + related posts) instead of an empty one, so new posts and the template editor are never blank. Applied on the server (`GET /post-templates/:type`) and defended client-side.
  - The admin post-editor service now reads/writes templates through the dedicated, audited `/post-templates/:type` endpoints (scoped + logged) instead of the raw `post-templates` collection API.

- 1768a8a: Render the post type's template header on the public site so the live page matches the editor canvas and preview.
  - `GET /resolve` now returns `template.header` (a normalized `PostHeaderConfig`) for post-type documents, resolved from the post type's `post-templates` doc at read time. The template stays the single source of truth, so editing it restyles every post of that type without re-saving each post.
  - New `computeReadingTime()` export drives the header's "min read" figure from the post body (200 wpm, HTML stripped, min 1 minute) instead of the previous hardcoded "5 min read".

- 01bba26: Fix posts list bulk actions silently no-op'ing across pages. Selection is now scoped to the rows visible on the current page and is cleared on page change, so the bulk-action bar count always matches the posts a publish/delete will actually touch.
- bf799ac: Posts editor polish: the post editor's permission gate now fails closed when no
  role is present (the server already enforces access independently); typed the
  `config` / `session` props on `PostSectionEditor` instead of `any`; removed a
  dead category branch and an arbitrary brand color (now the `primary` token) in
  the post header / empty-state; and corrected stale doc comments in
  `NewPostPicker` and `PostTypesView`.
- d85e89e: Add a server-side aggregate `GET /posts` endpoint for the admin Posts area.
  Filtering, sorting and pagination now run at the database level (one query plus
  one count) and respect per-collection read scope, so the Posts list scales past
  the old ~500-row-per-type client cap instead of pulling every document into the
  browser. The admin `fetchPosts` service now calls this endpoint; its public
  `Post` / `PostType` / `PostAuthor` shapes are unchanged.

## 0.18.0

### Minor Changes

- d649ba4: Pages production-readiness hardening:
  - **Accurate counts:** the Pages list header now reports the true page total (`grandTotal`) instead of the sum of tag counts, which silently excluded untagged pages.
  - **Fewer/lighter fetches:** the list fetches the dataset once and applies search, sort, group, and paging in memory, so typing or re-sorting no longer re-downloads every page document (also removes a double-fetch on tag toggle). New `fetchPagesDataset()` / `PagesDataset` exports back this.
  - **Data-loss guard:** the page editor detects a misconfigured `pages` collection (missing `sections`/`path`/`metaTitle`/`metaDescription`) and refuses to operate with a clear message instead of silently dropping content on save. New `missingPageFields()` export.
  - **Accessibility:** `aria-sort` moved from the sort button to the column header (`<th>`); the bulk tag menu is now a Radix `DropdownMenu` (keyboard nav, Escape, focus return) instead of a hand-rolled dropdown.
  - **Reliability:** bulk publish/draft/tag/delete now run with bounded concurrency (no longer one-at-a-time), tolerate per-row failures, and surface a real error toast on total failure.
  - **Preview:** the standalone preview "Close" button falls back to the editor when the tab wasn't script-opened.

  Adds regression tests for the API mapping/write payloads, `fetchPages` counts/pagination, bulk concurrency, the schema guard, and the list view.

## 0.17.0

### Minor Changes

- 4fb0097: Add semantic theme tokens and migrate the Dashboard off raw palette colors.
  - New theme tokens in `theme.css` (light + dark + `@theme inline`): `success`, `warning`, `info` (each with a `-foreground`), and a `brand` accent capturing the admin's violet. Values are tuned so the token reads as accessible TEXT on both the card surface and its own `/10` tint, and as a solid fill (dots, bars). This is the foundation for migrating the rest of the admin off hard-coded Tailwind palette colors.
  - `Dashboard.tsx` now uses these tokens for all status, content-health, delivery, banner, and brand-accent styling, and maps the decorative stat-card accents to the sanctioned `chart-1..5` categorical tokens. Error states intentionally remain on raw `red-*` because the existing `--destructive` dark value is too dark to use as text — promoting error to a token is a separate, admin-wide change.

  No intended visual change to semantic/brand styling; the stat-card icon accents adopt the chart palette.

- 4fb0097: Dashboard performance: cache the expensive SEO compute and reduce refetch churn.
  - **cms-core:** `/stats` now caches its average-SEO-score computation per content-scope for a short TTL (60s). That score otherwise reads and scans the `data` JSON of up to 200 published documents on every dashboard load — the heaviest part of the endpoint. On a warm cache the read is skipped entirely; transient DB failures are not cached. Module-level so it survives across warm requests; a cold start recomputes once.
  - **cms-admin:** `useApiData` gains an opt-in `cacheMs` option. When set, successful responses are cached in-memory by endpoint and reused on the next mount (cache-first), so views that remount frequently don't re-issue identical requests. `refetch()` always bypasses the cache. Default behaviour is unchanged for existing callers. The Dashboard enables a 30s cache on its four requests, so navigating away and back no longer re-fires them; first load and independent streaming are preserved.

## 0.16.1

### Patch Changes

- 8a51783: Dashboard production-readiness fixes.
  - **Fix:** Recent Activity and Publishing Queue rows now open documents in the correct editor. Post-type documents route to the section-based post editor (`/posts/:type/:id/edit`) instead of the legacy generic document editor, which has no concept of the section model and would silently strip a post's `sections` on save. Pages route to the full page editor (`/pages/:id/edit`).
  - **Accessibility:** Clickable Recent Activity, Publishing Queue, and Content Health rows are now real `<button>` elements — keyboard-reachable and focusable with a visible focus state — instead of click-only `<li>`s.
  - **UX:** The Recent Activity "View all" control is relabeled to "Show more / Show less" (it expands in place, capped at the server's 20-item limit) and is hidden when there's nothing more to show. The greeting and date now refresh on a 60s ticker so a long-open dashboard no longer reads "Good morning" all afternoon, and relative activity timestamps stay current.
  - A document opened from the dashboard via the bare `/pages/:id` route now receives the preview handler, matching the `/pages/:id/edit` route.

  Adds `useApiData` unit tests (load / null endpoint / exponential-backoff retry + exhaustion / refetch recovery) and Dashboard render tests covering loading, empty, and exhausted-error states plus the editor-routing regression above.

## 0.16.0

### Minor Changes

- 91885de: Add a section-based visual Page Editor for the Pages area.

  `/pages/new` and `/pages/:id/edit` now open a flat, section-based editor (a
  left **Sections** panel + scaled preview canvas + slide-over inspector)
  instead of the tree-based page builder. Pages are an ordered list of
  `PageSection`s persisted as JSON on the page document, so there is no schema
  change.

  Highlights:
  - Sections panel with accessible drag-and-drop reorder (dnd-kit, keyboard
    support), visibility toggle, duplicate, and delete-with-confirmation.
  - Top bar with breadcrumb, live status indicator, real viewport presets
    (4K / 1080p / Laptop / Tablet / Mobile) that scale the preview canvas,
    Save Draft, and Publish.
  - Shared `SectionRenderer` (Hero Banner, Text, Image + Text, By the Numbers,
    CTA Banner) used by both the editor canvas and the chrome-free
    `/pages/:id/preview` route, so the preview matches what ships.
  - Schema-driven inspector for content/appearance/advanced settings, media
    picking, draft/publish validation, unsaved-changes guards, permission
    gating, and loading/empty/error states.

- 91885de: Add the Pages management area and restructure the sidebar.
  - **Pages list** (`/pages`): table matching the design reference — search, group-by (None / Tag / Status / Author), status filter, sortable columns (Title / Author / Status / Last Modified / SEO), a tag-chip filter bar (OR semantics), bulk actions (publish, draft, add/remove tag, delete with homepage protection), and pagination. Loading / empty / filtered-empty / error states included.
  - **Page tags**: DB-backed tag system. Tags are documents in a hidden `page-tags` collection; pages reference them by slug in `data.tags` with an optional `data.primaryTag`. New `pages-service` (`fetchPages`, `fetchPageTags`, `applyPagesQuery`, `groupPages`, bulk page/tag mutations, tag CRUD + merge) keeps filtering/grouping logic pure and testable.
  - **Tag manager** (`/pages/tags`): create, rename, recolor, merge, and delete tags (detaching them from pages on delete).
  - **Sidebar**: Templates is now its own top-level Content item with **Saved Sections** and **Widgets** children; Pages no longer nests those. Adds a `/widgets` placeholder view.
  - Reserves the `/pages` and `/widgets` route namespaces and wires `/pages/new`, `/pages/:id/edit`, and `/pages/tags*`.

  Note: page seed/mock data lives only in the private `apps/dev` package and is never published.

- 91885de: Add the Posts admin area (All Posts + Post Types).
  - `/posts` aggregates every collection marked `type: 'post'` (or
    `admin.group: 'Posts'`) into a single sortable, filterable table
    with type tabs, search, status/author filters, bulk actions
    (publish / move to draft / delete), and pagination. Counts come
    from the data layer, not constants.
  - `/posts/types` shows a card grid per post-type collection with
    accent border, icon, description, post count, field count, and
    URL base — plus a dashed "+ New Post Type" card linking to setup
    instructions.
  - `/posts/new` lands on a type picker before opening the existing
    per-collection editor.
  - `/posts/types/new` offers a **Quick Post Type** gallery (Case
    Studies, News, Portfolio, Events, Team) that pre-fills a tailored
    field set, icon, and accent color, plus a manual form — both feed a
    paste-ready `actuate.config.ts` snippet generator. Collections
    remain code-defined because the read-only serverless filesystem
    rules out runtime config writes.
  - Sidebar collapses every post-type collection under a single
    "Posts" parent with fixed `All Posts` / `Post Types` children.
  - `/api/cms/collections` now surfaces `admin.description`, `icon`,
    `color`, and `group` so the admin client can render type cards
    and tabs without a separate fetch.
  - New `admin.color` field on `CollectionDefinition` (accent token).

  V1 deliberately skips an in-app Post Type editor / field builder —
  the cards click through to the existing per-collection list and
  new Post Types are added via the snippet generator. A follow-up PR
  introduces a DB-overlay for visual metadata (icon / color /
  description) editable from the admin UI.

- 91885de: Add a design-driven Posts template system that mirrors the Pages section editor.
  - **cms-core**: tag section types with a `scope` (`page` | `post`), add post-oriented section types (`article-body`, `quote`, `author-bio`, `related-posts`, `gallery`), `PostHeaderConfig` / `PostTemplate` types, the `getSectionTypesForScope` helper, and new field types (`richText`, `boolean`, `relationship`, `gallery`). Adds `GET/PUT /post-templates/:type` API routes and a `?scope=` filter on `GET /page-sections/types`.
  - **cms-admin**: new `PostSectionEditor`, `PostTemplateEditor` (with header panel), `PostPreview`, post section renderers, and a `post-editor-service` data layer. The Add Section gallery now filters by scope, and post type cards expose an "Edit template" action.
  - **mcp-server**: scope-aware `list_section_types`, plus `get_post_template` and `save_post_template` tools so an external AI agent can read and build a post type's template.

- 91885de: Page and Post previews now open in a new browser tab as standalone, chrome-free pages.
  - The editor "Preview" action opens `/{basePath}/…/preview` in a new tab (popup-safe: the tab is opened synchronously within the click and pointed at the URL after any pending draft save resolves), so the preview reflects the latest saved content.
  - Preview routes render without the admin sidebar/header, so a previewed page looks like the real published page.
  - The slim preview bar is now tab-scoped: "Close" (closes the tab) and "Open editor".
  - `useAdminRouter` exposes `buildHref(path)` for constructing absolute, basePath-aware admin URLs without navigating.

- 91885de: Add a "View on site" editor action that previews a draft through the real public front-end.
  - Page and Post editors gain a "View on site" button (shown once the document is saved). It mints a signed, time-boxed preview token (`POST /preview/token`), then opens the document's real public URL — `<urlPrefix>/<slug>?preview=<token>` — in a new tab (popup-safe, save-first when dirty). The public route drops the `PUBLISHED` filter for just that document, so unpublished drafts render at their canonical URL.
  - New `lib/preview-link.ts` helpers: `createPreviewToken`, `buildPublicPreviewUrl`, and `resolveSiteUrl` (config `seo.siteUrl` → current origin).
  - The public site URL is taken from `config.seo.siteUrl`, falling back to the current origin, so same-origin installs work with no extra config.

  This builds on the new-tab preview: the in-admin `/preview` route remains the always-available fidelity preview, while "View on site" renders through the consumer's actual page components when a public route exists.

### Patch Changes

- 91885de: Add a section-aware bridge so AI agents (e.g. in Cursor / VS Code) can build pages section-by-section through MCP, producing real, editable, publishable Page Editor sections rather than disposable markup.
  - **cms-core**: new `@actuate-media/cms-core/page-sections` subpath exporting the flat Page Editor section registry, pure section-array helpers, and a deterministic `validateSectionContent`. This is now the single source of truth shared by the admin editor, the REST API, and MCP. Adds REST routes: `GET /page-sections/types`, `POST /page-sections/validate`, and section CRUD over a page document's `data.sections` (`GET|POST /page-sections/:collection/:id`, `PATCH|DELETE /page-sections/:collection/:id/:sectionId`, `POST /page-sections/:collection/:id/reorder`). Mutations go through the audited `updateDocument` path (versions, hooks, cache invalidation, live-preview push) and reuse collection scope checks. Writes are rejected on collections that don't declare a `sections` JSON field.
  - **mcp-server**: nine new tools — `list_section_types`, `get_section_schema`, `validate_section_content`, `list_page_sections`, `add_page_section`, `update_page_section`, `reorder_page_sections`, `remove_page_section`, `toggle_page_section_visibility`.
  - **cms-admin**: the Page Editor section registry and section helpers now re-export from `@actuate-media/cms-core/page-sections`; per-section publish validation is delegated to the shared `validateSectionContent`. No behavior change — internal refactor for a single source of truth.

- 91885de: Sidebar: map the `navigation`, `menu`, and `list-tree` `admin.icon`
  identifiers to Lucide icons so navigation/menu collections (e.g.
  `@actuate-media/plugin-navigation`'s `navigations` collection) render
  with a meaningful icon instead of the generic document fallback. Any
  collection tagged `admin.group: 'Content'` already surfaces as its own
  top-level Content nav item; this just gives "Menus"-style collections the
  right glyph.

## 0.15.0

### Minor Changes

- 560d1de: Dashboard redesign + real-data backends.

  `@actuate-media/cms-admin`:
  - Sidebar now uses explicit Content and Settings group headers with nested
    Pages children (Saved Sections, Templates). The collapse control moved
    to the sidebar footer.
  - Header hoists primary page actions (e.g. "+ New Post", "View Site")
    into the top utility bar so the page hero is no longer the source of
    truth for those affordances. The notification bell is wired to the
    real `NotificationBell` component.
  - Dashboard greeting picks the user's first name, and the "Delivery API"
    - "Content Health" cards now read live data from new endpoints.

  `@actuate-media/cms-core`:
  - New `ApiRequestMetric` table (minute-bucketed counter + log-scale
    latency histogram) and `recordApiRequest` instrumentation wired into
    `handler-factory.ts`. Writes are fire-and-forget — request paths are
    never blocked on the metric DB.
  - New `ContentIssue` table + nightly `processContentHealthScan` cron
    (`/api/cms/cron/content-health`) that runs a pure-logic scanner for
    missing meta descriptions, missing alt text, outdated content
    (>90 days), and broken internal links via HEAD probes.
  - New endpoints `GET /api/cms/stats/api-delivery` and
    `GET /api/cms/stats/content-health` return the dashboard's
    aggregates with stable, never-undefined response shapes.
  - Migration `0007_dashboard_metrics` adds both tables with the indexes
    the new queries need. Safe to apply to populated DBs (no backfill).

  `create-actuate-cms`:
  - Scaffolded `vercel.json` now includes the nightly content-health
    cron entry, and the deployment guide documents it.

## 0.14.1

### Patch Changes

- 40d48da: Two flake fixes from the OSS maintenance pass:
  - **`NotificationBell` SSE/REST race.** The `seenIdsRef` lookup index
    was reconciled with `items` via `useEffect([items])`, which runs
    after React commits. An SSE push that arrived between a REST
    refresh resolving and the effect running would read a stale ref,
    fall through the de-dup gate, and append a duplicate row. The ref
    is now maintained synchronously at every items-mutation call site,
    and the SSE handler also re-checks the latest snapshot inside the
    `setItems` updater as a final safety net.
  - **`compareToDummyHash` eager-init test determinism.** Exported a
    test-only `_waitForDummyHashReady()` so the timing-parity test can
    measure verify-only latency (rather than racing the eager hash
    precompute on a cold module). Added a paired test that asserts the
    eager-init promise resolves with no work pending on the consumer
    side — catches future refactors that accidentally drop the
    top-level invocation.

## 0.14.0

### Minor Changes

- a0f6eba: Rich `@`-mention picker for comments (last Phase 7 carry-over).

  **`cms-core`**: New `GET /api/cms/users/search?q=<query>` endpoint —
  authenticated (any role), case-insensitive `contains` matching against
  `name` + `email`, capped at 10 results, returns only `{ id, name,
email }` (no role / hash / audit metadata). Empty / whitespace
  queries return an empty array so the endpoint can't be used as a
  directory-enumeration tool.

  **`cms-admin`**: New `<MentionableTextarea>` component plus pure
  helpers in `lib/mention-picker.ts` (`detectMentionContext`,
  `insertMentionToken`, `searchMentionUsers`). Wired into the three
  comment composer surfaces in `CommentSidePanel`: the new-thread
  textarea, the reply input, and the in-place edit textarea. The
  picker is keyboard-driven (Arrow Up / Down, Enter / Tab to commit,
  Escape to close) with a Radix-style listbox + ARIA roles, and
  gracefully handles slow responses (drops stale requests when typing
  quickly).

  **`mcp-server`**: New `search_users(query)` tool exposes the same
  lookup so AI agents can resolve teammates by name fragment before
  drafting a comment. Wire format is unchanged — the picker writes
  `@<userId>` tokens that the existing `extractMentionedUserIds`
  parser already understands, so no notification schema migration is
  required.

  No breaking changes.

## 0.13.0

### Minor Changes

- 4732184: Per-user SSE push channel for the notification bell (Phase 7 carry-over).

  `@actuate-media/cms-core` exposes a new authenticated SSE endpoint:

  GET /api/cms/notifications/stream

  The stream pushes each `comment_reply`, `comment_mention`, and
  `comment_resolved` notification the instant it's persisted to
  `DocumentNotification`. The wire format is `event: ready` (once on
  connect, with the subscribed `userId`) followed by `event:
notification` frames carrying the full DTO, plus a 25 s heartbeat
  keepalive and a 4 min server-initiated close so `EventSource`
  reconnects before platform max-duration caps fire.

  `@actuate-media/cms-admin` switches `NotificationBell` over to an
  `EventSource`-backed subscription with the existing REST endpoints as
  a convergence backstop (the in-process bus is single-node by design;
  the poller now runs every 60 s instead of 30 s). The new
  `subscribeNotificationStream` helper is exported for any consumer that
  wants to drive its own UI off the same channel.

  Both packages bump `minor`: the cms-core change adds a new public
  route and a new realtime export surface; the cms-admin change adds a
  new `NotificationsApi.subscribe` member and exports the
  `NotificationStreamHandle` / `NotificationStreamOptions` /
  `EventSourceLike` types. No breaking changes — callers who don't pass
  a custom `api` continue to work unchanged.

## 0.12.1

### Patch Changes

- a3be694: Security: bump `happy-dom` from `^15.7.4` to `^20.9.0` to close all five open Dependabot alerts (2 critical + 3 high). The package is a vitest test-environment polyfill only (no production runtime exposure), but the advisories cover real attack surfaces against dev / CI machines:
  - `GHSA-37j7-fg3j-429f` (critical, fixed in 20.0.0): VM Context Escape → RCE through the script-execution sandbox.
  - `GHSA-w4gp-fjgq-3q4g` (high, fixed in 20.8.9): `fetch` credentials use page-origin cookies instead of target-origin cookies, leaking session cookies to cross-origin endpoints.
  - `GHSA-6q6h-j7hj-3r64` (high, fixed in 20.8.8): `ECMAScriptModuleCompiler` interpolates unsanitised export names as executable code.

  The bump is a five-major-version jump (15 → 20) but happy-dom is consumed only via the `// @vitest-environment happy-dom` directive in three test files — no direct API usage exists, so the major-version surface change is transparent. All 264 cms-admin tests and 26/26 workspace test tasks pass on `happy-dom@20.9.0`.

## 0.12.0

### Minor Changes

- 02df678: Phase 4, PR #2 — admin form generator + cross-file type resolution.

  `@actuate-media/cms-admin` adds a new `componentBlock` field renderer:
  - `<ComponentBlockField>` picks a component from the manifest's allow-list and renders a typed props form via a recursive `<PropInput>` dispatcher. Inputs vary per `PropType.kind` (string, number, boolean, enum, literal, object, array; unsupported kinds fall back to a JSON textarea with a hint).
  - Picking a new component seeds the props object from each prop's `defaultValue`, with structural defaults for required props that lack a literal default.
  - Live structural validation mirrors `validateComponentBlockValue` from cms-core and surfaces per-prop errors under the correct input on every keystroke.
  - The new field is wired into `FieldRenderer`'s `FIELD_MAP` under `componentBlock` and exported from the package's main entry.

  `@actuate-media/component-blocks` extends the extractor:
  - Cross-file `import type { Foo } from './bar'` is now followed during extraction (with cycle detection + per-run caching). Renamed imports (`{ Foo as Bar }`) are stored under their local alias.
  - TypeScript-ESM-style `.js` import specifiers resolve to their `.ts`/`.tsx` source siblings.
  - Manifest `filePath` is now a POSIX-relative path from the `rootDir` instead of an absolute disk path, so a manifest generated on Windows is identical to one generated on Linux.

  Bug fixes from Bugbot review:
  - `<EnumInput>` placeholder option now clears the field to `undefined` instead of silently selecting the first enum value (`Number("")` → `0` regression).
  - `getAllowedComponents` and `buildClientValidator` now agree on the meaning of `allow={[]}`: both treat an empty allow array as "no filter". Previously the picker showed every component while validation rejected every selection.
  - Import scanner bail-out no longer skips 6 extra characters after `import` (the keyword-match index already points past `import`), so subsequent imports after a side-effect import are no longer silently swallowed.
  - `defaultForType` now returns `null` (not `undefined`) for `union`, `reference`, and `unknown` kinds so the documented "never `undefined` in an array" invariant holds. `JsonFallback` treats both `null` and `undefined` as empty so the textarea doesn't show the literal string `null`. `seedPropsForComponent` skips seeding required props of those kinds so the validator's "Missing required prop" message doesn't fire the instant an editor picks the component (the prop stays absent until the editor provides a value via the JsonFallback).
  - `safeJsonStringify` no longer short-circuits for strings; the JsonFallback textarea now round-trips strings through `"hello"` instead of bare `hello`, so users can keep editing without the field "locking" into a non-JSON form.
  - `clientShapeError` now recurses into object props with required children. Passing `{}` for an object prop with required nested fields used to slip past the client validator and fail server-side; the validator now reports `Prop 'cta.label' is required but missing.` inline.

- 12e042c: Phase 4, PR #4 — discriminated-union picker + marketing-grade docs.
  Closes Phase 4 of the competitive gaps roadmap.

  The headline UX win: a `union` PropType whose variants share a single
  required literal field (the discriminator) no longer falls through
  to the JSON textarea. The admin form renders a variant `<select>` and
  a recursive object form for the chosen variant's remaining fields,
  keyed automatically off the TypeScript shape — no manifest
  annotation, no schema language, no configuration.

  **New `@actuate-media/component-blocks` exports:**
  - `detectDiscriminator(propType)` — pure function. Inspects a union
    PropType; returns `{ field, kind, variants[] }` when every variant
    is an object sharing a required-literal field with distinct values,
    or `null` otherwise. The rule is strict by design — a half-
    discriminated union would silently accept invalid states.
  - `findVariant(value, detected)` — locate the variant whose
    discriminator value matches a stored value. Used by the form to
    keep the picker in sync, and by the validator + future migrator to
    route checks into the correct variant's schema.
  - New types: `DiscriminatedUnion`, `DiscriminatedVariant`,
    `DiscriminatorValue`.

  **`@actuate-media/cms-admin` updates:**
  - `<PropInput>` `union` branch now calls `detectDiscriminator` first;
    on a hit it renders the new `DiscriminatedUnionInput` (variant
    picker + recursive object form for the chosen variant's remaining
    fields). Non-discriminated unions still fall back to the JSON
    textarea with a clearer hint.
  - Variant switching preserves fields that exist on both variants
    (so `{ kind: 'link', label: 'Go' }` → `{ kind: 'modal', label: 'Go',
modalId: '' }` keeps the label), drops variant-exclusive fields,
    and seeds new required fields from their declared defaults.
  - `defaultForType` now returns a structural default for discriminated
    unions (first variant + its required field defaults). A required
    discriminated-union prop arrives on screen as a fully-editable form
    rather than a `Missing required prop` error.
  - `seedPropsForComponent` carries that forward — required
    discriminated-union props seed cleanly; non-discriminated unions
    / references / unknowns still stay absent so the validator's
    message and the data state agree honestly.
  - `clientShapeError` routes discriminated-union values into the
    matching variant's schema. New error messages: `cta.kind must be
one of: "link", "modal"`, `cta.modalId is required but missing`,
    `cta must be an object with a 'kind' discriminator`. Same
    precision as plain object props.

  **Example component upgraded:**
  - `examples/Hero.tsx` now ships a discriminated `cta` prop
    (`{ kind: 'link', href, label } | { kind: 'modal', modalId, label }`).
    The `/component-blocks-demo` live playground doubles as the
    marketing artifact and exercise the picker end-to-end.

  **Docs:**
  - New `docs/component-blocks-intro.md` — marketing-grade landing page
    with the comparison matrix vs schema-first CMSs and a 60-second
    hands-on tour. The technical reference doc links to it from the top.
  - `docs/component-blocks.md` gains a dedicated "Discriminated-union
    picker" section covering the detection rule + programmatic API.
    Known limits refreshed; "variant picker ships in PR #4" notes
    removed.
  - `docs/roadmap-status.md` marks Phase 4 as feature-complete /
    in-review.

  **Tests:** 18 new unit tests in `discriminated-union.test.ts`
  (positive + negative cases for the detector, `findVariant` matching);
  7 new helper tests covering `defaultForType` / `seedPropsForComponent`
  / `clientShapeError` for discriminated unions; existing extractor
  test updated to assert the new Hero CTA shape via the public detector.
  Workspace test counts: component-blocks 141 → 159, cms-admin 82 → 89.

  This PR closes Phase 4. The next session can start fresh on
  Phase 3 (real-time collaboration) or Phase 6 (importers).

## 0.11.0

### Minor Changes

- 72db484: Ship the Actuate Media brand mark as the default admin/login logo, and extend the existing `admin.branding` override to the login screen.

  **Sidebar**
  - The default expanded-sidebar wordmark now renders the real Actuate Media lockup (inlined as a base64 data URL so it works under every consumer bundler with no extra asset config). The collapsed state keeps the lightweight inline SVG mark since the raster asset gets fuzzy at 32×32.
  - `config.admin.branding.logo` still overrides the default — unchanged behaviour for integrators who already configured a custom logo.

  **Login**
  - The login screen now renders the same brand logo at the top by default (the generic Shield icon is gone). It reads from the same `config.admin.branding` block as the sidebar, so swapping logos is a one-line change.
  - New `branding?: LoginBrandingConfig` prop on `<Login />` exposes `logo`, `name`, and `tagline`. Pass `logo: null` to opt out of any logo on whitelabel deployments.
  - `AdminRoot` wires `config.admin.branding` through to `Login` automatically.

  Docs updated in `docs/admin-guide.md` ("Admin Branding") with the override snippet.

- 8f2d3f8: Redesign the admin Dashboard and fix the recurring "content cut off on the left" layout bug.

  **Dashboard (`@actuate-media/cms-admin`)**
  - New `Dashboard.tsx` matching the production design: time-of-day greeting, hero action row (New Post / View Site), horizontally-scrollable quick actions, five stat cards, two-column main grid (Recent Activity + Publishing Queue + Content Health), and a four-tile Content Delivery footer (Total Documents, Scheduled, Form Responses, Active Webhooks).
  - All data derived from the existing `/stats` and `/health` endpoints — no additional network round-trips. Recent activity, publishing queue, content health, and delivery tiles are computed client-side via `useMemo` so the backend never blocks the frontend.
  - Fully responsive: stat cards collapse from 5 → 3 → 2 columns at `lg` / `sm` / mobile breakpoints, the main grid stacks below `lg`, and quick actions scroll horizontally on narrow screens.
  - Deterministic per-author avatar colors (hash-based) so the activity feed is stable across renders without state or extra fetches.

  **Layout cutoff fix (`@actuate-media/cms-admin`)**
  - Added `lg:shrink-0` to the desktop Sidebar wrapper. Without it, on viewports between `lg` (1024px) and ~1280px the flex sidebar column could compress below the Sidebar's intrinsic 256px width, causing dashboard content to overflow into and visually overlap the sidebar. Combined with the existing `min-w-0` on the main column, the row now reliably renders as `[256px Sidebar][1fr Main]` at every desktop width.
  - Dashboard is wrapped in `max-w-[1400px] mx-auto w-full` so on ultra-wide displays the content stays in a comfortable reading column instead of stretching edge-to-edge.

  **Stats endpoint extension (`@actuate-media/cms-core`)**
  - `GET /stats` now returns `webhookCount` and `webhookActiveCount`, calculated via two cheap `safeCount` calls in the existing `Promise.allSettled` batch. The dashboard's "Active Webhooks" tile uses these instead of issuing a second request, keeping page load to a single stats fetch.

- d3d06bf: Add layout primitives package (Phase 0.3 of the best-in-class roadmap).

  Introduces a sanctioned set of layout components every admin view must
  compose against, so layout bugs (sidebar overlap, breakpoint mismatches,
  content blowout) cannot recur in new views:
  - `AdminShell` — the CSS-Grid based admin chrome. Owns the
    desktop/mobile decision, the sidebar overlay, and the scroll lanes.
    `<Layout>` now delegates to it.
  - `PageContainer` — the canonical per-page wrapper. Owns max-width,
    padding, and the title/description/actions header.
  - `Stack` — vertical stack with a single `space` token.
  - `Cluster` — horizontal wrap-friendly group with `align` / `justify`.
  - `Grid` — explicit-column or `autoFit` responsive grid.
  - `Split` — two-pane primary+aside layout that stacks on mobile.
  - `Box` — lowest-level forwardRef'd container (semantic `as` prop).
  - `tokens` — single source of truth for the spacing / radius /
    breakpoint scales (mirrors Tailwind).

  All primitives are exported from `@actuate-media/cms-admin`. Existing
  views continue to render the same markup — the primitives package
  formalizes the patterns rather than reskinning the app.

- 5d18097: Wire per-type SEO defaults into the admin Settings panel — the last loose end from Slice A (SEO Completeness).

  **`cms-core`**
  - New `seo/config-store` module persists admin edits in a single reserved-slug document (`__seo_config__`) and merges them on top of the static `actuate.config.ts` `seo` block at runtime, field-by-field.
  - New endpoints `GET /seo/config` and `PUT /seo/config` (admin-only). Saving uses partial-update semantics so editing the site-wide section never wipes per-collection overrides (and vice versa).
  - `/sitemap.xml`, `/sitemaps/:slug.xml`, `/robots.txt`, `/og.png`, and `/resolve` now load the merged config per request via `loadEffectiveConfig()`, so admin changes are live immediately with no redeploy.
  - Empty strings in PUT payloads are treated as "unset" (fall back to static config) — the panel is purely additive.

  **`cms-admin`**
  - New **Settings → SEO** tab backed by `<SEOConfigPanel />`. Edits site-wide defaults (siteUrl, siteName, OG image, Twitter handle, change-freq, priority, robots toggles, AI-bot blocking) and per-collection defaults (Schema.org type, archive path, sitemap priority/changefreq, noindex/nofollow, exclude-from-sitemap).
  - Static `actuate.config.ts` values render as input placeholders so admins always see what they're overriding.

  12 new unit tests cover the merge semantics, unset behaviour, DB roundtrip, upsert (no duplicate rows), and DB-error fallback.

- e021937: Add typed component variants via a built-in `cv()` helper
  (class-variance-authority pattern, no runtime dependency).

  Refactors `Button` and `Badge` to use typed variants, and introduces three
  new variant-typed primitives: `Card` (with `CardHeader` / `CardTitle` /
  `CardDescription` / `CardContent` / `CardFooter`), `Input`, and `Select`.

  All existing props (Button: `variant`, `size`, `loading`; Badge: `status`)
  continue to work — the new API is purely additive. New options:
  - Button: `variant: 'outline' | 'link'`, `size: 'xs' | 'icon'`,
    `leftIcon`, `rightIcon`, `full`.
  - Badge: `tone`, `size`, `outline` for generic non-status badges.
  - Card / Input / Select: typed variants for size, padding, elevation,
    validation state.

  Also exports each component's variant function (`buttonVariants`,
  `badgeVariants`, ...) so consumers can compose the same class strings
  outside the JSX (e.g. for headless components or third-party renderers).

### Patch Changes

- 3115d90: Two follow-ups to the recent brand-logo addition:

  **1. Inline SVG logo instead of base64 JPEG.** The Actuate Media wordmark now ships as a transparent inline SVG (`<ActuateBrandLogo />`) using the official 2021 brand asset. Drops the surrounding dark pill on both the sidebar and the login screen — the SVG sits naturally on any surface. Bundle size for the logo is ~3.5 KB of paths vs the ~40 KB base64 JPEG it replaced.

  **2. Fix dashboard content cutoff at small desktop widths.** The main column in `Layout.tsx` was missing `min-w-0`, so its flex `min-width: auto` default prevented it from shrinking below the intrinsic width of wider content (the dashboard's `xl:grid-cols-5` stat row, long document titles, tables). On viewports just above the `lg` breakpoint this caused the column to overflow under the sidebar and visibly clip the leftmost cards. Added `min-w-0` and a comment explaining why it's load-bearing.

- 848309f: Improve admin dev tooling (Phase 0.6).
  - `pnpm dev` in `packages/cms-admin` now runs `tsc --watch` and the
    Tailwind CLI `--watch` in parallel via `concurrently`, so JS _and_ CSS
    rebuild on save. Previously you had to remember to run `pnpm build:css`
    manually, which is what allowed the stale-precompiled-CSS class of
    layout bugs to slip in.
  - Adds `prettier-plugin-tailwindcss` so Tailwind class lists are
    auto-sorted in canonical order on save / `pnpm format`. The plugin is
    configured to recognize the in-house `cv()`, `cn()`, and `clsx()`
    helpers so classes inside variant maps are sorted too.

- a815027: Refactor admin shell layout to CSS Grid (with inline-styled grid template
  columns) and a JS-driven `matchMedia` breakpoint, replacing the brittle
  `fixed` ↔ `md:static` positioning toggle that repeatedly let dashboard
  content render under the sidebar when the precompiled CSS bundle was
  stale or browser-cached.

  The new layout is bulletproof against missing utility classes: even if a
  single Tailwind class went missing, the grid column reserved by the
  parent guarantees the main content never overlaps the sidebar. Mobile
  overlay behavior is preserved.

  Also adds a `dev:css` watch script for parallel CSS rebuilds during
  development.

- 8a87aac: Add Playwright visual regression infrastructure (Phase 0.5).

  `e2e/visual-regression.spec.ts` snapshots four canonical admin screens
  (login, dashboard, posts list, settings) at four breakpoints
  (375 / 768 / 1024 / 1440 px), giving every PR a pixel-level safety net
  for the kinds of layout regressions that caused recurring "content under
  the sidebar" bugs.

  New scripts:
  - `pnpm test:visual` — run the suite.
  - `pnpm test:visual:update` — refresh baselines after an intentional
    design change.

  Docs: `docs/visual-regression.md`.

  Baselines will be captured on the first CI run after this PR merges.

## 0.10.0

### Minor Changes

- 39f5941: Slice E — Preview & Publishing Polish.

  **Draft preview links**
  - Preview tokens now accept a configurable `ttlSeconds` (clamped to
    `[60, 2,592,000]`, default 7 days). The old 5-minute hard-coded TTL
    was too short for sharing with clients.
  - `POST /preview/token` now verifies the document exists before
    issuing a token, includes the doc's `slug` in the response so the
    admin can build a friendly URL, and records the issuing user in the
    JWT `sub` claim for audit logs.
  - `GET /resolve?preview=<token>` drops the `status: PUBLISHED` filter
    when a valid token is presented, returning draft content at the
    document's real URL. The response includes a `preview: { active,
expiresAt }` block so frontends can render a "preview mode" banner.
    Tokens are bound to a single `(collection, documentId)` — using one
    against a different URL returns `403`.

  **Scheduled publishing**
  - New `POST /collections/:slug/:id/schedule` endpoint sets
    `scheduledAt` and/or `scheduledUnpublishAt` (validated as future
    ISO 8601 timestamps with `unpublish > publish`) and flips status to
    `SCHEDULED`. The existing scheduling cron picks the doc up at the
    scheduled time.
  - New `DELETE /collections/:slug/:id/schedule` cancels a pending
    schedule. `SCHEDULED` docs revert to `DRAFT`; `PUBLISHED` docs with
    a pending unpublish stay live.

  **Admin UI**
  - New **Share preview** toolbar button on `DocumentEdit` opens a
    dialog to generate signed preview URLs with selectable TTL
    (1 hour / 1 day / 7 days / 30 days) and a copy-to-clipboard control.
  - New **Schedule** button in the Status panel opens a datetime picker
    for setting both publish and (optionally) unpublish times. Active
    schedules render an inline blue pill with the times. "Reschedule"
    and "Cancel schedule" supported.
  - New exported components: `SharePreviewLinkDialog`,
    `SchedulePublishDialog`.

  **Tests & docs**
  - 20 new tests: preview adapter TTL clamping, JWT round-trip, secret
    isolation; `/preview/token` + `/resolve?preview=` happy/sad paths;
    schedule create/cancel/validation paths.
  - New `docs/preview-and-scheduling.md` walkthrough; `docs/api-reference.md`
    expanded with the new endpoints.

## 0.9.0

### Minor Changes

- e87471d: Phase 1: AI access. API key authentication (act*sk*\* bearer tokens) with scope-based authorization across collections, globals, media, and the page builder. New /api-keys admin endpoints + UI for creating, listing, and revoking keys with password reauth. CSRF is skipped for API-key requests. New /page-builder/create one-shot endpoint that generates an AI page and persists it as a document in a single call. New @actuate-media/mcp-server package exposes the CMS to AI agents (Claude, Cursor, ChatGPT, etc.) via the Model Context Protocol — full CRUD, globals, media, page generation, and one-shot create tools. Adds 44 new tests covering the API key auth path, scope enforcement, CSRF skip, and MCP client/tools.

## 0.8.2

### Patch Changes

- 6d2ea6e: Fix admin rendering offscreen when wrapped in a `<div className="actuate-admin">` by the consumer. `theme.css` was applying `contain: layout style` to `.actuate-admin`, which made every such element a containing block for `position: fixed` descendants. AdminRoot's own fixed overlay then resolved relative to the wrapper instead of the viewport, and if the wrapper had no other content the entire admin rendered into a 0-height box. Switched to `contain: style` (keeps the style-scoping benefit, drops the positioning footgun).

  Symptom on the consumer side: clicks on admin buttons mysteriously fail with Playwright's `<body> intercepts pointer events`, even though the DOM is fully rendered.

- 6d2ea6e: Two fixes that emerged while stabilizing the e2e suite:
  - **cms-core**: `createRateLimiter()` now respects `ACTUATE_DISABLE_RATE_LIMIT=1` and returns a permissive limiter that always allows requests. This is for test harnesses where every test shares the same client IP and exhausts the strict per-IP buckets within the first few login attempts. Production never sets this var; the deploy guide and Vercel templates omit it. Previously the env var bypass only existed at the per-handler `checkRateLimitAsync` wrapper in `handlers.ts`, so the global API rate limit in `handler-factory.ts` (which uses `rateLimiter.check()` directly) ignored it and broke retries with HTTP 429.
  - **cms-admin**: `<SEO />` view crashed with "M.filter is not a function" the first time it rendered. The `/seo/link-health` endpoint returns `{ truncated, checked, issues: [...] }` (an object), but `useApiData<any[]>` typed it as an array and the `linkHealth = linkHealthData ?? []` coercion silently put the wrapper object into the `linkHealth` variable. Calls like `linkHealth.filter(l => l.status === 404)` then threw on every render — including when the user was on the (default) Pages tab, because Radix `<Tabs.Content>`'s children are evaluated eagerly even when the tab is unmounted. Fixed the typing and switched to `linkHealthData?.issues ?? []`.

## 0.8.1

### Patch Changes

- 22c314b: Fix React Rules of Hooks violation in `AdminShell` that caused
  "Rendered more hooks than during the previous render." after login.

  The `useMemo` for the collection→type map (added in v0.8.0 to power
  PageBuilder routing) was placed _after_ the early-return guards for
  `setupRequired` and `!session`. The pre-login render therefore called
  N hooks; the post-login render called N+1 — which crashes React. The
  hook is now hoisted above the early returns so the count is stable
  across renders. Caught by the e2e Playwright suite (`auth.spec.ts →
login redirects to dashboard`).

## 0.8.0

### Minor Changes

- 6db988e: Major engineering quality + integration upgrades.

  **Security & reliability (cms-core)**
  - Audit log: fixed runtime crash from `orderBy: createdAt` → `timestamp`; never throws on insert; allows null `details`.
  - IPv4 + IPv6 + CIDR allowlist with canonical comparison; reject `unknown` IPs against non-empty allowlists.
  - Centralised trusted client IP extraction via `x-vercel-forwarded-for` / `x-real-ip` / `x-forwarded-for` (with explicit `trustProxy`).
  - Atomic Upstash `INCR` + `EXPIRE NX` rate limiter, fail-open with logging.
  - Encryption at rest for OAuth tokens, TOTP secrets, backup codes, and webhook secrets via `CMS_ENCRYPTION_KEY`.
  - Hardened TOTP login: `mfa_pending` JWT + per-IP/per-user rate limit + request fingerprint binding (no userId leak between steps).
  - Reauth required for TOTP setup/disable; revoke other sessions on TOTP changes.
  - OAuth state nonce bound to a cookie; `allowSelfSignup` flag; never silently link to password-protected accounts.
  - SSRF-safe `safeFetch()` for webhooks and `/seo/link-health`; concurrency cap + URL count cap; private IPs/loopback/link-local rejected.
  - Magic-byte + MIME validation + DOMPurify SVG sanitisation on `/media/upload`; tightened WebP/AVIF/GIF/SVG detectors.
  - Webhook + plugin hook dispatch moved outside the DB transaction so a rollback never fires events for a doc that no longer exists.
  - AI: per-user rate limit + token/cost budget + input length cap + balanced-brace JSON extractor + `validateTree` after every step + secret redaction in audit prompts.
  - CSRF exemption tightened to exact-prefix match on the rewritten path (no more `pathname.includes`).
  - `refreshSession`: validate session row exists & is active before re-issuing.
  - Public SEO endpoints require auth; `/llms.txt` filters PUBLISHED + non-deleted.
  - `/forms`, `/forms/:id/submissions`, `/search/global` users restricted to EDITOR+.
  - Open-redirect host allowlist for `/redirects`; folder routes validate scope on move/delete; password-reset email enumeration blocked.
  - `/script-tags/resolve` returns proper public cache headers.

  **Integration fixes (consumer feedback)**
  - **`createServerSiteClient(prisma, config)`** — new server-only Site client that reads from Prisma directly. Use in Server Components, `generateMetadata`, route loaders. Avoids the `Invalid URL` crash that `createSiteClient()` throws under bare Node, and skips the self-fetch overhead.
  - **`createSiteClient()`** now auto-resolves `baseUrl` from `NEXT_PUBLIC_SITE_URL` or `VERCEL_URL` when not explicitly provided.
  - **`/public/globals/:slug`** defaults to public when no `access.read` is set (was a 403 footgun).
  - **`cmsApi`** auto-bootstraps the `actuate_csrf` cookie before non-GET requests; `AdminRoot` also primes it on mount when a session exists. Fixes the silent 403 every consumer hit when signing in via a custom Server Action.
  - **AdminShell** routes collections with `type: 'page'` through `<PageBuilder>` instead of `<DocumentEdit>`. Built-in admin paths (`/users`, `/media`, etc.) are reserved before collection routes so a user-defined `users` collection can't shadow the Users view.
  - **`BlockCatalog`** now reads custom blocks from `globalThis.__actuateConfig.pageBuilder.blocks` automatically. Also accepts an explicit `customBlocks` option and an `includeCore: false` flag.
  - **Page Builder envelope** unified at the top level — `layout` and `pageSettings` are accepted on POST/PUT and returned at top-level on GET. Storage shape is `data._layout` / `data._pageSettings`; both wire shapes accepted on input for backwards compatibility.
  - **`/media/upload`** now uses the configured platform storage adapter (e.g. `platform-vercel`) when set; honors `token`/`prefix` options. Falls back to direct `@vercel/blob` import for legacy installs.
  - **BlockEditor** field types fleshed out: real TipTap editor for `richText`, working media picker for `media`, JSON editor with validation for `json`, plus `slug`, `date`, and an array editor for primitive arrays.
  - **`pageBuilder.previewTheme`** now applied to the canvas via scoped CSS variables.
  - OpenAPI: documented `/public/globals/:slug` and `/resolve`.

  **DX**
  - `.env.example` documents required formats for `CMS_ENCRYPTION_KEY` (64-hex), `BLOB_READ_WRITE_TOKEN`, and `RESEND_*`.
  - Tests: 266 passing (was 230); regression tests for IP allowlist, client-ip, redact, secret-storage, audit, upload-magic, server-site, custom block discovery.

## 0.7.3

### Patch Changes

- 58bf2e6: Add site-wide SEO robots defaults with per-page index/follow override controls.

## 0.7.2

### Patch Changes

- e2fabee: Add AI-friendly deployment diagnostics, align generated schemas with first-run admin features, and harden setup guidance for design-first deployments.

## 0.7.1

### Patch Changes

- b66800c: Align admin-facing API response shapes with cms-admin consumers, add Vercel Blob image configuration, and harden admin views against partial data.

## 0.7.0

### Minor Changes

- c3c55c7: Add AI-powered page generation pipeline with prompt-to-page workflow.

  **cms-core**: AI pipeline types (GenerationContext, GenerationStep, StepResult, GenerationResult), prompt template builders for structure/content/SEO/accessibility steps, deterministic accessibility auto-fix engine (auditAccessibility, fixAccessibility), and API endpoints for /page-builder/generate, /page-builder/generate-block, /page-builder/audit-a11y, and /page-builder/fix-a11y.

  **plugin-ai**: Multi-step generatePage() pipeline that runs structure → content → SEO → accessibility steps sequentially via AIProvider, with validation retry, graceful fallback, progress callbacks, and token tracking. Single-block generateBlockContent() for context-aware content generation.

  **cms-admin**: AI Generate Dialog with prompt input, tone selection, step toggles, business context fields, real-time generation progress, and step-by-step review with accept/regenerate flow. AI Block Assist button for one-click content generation per block. AI button integrated into the builder toolbar.

- 2ef3505: Add SEO analysis and design scoring for the page builder. cms-core gains `analyzeSEO()` (content extraction, readability, 15+ SEO checks, per-block hints), `analyzeDesign()` (6-category scoring for visual hierarchy, spacing consistency, content density, color usage, image quality, and mobile readiness), and semantic block intelligence with smart suggestions. cms-admin replaces the placeholder SEO and Design tabs in the page builder ContextPanel with fully functional `BuilderSEOPanel` (score ring, checks, readability grid, SERP/social previews, inline editing) and `DesignScorePanel` (score ring, category progress bars, expandable checks, severity-ranked suggestions).
- 2269f10: Add page templates and saved sections for the page builder. cms-core gains 6 built-in page templates (blank, landing, about, contact, location, blog-post) with full container tree structures, plus CRUD API endpoints for both page templates and saved sections with tree validation, auto-seeding, and audit logging. cms-admin adds a TemplatePicker modal for new page creation, a SavedSections CRUD management view with category filtering, and admin routing for the new views.
- b68358b: Add visual page builder UI with three-zone layout (toolbar, canvas, context panel), 10 core block preview renderers, block picker modal, structural node editors, and admin routing integration.

## 0.6.0

### Minor Changes

- b5de8dd: Add password reset flow for admin login. Users click "Forgot Password?" on the login page, enter their email, and receive a time-limited reset link via the configured email adapter (Resend). The reset token is hashed before storage and expires after 1 hour. On successful reset, all existing sessions are revoked.
  - New PasswordResetToken Prisma model with migration 0005_password_reset_tokens.
  - POST /auth/forgot-password and POST /auth/reset-password API endpoints with rate limiting.
  - Core auth module (reset.ts) with token generation, email dispatch, and password update logic.
  - ForgotPassword and ResetPassword admin views with full form validation.
  - AdminRoot routes /forgot-password and /reset-password in the unauthenticated shell.

### Patch Changes

- b5de8dd: Fix ESM module resolution and dependency duplication issues reported during consumer integration.
  - Add "default" export condition to all platform and plugin packages so they resolve correctly from require() contexts (fixes ERR_PACKAGE_PATH_NOT_EXPORTED in next.config.ts and serverless functions).
  - Move @actuate-media/cms-core from direct dependencies to peerDependencies across all packages to prevent duplicate installations when consumer version ranges don't exactly match the pinned version.
  - Add "prepack" lifecycle script to create-actuate-cms to guarantee dist/ is built before npm publishes the package (fixes empty dist/ in v0.4.0).

## 0.5.0

### Minor Changes

- 54c7a3b: Add Script Tags management system for injecting tracking codes, analytics, and custom scripts into public site pages. Admins create tags with HTML/JS code, choose placement (head, body open, body close), set scope (entire site, parent pages + descendants, or exact URLs), and assign priority order. Includes new ScriptTag database model, CRUD + public resolve API endpoints, admin list and editor views, and updated scaffolder site layout with automatic tag rendering.

### Patch Changes

- Updated dependencies [54c7a3b]
  - @actuate-media/cms-core@0.6.0

## 0.4.0

### Minor Changes

- c08cf8b: Add layout variants system for customizable per-page headers, footers, and other layout regions. Layout regions are defined in config, stored as regular collection documents, and assigned to pages with automatic parent-to-child URL path inheritance. The /resolve endpoint returns resolved layout variants alongside page data. Admin UI includes layout assignment on document edit, collection grouping in sidebar, and default layout settings.

### Patch Changes

- Updated dependencies [c08cf8b]
- Updated dependencies [80aa939]
  - @actuate-media/cms-core@0.5.0

## 0.3.0

### Minor Changes

- f2cf7d7: Redesign Dashboard and Pages views: extended /stats API with statusCounts, formCount, avgSeoScore, and author names; new /seo/summary endpoint; recharts donut chart for content overview; SEO performance panel; folder color badges and SEO score column in Pages view.

### Patch Changes

- Updated dependencies [f2cf7d7]
  - @actuate-media/cms-core@0.4.0

## 0.2.1

### Patch Changes

- 0621037: Make Dashboard collection-aware instead of hardcoding Posts/Pages labels.

  **cms-core:**
  - `/stats` API now returns `collectionCounts` (per-collection document counts via groupBy)
  - Recent documents include `collection` field in response

  **cms-admin:**
  - Dashboard reads `config.collections` to derive stat card labels, counts, routes, and quick actions
  - "Edit" links use `/{doc.collection}/{doc.id}` instead of hardcoded `/posts/{id}`
  - "New" quick actions are generated from configured collections
  - Falls back to generic "Documents" / "Pages" labels when no config is provided

- Updated dependencies [0621037]
  - @actuate-media/cms-core@0.3.1

## 0.2.0

### Minor Changes

- 4e3a5eb: RC2-RC4 and hardening release.

  **cms-core:**
  - Replace isomorphic-dompurify with sanitize-html (eliminates Vercel ESM crash)
  - Remove cms-core from serverExternalPackages, move to transpilePackages
  - Add GET /api/cms/health endpoint with model probing and diagnostics
  - Harden db() Proxy with try/catch on property access and rawDb()
  - Harden safeCount/safeFindMany against Proxy throws
  - Add startup validation for missing CMS_SECRET (503 instead of 500)
  - Bypass auth on /stats when secret is missing (returns zero-data)
  - Loosen NextConfigLike types (experimental/images/webpack as unknown)
  - Implement login anomaly detection (new IP, brute force, unusual hours)
  - Implement webhook DNS resolution with private range checking
  - Implement TOTP reauth verification
  - Add Zod validation for secret in defineConfig (min 32 chars)
  - Read CMS_CORE_VERSION from package.json at runtime instead of hardcoding '0.1.0'

  **cms-admin:**
  - Ship pre-compiled CSS (dist/actuate-admin.css) with all 48 responsive classes
  - New export: @actuate-media/cms-admin/styles/precompiled.css
  - Add CSS containment (`contain: layout style`) on .actuate-admin
  - Fix ShortcutHelp modal using React state instead of DOM manipulation
  - Add fixed-position layout isolation wrapper to AdminRoot
  - Add useApiData retry limits (maxRetries=3) with exponential backoff
  - Dashboard shows health-aware "Database Setup Required" banner

  **cli:**
  - Add `actuate db:init` command (injects CMS models into Prisma schema)
  - Add `actuate db:status` command (checks model presence and DB connectivity)

  **create-actuate-cms:**
  - Embed full CMS schema (9 models + enum) in generated schema.prisma
  - Add @actuate-media/cli to scaffolded devDependencies
  - Use pre-compiled CSS import in scaffolded admin layout

### Patch Changes

- Updated dependencies [4e3a5eb]
  - @actuate-media/cms-core@0.3.0

## 0.1.4

### Patch Changes

- Fix /stats endpoint: use Promise.allSettled so missing Prisma models return 0 instead of 500. Dashboard now shows a soft warning instead of a red error banner when stats are unavailable. Add !important to font-family on .actuate-admin to prevent consumer site fonts from cascading into the admin panel.
- Updated dependencies
  - @actuate-media/cms-core@0.2.3

## 0.1.3

### Patch Changes

- Updated dependencies
  - @actuate-media/cms-core@0.2.2

## 0.1.2

### Patch Changes

- Updated dependencies
  - @actuate-media/cms-core@0.2.1

## 0.1.1

### Patch Changes

- 682dac7: Fix consumer integration issues discovered during MaidPro.co deployment
  - **cms-core**: API handlers gracefully degrade when CMS Prisma models are missing (returns 501 instead of 500)
  - **cms-core**: Accept CMS secret via `actuate.config.ts` (`secret` field) in addition to `CMS_SECRET` env var
  - **cms-core**: `withActuateCMS` now accepts `Partial<ActuateCMSConfig>` (no more forced type casts)
  - **cms-core**: `withActuateCMS` auto-injects all `@actuate-media/*` packages into `transpilePackages`
  - **cms-core**: Expanded Prisma schema fragment with all CMS models for consumer merge
  - **cms-admin**: Removed duplicate Tailwind import from admin styles (consumers already load Tailwind)
  - **platform-vercel**: Email adapter accepts both `defaultFrom` and `from` for sender address
  - **plugin-email**: Email plugin accepts both `defaultFrom` and `from` for consistency

- 3fd38a2: Fix all 13 integration issues discovered during MaidPro.co deployment

  **cms-core:**
  - Add .js extensions to all ESM relative imports (58 files)
  - Replace workspace:\* with versioned deps in all packages
  - Add sideEffects:false for proper tree-shaking
  - Subpath exports (/config, /api, /middleware, /next) avoid barrel side effects
  - Stats/search routes use safeCount/safeFindMany for missing Prisma models
  - Ship prisma/cms-schema.prisma with all required CMS models
  - Accept both CMS_SECRET and CMS_SESSION_SECRET with clear error messages
  - Fix hardcoded /admin paths in OAuth flow (now uses getAdminPath())
  - Loosen withActuateCMS and createCMSMiddleware parameter types
  - Auto-inject transpilePackages and serverExternalPackages in withActuateCMS
  - Unify PluginDefinition with ActuatePlugin interface

  **cms-admin:**
  - Scope all 40+ CSS theme variables to .actuate-admin instead of :root
  - ThemeProvider applies dark class to .actuate-admin, not document.documentElement
  - Scaffold uses (admin)/(site) route groups for full CSS/script isolation
  - Sidebar branding supports custom logo via config.admin.branding

  **platform-vercel:**
  - Email adapter accepts both `from` and `defaultFrom`

  **All plugin/platform packages:**
  - Fix ESM .js extensions in compiled output
  - Replace workspace:\* with ^0.1.0 for publishable deps

- Updated dependencies [682dac7]
- Updated dependencies [3fd38a2]
  - @actuate-media/cms-core@0.2.0
