Skip to content

Enhancement: Project Front Door - #486

Open
baltinerdist wants to merge 230 commits into
amtgard:masterfrom
baltinerdist:feature/front-door
Open

Enhancement: Project Front Door#486
baltinerdist wants to merge 230 commits into
amtgard:masterfrom
baltinerdist:feature/front-door

Conversation

@baltinerdist

@baltinerdist baltinerdist commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Overview

Introduces a new public "front door" landing page for the ORK, plus a v2 content-management system (CMS) that powers it and a family of content pages and a blog.

Front Door

  • Cinematic, photo-rich, newcomer-first landing that replaces the utilitarian home page — hero carousel, live stat ticker, education sections (What is Amtgard / Find Your Path / Your First Day), upcoming events, photo mosaic, kingdoms teaser, and a closing call-to-action.
  • Marketing nav mirroring amtgard.com (now CMS-editable).
  • Adaptive: logged-in members get a slim personalized bar; anonymous visitors get the full marketing experience. Dark-mode + responsive throughout.
  • The previous dashboard becomes a standalone Kingdoms Directory at Directory/index (linked from the nav).
  • Rendered from a content-block model (Model_FrontDoorfrontdoor/render_blocks.tplfrontdoor/blocks/{type}.tpl).

CMS (v2)

  • Universal block renderer — the front door, CMS pages, and blog entries all render as an ordered list of blocks. "Page type" is an editor preset, not a separate renderer.
  • Page types: composed / article / media / resource / blog / dynamic.
  • 20+ block types: hero carousel, rich_text, card_grid, gallery (lightbox), video_embed, accordion, table, columns (recursive), file_download, cta_band, image, quote, heading, divider, plus dynamic feeds (events, kingdoms, blog).
  • Admin editor (Cms/index): page & post management, drag-orderable block editor with TinyMCE, media library + image picker, draft / publish / preview / autosave.
  • Blog: posts + tags, paginated index, entry pages, RSS.
  • Editable navigation (the marketing nav reads from the CMS).
  • RBAC: cumulative roles (contributor → author → editor → publisher → admin), CmsCan() with a bridge to the existing HasAuthority so kingdom/park officers gain rights when scoped pages arrive. A gated "Manage Site Pages" entry appears in the user drop-down.
  • Security: strict HTML sanitization (DOM allowlist) on all authored content; URL/scheme validation on links and nav.
  • Scope-ready data model (scope_type/scope_id — global now; kingdom/park drop-in later with no migration).

Data / migrations

  • New ork_cms_* tables (db-migrations/2026-06-23-cms-foundation.sql) + idempotent seed scripts (home page, marketing nav).
  • New web-served assets/cms-media/ dir (runtime uploads gitignored; .gitkeep tracked).
  • The front door is served from the CMS store, falling back to hardcoded defaults when unseeded.

Quality

Built and verified phase-by-phase, then put through a multi-lens polish pass (security, data-integrity, stability, performance, conventions) with implementer→critic review:

  • XSS hardening (sanitizer URL bypasses, nav-link validation, recursive nested-field sanitization).
  • Data integrity (dup-slug lastInsertId guard via read-back; ReplaceBlocks/Reorder transactions).
  • Stability (session-token check no longer mass-logs-out on a transient DB error).
  • Perf (capability resolution collapsed; blog tag N+1 → single batched query; RSS caching).
  • DRY CmsBase refactor across the CMS libs.

Specs: docs/superpowers/specs/2026-06-22-amtgard-front-door-design.md, docs/superpowers/specs/2026-06-23-amtgard-cms-design.md.


Update — Theming & Multi-Site (added since this PR was opened)

CMS Theme Engine

Token-based theming for the public CMS — admins restyle colors, typography, and shape/density from curated design tokens (plus an advanced raw-token panel), no CSS. Values are stored per scope and injected as a :root { --token: value } block at render; front-door stylesheets reference var(--token). Dark mode is auto-derived from the single palette (WCAG-contrast-checked). Live editor with preview + light/dark toggle. No free-form CSS (values only flow into named custom properties).

CMS Multi-Site — per-kingdom public websites

Cashes in the scope-ready data model: each kingdom gets its own standalone public website at /k/{slug}, authored with the same block builder.

  • Public routing + standalone chrome (Controller_Site): own header/logo, scoped nav, own theme, own footer with a subtle "Part of the Amtgard ORK ↗" tie-back. Per-org theme injected from the site's own scope.
  • Scope-aware admin: the existing CMS editor threads a validated ?scope= selector (re-checked server-side via HasAuthority — never trusted). Officers self-serve edit; monarch/regent (AUTH_ADMIN) publishes. IDOR guard on every mutation. Unpublished sites 404 to the public but preview for authorized officers, who also get the edit/new-post FAB.
  • Org-scoped dynamic blocks: live Officers, Parks (sortable by name/city/state, optional heraldry + rank badges), Events, and an interactive Parks Map (the Amtgard Atlas map + click-to-open detail sidebar) scoped to the kingdom.
  • Starter template seeded on first "Manage Public Site" (Home / About / Our Parks / Officers / Documents + nav, published-ready); Directory "Visit site" discovery link.
  • Global Sites overview (Cms/sites, ORK super-admins): Amtgard International pinned on top, then every started kingdom/park site — status badge, page/post counts, drill-in to the scoped CMS admin, inline publish/unpublish, and new-site provisioning.
  • Block picker refresh: wide 3-column layout with per-block descriptions and collapsible groups.

Data model is park-ready (/p/{slug} deferred). Spec: docs/superpowers/specs/2026-07-01-cms-multi-site-kingdom-sites-design.md.

Latest CMS-module polish

Multi-lens review (72 sharded reviewers) of the CMS module, verified/fixed: IsSafeUrl percent-encode XSS bypass closed (%6aavascript: / java%09script:, tested), park-name escaping in the map block, media-search placeholder fix, DeletePage/DeletePost atomicity (read-back + rollback) + opt-in scope-ownership guards, RevokeRole fail-closed auth, and block-render fault isolation (one bad block can't blank the page).

🤖 Generated with Claude Code


Update — Distributed review, then a mobile & accessibility pass

Three further rounds since the last update.

Distributed review — problem findings

Multi-lens review of the whole CMS/front-door platform, each shard verified by an adversarial critic pass.

  • Persistence integrity — block-save verification compares every column plus an order-insensitive JSON check; zero-row / trashed UpdatePage/UpdatePost now fail loudly; the dup-slug guard distinguishes a real collision from a dropped write; RestoreRevision hard-fails on a corrupt snapshot.
  • Routing — slug/parent redirects with prefix fallback, post-rename 301s, leaf-only publish gating, cycle and cross-scope parent rejection, transliterated slugs.
  • Caching / perf — GhettoCache on the home page + blocks and site-by-slug, gated _promoteScheduled, an in-memory admin path map (kills an N+1), clamped and cached teaser.
  • Multi-site scope — fail-closed scope normalization, global grants keyed at 0, RevokeRole capability probe, scoped person lookup, nav that resolves only live targets.
  • Security — sanitizer size/depth budget, recursion depth cap on columns, media where-used scan that includes embedded <img>, copyDoc MIME allowlist, CSRF on preview-publish.
  • Migrations — relative UIR seeds, full demo chain, guarded FK adds, idempotent content seeds, hard-required sanitizer.

Distributed review — 24 enhancements

  • Front doorcontent-visibility on block sections, block surfaces routed through --fd-* theme tokens (zero visual change), carousel ARIA region + live announcements, per-block <style> dedup, gallery lightbox scroll-lock + inert background, preview-only block-failure placeholder.
  • CMS lib — shared CmsPage::HTML_FIELDS/URL_FIELDS, content-mutation audit trail, PurgeTrashed + orphan-block sweep + unused-tag prune, block-only saves bump owner updated_at/updated_by, DB-enforced one-active-theme-per-scope.
  • Controllers — memoized block catalog, batched bulk media delete, centralized page-type enum, per-view analytics deferred off the render critical path, clearrendercache + runmaintenance actions.
  • Editors — shared cms-admin.js (one source for toast / modal / CSRF-post), view counts on Pages & Posts plus a top-content dashboard panel, window.CMS_CAPS.
  • Multi-site — single grant-driven edit-FAB helper across Page/Blog/Site, sitemap.xml.
  • Tests — CmsSanitizer XSS regression, CmsThemeTokens math, block-field allowlist contract.

Mobile & accessibility remediation

A mobile-review pass over the front-door blocks, the CMS admin shell, and the shared theme chrome.

  • Layout — CTA rows wrap via flex/gap instead of margin-left on the nth button; the steps grid steps 4→2→1 column; the path grid collapses at 520px; carousel dots and the play/pause toggle lift above the 2×2 stat ticker; mosaic tiles take a uniform height with object-fit:cover; the member bar actually hides on phones; long org names ellipsize instead of shoving the hamburger off-screen; rich-text and raw-HTML tables scroll in their own container rather than forcing horizontal page scroll; CMS nav-manager rows wrap so actions keep a 40px touch target; the recovery-email input moves to 16px to stop iOS zoom-on-focus.
  • Contrast (WCAG AA)#b8860b (2.9:1 on white) darkened across kickers, nav hover/active, officer roles, blog cards and the RSS link; muted greys lifted in file_download and the parks map. Dark mode added for the recovery-email modal and the parks-map empty states; --ork-warn defined globally so the media library's "In use" label is no longer an undefined token.
  • Accessibility — icon-only controls get aria-label with aria-hidden on the glyph; the search toggle becomes a real <button> and the maintenance banner an aria-expanded button; focus trap + focus restore on CMS admin modals and background inert on the staff-roster modal; media tiles are keyboard-operable with a focus ring; kingdom-profile tabs get role=tablist/tab/tabpanel; icon-rail labels are visually hidden rather than display:none so the accessible name survives; Directory section titles promoted to <h2>; scrollable tables get tabindex=0 + role=region.
  • Also — the carousel supports touch swipe (passive, horizontal-only); CMS/blog pages suppress the stacked app nav; the Page_view breadcrumb uses UIR instead of a hardcoded path; photo_mosaic surfaces an empty-state hint in the CMS preview.

Update — OGRE branding, adversarial review, and park sites

Five commits since the last update.

The CMS is now "OGRE" (Online Gallery and Resource Engine)

Product name surfaced in the admin chrome and author-facing copy — rail wordmark (with a visually-hidden acronym expansion so a screen reader doesn't read an opaque token), dashboard lede, page titles, deny page, draft-preview banner, nav dropdown, and the kingdom "Manage Public Site" tooltip. Cms* classes and ork_cms_* tables stay as the internal identifiers.

Adversarial review — three hostile passes

Core engine, presentation, then a third pass attacking the remediation itself and the test suite.

  • SeedingCmsSite inferred "starter template never seeded" from live content, so an org that deleted its seeded nav links or trashed a starter page got them silently re-created as published pages. Replaced with an explicit template_seeded_at marker plus a separated repair path. The schema probe guarding that column used try/catch, which is unreachable under PDO ERRMODE_WARNING — now SHOW COLUMNS.
  • Slugs are addresses_normalizeSlug used iconv('ASCII//TRANSLIT'), whose output is libc- and locale-dependent: the same org name derived creconom in the container and cr-econom on a macOS host, so one kingdom could get two different live URLs depending on which machine created the row. Replaced with an explicit transliteration map + NFD normalization (a decomposed paste converges on the same slug as the precomposed form), verified byte-identical on both hosts. CmsPost::_slugify carried a second copy of the same call — tag slugs are both a URL segment and the dedup key, so one tag name entered on two hosts produced two ork_cms_tag rows; now routed through the shared helper.
  • Scope / tenancymediabulkdelete accepted ids without filtering them to the caller's scope; added FilterOwnedIds (int-cast into the IN list, never the no-op escape shim) with a cap that fails closed. Media reference counts ignored deleted_at, so soft-deleted owners pinned media forever — split into live-owner counting for the reversible guard and all-owner counting for the irreversible purge.
  • Leaks & clocks — breadcrumbs leaked unpublished ancestor slugs, and the due-check compared PHP's America/Chicago clock against a UTC database, keeping live ancestors redacted for ~5h after publish. is_live is now computed in SQL.
  • Presentation — the block editor built its summary with innerHTML on a detached div, and a detached <img onerror> still fires in Chrome; replaced with a DOM-free stripHtml() that decodes entities in a single pass so &amp;lt; cannot cascade into a real <. That table had also been folded to lowercase two lines under the comment forbidding it, flattening exactly the Norse and Old-English names (&AElig;, &Eacute;) it existed for.
  • Editor / picker — the home-page picker read a 500-row updated_at DESC list and reported "no home page chosen" when the real one aged off the cap (a page nobody edits falls off first); it now injects the current home page on a miss, scope-checked against the save rule.

Park sites are live

/p/{slug} is served alongside /k/{slug}. New park_meeting, park_officers and park_events blocks, with kingdom and park officer rendering sharing one partial. Controller_Site::_prefixFor() is now the only way to derive an org URL — the site-settings modal had hard-coded /k/ in three places, showing park officers the wrong public address for their own chapter, and its permission copy named "monarch or regent" in park scope where the gate is AUTH_PARK. CmsSite::OrgUnitNoun() returns Kingdom / Principality / Park so copy stops hard-coding "kingdom"; principalities need no schema change, being ork_kingdom rows with a parent_kingdom_id.

Three new single-source-of-truth classes

Each replaces a pair of declarations that could drift apart silently.

  • CmsBlockRegistry — the one declaration of the block catalog and page-type presets. The editor chooser, starter-block seeds, page-type allowlist and the CmsAjax save-time allowlists all read it, so the write side can no longer drift from the editor's vocabulary.
  • CmsMeta — canonical + Open Graph assembly. Five controllers had hand-rolled the same $PageMeta literal and their own http/https origin detection.
  • CmsRenderCache — the render-cache key registry. Memcached has no prefix scan, so "flush this org's site" can only be an enumeration; the block partials that write keys and the endpoint that busts them previously declared namespace, key format and clamp bounds independently, and drift there failed in the worst direction — the endpoint reports "cleared 433 entries" while the officer who just fixed their roster keeps seeing the stale one.

Front door

The "Record Keeper" nav link is now an "ORK Login" outline button (destination unchanged — Route=Directory). The label lived in three places that all had to agree, including the marketing_nav DB row that the live home page actually renders, so a PHP-only edit would have changed nothing — hence db-migrations/2026-08-09-cms-nav-ork-login-label.php (idempotent, leaves deliberately-renamed labels alone, reads each write back since Execute() is void under ERRMODE_WARNING). Also trimmed ~48px of dead clearance reserved for #newmenu, which the front door hides.

Tests

New tests/cms-tenancy covers the multi-tenant boundary in CmsMedia — scope filtering, storage metering, upload quota — code three review passes had read but nothing had ever executed. site_test.php was red on this branch (it pinned the pre-transliteration slug); corrected and extended with Æ/Þ/Ø/ß and NFD-vs-NFC cases. All five suites pass.

Also

Fixed a pre-existing master bug where ENVIRONMENT=DEV served a blank 200 at the document root — the HTTP_UI redirect sat inside the non-DEV branch only.

Migrations: 2026-08-09-cms-site-seed-marker.sql (additive, re-run safe), 2026-08-09-cms-nav-ork-login-label.php.


Update — OGRE in-browser capabilities test, and the six fixes it produced

The CMS declares 32 block types and 8 of them had never had a single row written
to the database
(spacer, table, video_embed, blog_feed, raw_html,
park_meeting, park_officers, park_events), so no amount of static review could
reach them. Every addable block type was instantiated through the Add-block chooser,
saved, and looked at in Chrome across three kitchen-sink pages — global, kingdom, and a
park site provisioned during the run. Plan and full results:
docs/superpowers/plans/2026-08-10-ogre-capabilities-test.md.

30/30 addable block types pass. Also verified: park-site provisioning, scope gating
(kingdom_* correctly absent from the park chooser), the page-type chooser, autosave,
media upload (master + thumb + WebP rendition, all served), dark/light contrast (0
failures), 390px horizontal overflow (0 offenders), and 0 console errors on public pages.

The XSS battery — entered through the real editor save path, not injected into the DB —
was fully neutralized: <script>, <img onerror>, <svg onload>, javascript:,
%6aavascript:, java&#9;script: and a foreign <iframe> all stripped, benign markup
intact, the three anchors keeping their text and losing their href entirely.

Six findings, all fixed in this branch

  • Park sites were seeded with the KINGDOM starter template (high). A new park site
    got kingdom_events, kingdom_officers, kingdom_parks and kingdom_parks_map
    each of which correctly renders nothing outside a kingdom scope — an "Our Parks"
    page for an org with no parks, and copy calling the park a kingdom. The blocks and the
    chooser were already scope-correct; only the seeder wasn't, so an officer opened their
    new site to three blank pages with no error. The registry is now scope-aware, seeds the
    three park_* blocks (including park_meeting, previously seeded nowhere), drops the
    parks page, and runs its copy through OrgUnitNoun().
  • park_meeting printed a literal 0 as the venue name (medium). alternate_location
    is a tinyint flag read as a name. The first fix made it worse — reading location
    instead dumped a raw geocode JSON blob on the page, because that column is a geocode
    cache in practice (522 of 806 rows hold JSON, 33 hold anything human). Caught by
    re-testing in the browser rather than assuming.
  • Seeded pages repeated their own titleSite_shell already promotes the title to
    the page <h1>, so the leading heading block rendered it twice.
  • Empty rich_text / cta_band painted blank full-width bands — now silent for
    visitors, italic author hint in preview, matching the existing hero/mosaic pattern.
  • Route=Cms/edit with no id returned a zero-byte HTTP 200 — blank page, no error,
    same shape as the DEV document-root bug fixed earlier on this branch. All four bare
    admin routes now redirect to their list, carrying scope.
  • TinyMCE logged an evaluation-mode warning once per rich-text block — added
    license_key: 'gpl'.

db-migrations/2026-08-10-cms-park-seed-repair.php repairs park sites already seeded
with the kingdom template. Conservative by design: it retypes kingdom_officers/
kingdom_events to their park_* counterparts in place, disables rather than deletes
the two blocks with no park counterpart, and rewrites seed copy only where it still
matches the seed text byte-for-byte. It creates no pages, touches no nav, deletes
nothing, and is re-run safe.

tests/cms-site gains 13 checks pinning the scope-aware seeder. All five CMS suites pass.


Update — the park starter template ("Seal and Field")

A park is not a small kingdom. It is a physical place a nervous stranger might drive to
on a Saturday, and its site has one job the kingdom's does not: convert that stranger
into someone who shows up. Parks previously got a lightly-trimmed copy of the kingdom
template. This replaces it with a purpose-built three-page site.

Design: docs/superpowers/specs/2026-08-10-park-site-starter-template-design.md ·
Plan: docs/superpowers/plans/2026-08-10-park-site-starter-template.md

Two measurements decided most of the structure

Taken against a production-shaped restore of 342 active parks:

Signal Coverage Consequence
Park-day records 308 (90%) The recurring meeting is the spine of the site
Upcoming event 26 (7.6%) No Events page — it would be empty on 92% of parks
Heraldry device 316 (92%) The hero is a crest…
Banner photo 5 (1.5%) …because it cannot be a photograph

An Events page would have told a prospective newcomer this club is dead — via the nav,
before anyone clicked. Events stay as a block on Home, where the honest empty state reads
as "nothing beyond our regular park days."

What ships

  • Three pages — Home / New Players / Contact, against the kingdom's five. About Us
    is gone (its seeded body published author instructions to the open web) and so are
    Documents & Resources (a park has no library) and the Board of Directors roster
    (parks have no board; it published a fabricated person).
  • A sticky quick-facts strip on every page — day, city, directions — sourced from
    park-day data, never hand-typed. All three audiences want that fact first and diverge
    only on the second, so it is hoisted above the split. Three degradation tiers: time and
    place; place only when there are no park days (never an approximated time); and
    nothing at all when neither exists.
  • park_hero, a crest-led hero. The park's device is framed as a seal — .jpg devices
    cover-crop so their own background becomes the plate, .png devices float matted. The
    frame is the design decision; the image is cargo. Parks with no device get a monogram,
    not a shared placeholder crest. Carries the next game day and, inside a 7-day horizon,
    the forecast high.
  • A palette derived from each park's own arms, extracted once at seed time and written
    to ork_cms_theme, so no two of the 342 look alike. Lightness is forced to 0.22
    specifically because white then clears 7:1 on every hue — worst case 7.11:1 at hue
    60, pinned by an all-hue sweep in the tests.
  • Copy that is true for any park unedited — no weekday, no price, no city, no
    attendance claim, and never text that reads as instructions to the officer.

Bugs fixed along the way

  • Vacant officer seats rendered as nameless cards — the skip required both persona
    and role to be empty; a vacancy has a role. 187 of 342 parks showed at least one.
  • MedievalSharp was the default heading font and the seeder never created a theme
    row, so every org site shipped in a faux-medieval face.
  • Per-org theming never reached below the hero — section bands and ~30 colours were
    hard-coded literals, so a wine-red park still got Bootstrap-blue links.
  • The member bar was hidden on phones — right for the global front door, backwards for
    a park, whose most common visit is a member checking the time in a parking lot.
  • Four WCAG AA failures, all found by measuring rendered pixels rather than reading
    markup: white-on-gold at 1.86:1 on the hero's primary CTA, the dark-mode accent
    token at 2.77:1, a kicker at 1.657:1, and the monogram seal at 3.20:1 in dark. Worst
    ratio anywhere on the seeded pages is now 4.68:1.

Two criticals only the whole-diff review could see

Every task passed its own review first; these needed the whole picture.

  • The heraldry colour extractor was dead for every kingdom. _heraldryPath() built
    %05d for both scopes, but kingdom files are pad-4 (0007.jpg). The probe never
    matched, so kingdoms silently fell through to a name-hash colour — and the
    park→parent-kingdom cascade could never fire. The tell was in data already written: the
    stored primaries were byte-identical to the hash fallback. Pad widths now come from
    one Heraldry::PAD_LENGTHS constant, with a regression test.
  • .fd-kicker's font-size was invalid CSS. --fd-font-scale is a length (1rem), so
    calc(var(--fd-font-scale, 1) * 0.6875rem) is rem × rem — dropped silently on every
    front-door page. Measured at 14.67px (inherited) before, 11px after.

Also fixed: the hero and strip could display a park day already in the past (11 parks);
park_hero had no block-editor schema and so was uneditable; and the closing CTA band had
no "come to a park day" button — a URL-less park rendered zero buttons.

Honesty about seeded content

_parkIntroBody() inherits the park's own ork_park.description, but 123 of the 246
parks with a description contain a weekday or a clock time
. Snapshotting one would
publish a meeting time that never refreshes — the exact failure the "never seed a time or
place" invariant exists to prevent, since a stale time sends a newcomer to an empty field.
Those descriptions now fall through to the evergreen paragraph.

Deploy note

db-migrations/2026-08-11-park-theme-backfill.php must run after this lands. Any org
site seeded earlier carries a hash colour, and because the migration skips scopes that
already have a theme row, re-running it will not self-correct — those rows must be deleted
first.

Not delivered

The design called for a clearly-labelled placeholder hero photo behind the crest.
park_hero.tpl supports placeholder_image, but nothing seeds it and it is unreachable
from the editor, so parks ship crest-and-colour only. Recorded in the spec's AS-SHIPPED
note rather than quietly dropped.

23 commits. All seven test suites pass (+27 new checks).


Update — the CMS/CRM CSS separation, and the gates that keep it

The CMS ("OGRE") is a separate product hosted inside the ORK, but its CSS was
entangled with the CRM's. A standalone org site was downloading the whole ORK
application stylesheet to render a page that shares none of its chrome.

Three surface tiers, enforced rather than intended. A standalone org site at
/k/{slug} now loads cms-base.css + the public set + orgsite.css — and
zero bytes of orkui.css, tokens.css or cms-admin.css. In-shell CMS
surfaces keep the CRM pair plus orkshell-interop.css, the one designated
coupling point. OGRE admin renders inside the shell so it may read --ork-*
(350 times) but never define one.

bin/check-css-boundaries.sh holds the line with rules C0–C7 over 147 files.
The scope is derived, not listed: everything under frontdoor/ is public tier
and everything under cms/ is admin tier at any depth; CMS page templates are
found from the controllers carrying the CmsScopeContext trait; and a
$TPL_ROOT template that renders CMS chrome is classified by resolving its
includes rather than matching a literal path. That last one matters — as a
literal match it hit no include line in the repo, because every CMS template
writes include $fdDir . 'render_blocks.tpl', so a new routed CMS surface got
zero rules and could link orkui.css at exit 0.

The gate was then hardened against nine proven evasions, including a staged
symlink (git show :path returns the link target, one short line that passes
everything), CSS identifier escapes, attribute-selector spellings of the same
coupling, and <style> tags built by string concatenation in PHP. CMS PHP and
JS are in scope too: a verifier put a stylesheet link and a <style> onto a
live org-site page from two directions — frontdoor.js via
insertAdjacentHTML, and controller.Site.php echoing markup — and both gates
returned exit 0. That blind set was 31 files.

Static CSS is now pinned tree-wide at C3_TOTAL_STATIC=0, so any static
declaration inside an interpolating <style> trips on landing, and a
duplication ratchet (MAX_GROUPS_2PLUS = 25, MAX_GROUPS_ANY = 89) fails in
both directions — it demands a re-pin when duplication falls, so an
improvement cannot be silently given back. All four gates run in CI, where a
skipped runtime assertion fails the job rather than passing quietly.

Also here: org sites stop reporting pageviews into ORK analytics and stop
shipping the ~1 MB ORK JS bundle; blog.css links only on the two surfaces that
emit blog markup (it was 6,811 bytes matching zero nodes everywhere else).

The accounting is honest in both directions: a standalone org site got 52,953 B
lighter
, and the in-shell front door got 37,654 B heavier, because
blocks.css is deliberately unconditional — block presence is authored content,
so linking it by current content would un-style the next edit. Every figure in
frontdoor/css/README.md carries the command that produces it.

Update — OGRE's admin becomes an editor, not a form dump

A UX pass over the CMS admin, driven by watching the tool get used.

The block editor opened as a wall of every block's form at once; it opens as a
list now, bodies building lazily on expand. Card icons are picked, not typed
as CSS class names. Links are chosen from a page list instead of pasted as
index.php?Route= strings — and Navigation stops showing a route where a page
name belongs. Page types are named for what the author gets, with a sentence
saying what each one makes. The live preview shows what you are typing rather
than your last save. Media cards stop leading with Delete.

The dashboard leads with unfinished work instead of a second welcome banner, and
a brand-new site's dashboard invites work rather than reporting four zeros. The
rail and masthead became one workspace that uses the window; light mode got a
ground so cards read as objects rather than floating.

Bugfixes alongside: block summaries printed raw config tokens; image fields
showed a broken glyph instead of saying "missing"; a missing image file was
treated as an emergency rather than news; and the live preview rendered author
content on the admin's own origin — the one with the session cookie.

Update — three review passes: layers, polish, simplify

A layer-separation and CSS-quality review, then a six-lens quality panel, then a
reuse/simplification pass. Findings were adversarially verified before anything
was applied, which mattered: 47 of 205 were refuted and 85 downgraded. Every
"cross-kingdom IDOR via the model layer" finding died on inspection — the model
is a deliberately thin membrane and the callers do scope, with explicit
IDOR guard comments and _rowInScope / _requireOwned at each one.

Defects that were real:

  • The dashboard's Publish/Unpublish site button threw a ReferenceError on every
    click
    — its 'use strict' IIFE read a bare UIR this one template never
    declared. Nine templates were each re-declaring it while the shell already
    emits window.CMS_UIR, with three different escaping policies between them.
  • The CmsAjax CSRF gate was conditional on REQUEST_METHOD, so a mutation
    taking no parameters executed on a bare cross-site GET.
  • OGRE admin gates read grant rows only, never the officer-authority bridge,
    locking real officers out of the dashboard and page list.
  • The post editor autosaved a published post straight to the public site
    the page editor's deliberate published-guard was never carried over.
  • Route dispatch collided with the render step. Route=Site/view answered
    200 with an unpopulated template. Fixed at the framework: Controller::render()
    now carries the render body, and both per-controller workarounds are gone.
  • The canonical-host allowlist protected two meta tags while the config minted
    every absolute URL from the raw client Host — including index.php's
    header("Location: " . HTTP_UI), an open redirect. The Host is validated before
    any config runs; unset, behaviour is byte-identical.
  • SessionToken's fail-open guard was dead code: YapoMysql::DataSet() returns
    unconditionally and PDO runs ERRMODE_WARNING, so the blip it claimed to absorb
    still logged everyone out. A mitigation that reads as shipped while doing nothing
    is worse than none.

Work that pays for itself: park pages ran GetParkDetails/GetParkDays three
times each with staggered TTLs (~12 queries where 4 suffice); bulk media delete ran
an un-indexable REGEXP scan of ork_cms_block.fields_json per id; the nav cache
probe had grown heavier than the fetch it guarded; and a sparsely-populated org was
permanently uncacheable, because the cache could not tell a failed build from a
legitimately empty one — and the park strip renders on every page of an org site.

Two things the reviews caught in their own output. A first park-memo attempt
used instance properties and never hit, because APIModel constructs a fresh
domain object per call site — it was reverted, not shipped. And the batch
where-used API was built but never wired, so the perf win it advertised did not
exist until the follow-up pass connected it and deleted the public parameter that
could disable the safety guard.

70 commits. Layering, CSS boundary, stylelint and the duplication ratchet all
pass; 10 CMS test suites pass. Verified live in-browser: the 404 routes return
real bodies, the host guard rewrites an attacker Host when configured, the
preview renders <body class="cms-page"> with the navbar gone, and org sites
serve no CRM CSS.

Update — 47 fonts, a picker that shows them, and two bugs the UI revealed

Type across the CMS

The theme editor offered six families from a flat allowlist and drew their names
in the UI's own font, so choosing type meant guessing. It now offers 47 and
renders every name in the face it names.

CmsThemeTokens::FontCatalog() carries group, role, CSS fallback and the css2
weight axis per family, and the pickers, Validate(), FontStack() and the
public <link> all read it. Those were four hand-maintained lists — an
allowlist, an if-ladder of fallbacks, and hardcoded <link> tags in
default.theme — so adding a face took three edits in three files and
forgetting one failed silently. It already had: the org-site seeder wrote
Lexend for every site while default.theme never linked it, so every org site
asked for a webfont that was never loaded and fell back to the generic sans.

The roles are a guard, not decoration. Heading offers all 47; body offers the
21 faces readable at paragraph length, and Validate() enforces the same split
the picker shows — a hand-posted token cannot set body copy in blackletter. The
officer who did it would not see the damage from a one-line preview.

The picker is a real listbox because a native one cannot do this:
font-family on an <option> is honoured by Firefox and ignored by Chrome on
macOS. The <select> stays as the value holder and the no-JS fallback, and the
upgrade dispatches a bubbling change on it so the existing delegated handler
drives preview and dirty state unchanged. The panel is position: fixed and
placed from the button's client rect — .te-group is overflow: hidden, the
same trap the player-search autocomplete hit inside modals, solved the same way.

Loading is proportionate: picker rows fetch their own face as they scroll into
view, and a public page requests only the two families it renders, resolved from
its own theme. An org site went from four hardcoded families to two
actually-used
, and MedievalSharp is no longer pushed to every org that never
chose it. Every family/weight combination was verified against the live css2 API
before landing — an unavailable weight makes Google answer 400 and the whole
stylesheet fails, taking the valid families with it.

Two gates caught real defects here and neither was worked around. C6 failed
closed on a stylesheet href built from a variable ("could name any file,
orkui.css included") — so the origin is now a literal in the template and only
the query interpolates. The duplication ratchet rejected a dark-mode block whose
every token orkui.css already flips; deleting it removed the duplication and
the redundancy together.

Two bugs that only looking at the page could find

Both passed every automated gate.

The park FAQ printed its own <p> tags at readers. The starter template
seeded accordion answers as sanitized HTML into a field the rest of the pipeline
treats as plain text — the editor gives it a plain textarea, accordion.tpl
escapes it, and it is correctly absent from CmsPage::HTML_FIELDS. The seeder
was the one component that disagreed, and eight answers opened with a literal
<p>. Rendering the field raw would have been the wrong repair: nothing
sanitizes it, so un-escaping would trade a cosmetic bug for a stored-XSS
surface. Existing park sites need their stored rows repaired too.

The live preview rendered a font nobody chose. The preview is an <iframe>
its own document with its own font set — so CSS naming a family the frame had
never fetched drew the generic fallback. That failure is quiet and plausible
rather than blank: 'Tangerine', cursive with Tangerine missing renders in
macOS Snell Roundhand, so the preview showed a font, just never the picked one.

Apostrophes in public URLs

_normalizeSlug hyphenated every non-alphanumeric, so "Angler's Rift" became
angler-s-rift — a bare "s" stranded between two words. 139 parks and one
kingdom
on this instance have an apostrophe in their name. An apostrophe sits
inside a word, so it is now deleted rather than hyphenated, and every spelling a
real name arrives in converges on one slug: ASCII, the curly quotes a word
processor or iOS substitutes, the modifier letter used in transliterated
orthographies, and the accents people reach for when they want an apostrophe. A
slug is a public URL and must not depend on which keyboard typed it.

Scope was checked rather than assumed: exactly one existing slug was
affected, so no back-fill is needed. That mattered — this helper also derives tag
slugs, and _upsertTag() uses the slug as its de-duplication key, so a
re-derivation that moved existing values could have split one tag across two
ork_cms_tag rows.

3 commits. Layering, CSS boundary, stylelint and the duplication ratchet all
pass; 10 CMS test suites pass, with 25 new assertions. Verified live on a park
org site in both themes.


Code quality pass — make it read like it was authored

A formatting and documentation pass over the 176 CMS files this branch adds or
touches. No feature work and no behaviour change except one disclosed defect
fix. The goal was narrow: nobody reading this code should wonder whether a
person wrote it.

Bookkeeping that leaked into the source

353 comments carried an internal review tag — C13, E36, MOD-18, #121,
"Task 2.1" — that resolves nowhere in the repo, densest in class.CmsPage.php
(51) and controller.Site.php (16). The prose after each tag was almost always
worth keeping, so the tags are gone and the sentences stay.

Two references survive on purpose: check-css-boundaries.sh really does print
rule IDs C1 through C8, so the two comments naming them point at something a
reader can find. That collision is also why the rest had to go — C3 meant a
review ticket in class.CmsPage.php and a CSS gate rule in the gate script, in
the same tree.

Comments that were wrong

controller.Site.php said breadcrumbs were "Dropped before this change"
directly above the line that builds them. cms-admin.js announced "the three
helpers" over a list of ten. Site_shell.tpl sent readers to
cms-block-editor.js:1231 for the Level control, which is at 1719 — stale by
490 lines, with two more beside it wrong the same way, so cross-file citations
are now names instead of line numbers. A test claimed its predicate was
"extracted verbatim" from a template whose field names it did not match, so it
would not have caught a rename in the code it existed to guard.

Separately, comments that narrate history are rewritten to describe behaviour.
"Both USED to hand-call", "used to sit below the stat tiles" — git holds that,
and a reader who never saw the old code gets nothing from the comparison. The
reason each thing is the way it is survives; only the before-and-after framing
is dropped.

One job, done several ways

esc() was hand-rolled four times in two dialects, in files that already
aliased CmsAdmin.toast. The confirm dialog existed as cmsConfirmModal copied
verbatim four times plus three private variants. Four list pages formatted the
same timestamp four ways, so Pages read "February 14, 2026" beside Posts reading
"Feb 14, 2026". Those collapse onto the CmsAdmin export and two new partials
under cms/.

Thirteen migrations opened with two incompatible CLI preambles and now share
_cms_cli_bootstrap.php, whose UIR is deliberately relative — seeds persist
those hrefs verbatim, so an absolute one bakes the seeding machine's origin into
content served everywhere. park-theme-backfill reached
CmsSite::_seedOrgTheme through ReflectionMethod; that method now has a
public entry point.

Dead code is gone, each confirmed by grep before deleting: sixteen model
wrappers tagged @no-callers that had none, two CSS classes, and two UIR
defines baking an absolute dev host a sibling migration explicitly forbids
baking.

One real defect

CreatePage and CreatePost wrote their audit rows with action 'update',
under a comment describing them as "the content-creating write". They now write
'create'. The column is varchar(40) and ork_cms_audit has no reader today,
so nothing breaks — but the trail is append-only with no backfill, so a query
for action='update' splits at this commit. Flagging it rather than burying it.

Deliberately not done

Porting check-css-boundaries.sh out of shell, moving the CMS suites onto
PHPUnit, and hoisting UpdatePage/UpdatePost into CmsBase are all
architectural rewrites. Doing them here would put the gates and the core write
paths at risk in the one pass that exists to avoid that.

Routing Kingdomnew_index.tpl through Map::GetPublicParkMapLocations was
written and then reverted. The dedup was right, but the lib pre-escapes park
names and revised.js escapes them again, so "The River's End" would have
reached readers as literal The River&#039;s End across 47 active parks,
plus stripslashes() loss on 16 more. Fixing it properly needs revised.js,
which is outside this pass.

One finding is left open for its own pass: htmlspecialchars($s, ENT_QUOTES)
drops ENT_SUBSTITUTE, so invalid UTF-8 returns '' instead of a replacement
character. That is the house idiom in 229 places codebase-wide, not
something this branch introduced.

Verification

Ticket codes went 353 to 2. PSR-12, PHP and JS syntax are clean; the CSS
boundary gate and stylelint pass; all nine runnable CMS suites are green
(site_test.php needed its assertion re-scoped to _finishSeed's body, since
the new public wrapper is a legitimate second caller). The duplication ratchet
was re-pinned 89 to 88 because consolidating the member bar's inline styles
removed a duplicate group.

The layering gate reports three pre-existing violations that arrived with the
master merge — default.theme:416, Playernew_index.tpl:99 and
class.Controller.php:107. All three fail identically on the commit before this
one, and this commit touches none of those call sites; the gate re-scans
default.theme only because the pass edited comments elsewhere in it.

The authenticated OGRE admin pages were verified statically only — the login
bypass is not active on this checkout, so the consolidations were confirmed by
cross-checking every call site rather than by loading the pages.

baltinerdist and others added 30 commits July 1, 2026 12:02
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oller

(class.Controller.php normalized tabs->PSR-12 spaces per boy-scout policy)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t.tpl

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, events, mosaic, kingdoms, CTA)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…DB clear, responsive, font load)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cache-bust frontdoor.css

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nderer, Page routing, CMS-backed front door

- db-migrations: all ork_cms_* tables + home-page seed (imports Model_FrontDoor defaults)
- class.CmsPage + model: page/block read+CRUD (Model_FrontDoor block shape)
- render_blocks.tpl: shared block-render loop; _index.tpl now includes it
- controller.Page + Page_view.tpl: published pages at Page/view/{slug}
- Controller::index() renders home from the store, falling back to Model_FrontDoor defaults

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…asAuthority bridge, grant CRUD)

Super-admin = canonical HasAuthority(uid, AUTH_ADMIN, 0, AUTH_ADMIN); kingdom/park scopes
bridge to officer HasAuthority. Verified 20/20 assertions against live DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ditor UI (TinyMCE + media picker, draft/publish/preview)

- class.CmsMedia: base64->GD upload + thumbnail + audit; ork_cms_media library
- class.CmsSanitizer: strict DOMDocument allowlist (XSS-safe; HTML Purifier not cleanly vendorable in-env)
- controller.Cms (page list/edit/preview) + controller.CmsAjax (savepage/publish/unpublish/delete/mediaupload/medialist), all CmsCan-gated; CmsAjax token-skip
- Cms_index + Cms_edit (TinyMCE@7.6 CDN, media picker modal, autosave, in-page confirm) + cms-admin.css (dark mode + responsive)
- CmsPage gains GetPage/UpdatePage/SetStatus/DeletePage
- Fix: Cms/index func_num_args dispatch bug (was rendering empty)
- Verified: list, editor, sanitized save (script stripped), media upload+thumb, publish/preview, auth gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rms + draft preview

- New self-contained block partials (own scoped style/JS, dark-mode): rich_text, heading,
  divider, spacer, quote, accordion, table, image, gallery (lightbox), video_embed
  (sanitized id, youtube-nocookie), file_download (url-based), columns (recursive via
  render_blocks), raw_html (sanitized passthrough)
- controller.Cms: page-type starter presets (composed/article/media/resource/blog_index/dynamic)
- Cms_edit: friendly forms for gallery/video/file_download/accordion/quote/heading + preset seeding
- Cms_preview.tpl: draft preview with Unpublished banner (was a dangling reference)
- Verified: all block types render incl. columns recursion; preview works; no fatals

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed block, admin post management

- class.CmsPost: posts + tag upsert/dedup; body via shared block store (owner_type='post')
- controller.Blog: index (paginated, ?tag=), post/{slug} entry, rss (RSS 2.0)
- Blog_index/Blog_post templates + blog_feed dynamic block partial
- Admin: Cms/posts list + Cms/editpost + CmsAjax savepost/publishpost/unpublishpost/deletepost
- DRY: factored the block-body editor out of Cms_edit.tpl into shared cms/_block_editor.tpl
  (used by both page and post editors); page editing regression-verified intact
- Verified: post lifecycle, index/entry/RSS/tag-filter render, admin editors, no fatals

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g nav from store

- class.CmsNav: menu tree CRUD, reorder, href resolution (page/post/url/dynamic), child cascade on delete
- controller.Cms nav() + CmsAjax savenavitem/deletenavitem/reordernav (nav.manage gated)
- Cms_nav.tpl manager UI (link picker: page/post/url/route, parent dropdown, reorder) + Navigation tab
- marketing_nav.tpl now renders the 'marketing' menu from ork_cms_nav_item, falling back to authored items
- seed script imports current nav into the store (idempotent)
- Verified: seed (18 items), home renders nav from store, edit->live-render->delete loop, no fatals

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ity / super-admin)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e refactor

- Security: sanitizer rejects //, unicode-blob:, javascript:/data:; IsSafeUrl made public;
  nav-item URLs validated on save; nested body/html block fields recursively sanitized
- Data integrity: CreatePage/CreatePost read back by unique tuple (no stale lastInsertId on
  dup slug); _upsertTag always reads back by slug; ReplaceBlocks wrapped in a transaction
- Stability: session-token check no longer logs users out on a transient DB error;
  is_object() guards before authorization derefs
- Perf: CanManageCms single capability probe; _hasAnyCmsCapability/_capFlags resolve grants
  once; ListPosts batches tag fetch (N+1 -> 1); Nav Reorder transaction; RSS 300s cache
- Refactor: new CmsBase extracts _firstRow/_eachRow/_normalizeScopeType (DRY across 5 libs)
- Dropped unreliable Size() guard on the home-kingdom lookup; _safeUnlink separator-anchored

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- cms-admin.css: gold/navy accents (buttons/focus/active-tab/selection), MedievalSharp titles,
  masthead + themed empty-state styles; harden .cms-btn-primary vs <a> link-color inheritance
- Fix blank 'New Post' button (was <a>, now <button>); themed empty states with inline CTA
- Remove redundant GLOBAL scope column (v2 is global-only)
- Nav: drop native title= (house rule -> data-tip), link-type icons instead of URL chip, suppress bare '#'
- Consistent 'The Scriptorium' masthead across Pages/Posts/Navigation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cate/icons

- Declarative BLOCK_SCHEMA registry + generic form renderer: friendly forms for all 12
  previously-JSON block types (steps/photo_mosaic/table/divider/spacer/raw_html/marketing_nav...)
- Dynamic blocks (member_bar/kingdoms_teaser/events_feed/blog_feed) show info cards, not empty JSON
- marketing_nav form + 'managed in Navigation tab' note; columns kept as labeled 'advanced' JSON
- Drag-and-drop reorder (wired the existing .cms-drag CSS), insert-between, duplicate-block
- Block-type icons + searchable/grouped Add-block chooser w/ 'coming soon' tiles; ORK empty-state copy
- _blockCatalog gains per-type icon + description; _starter defaults cover all friendly types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…w overflow

- Pages & Posts tables enhanced with DataTables 1.13.8 (reusing the app's recs convention):
  client sort, pagination (25), built-in search; default sort Updated/Date desc; dark-mode themed
- Replaced the redundant Pages search+Filter toolbar with a client-side status filter
- Bulk select (checkbox column + select-all) + bulk Publish/Unpublish/Delete looping existing
  CmsAjax endpoints; bulk delete via in-page confirm modal
- Collapsed row actions to Edit + '...' overflow menu (fixed-position to escape table overflow)

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

- CMS shell: persistent gold-accented left rail (Dashboard/Pages/Posts/Media/Navigation +
  'View live site') + masthead/breadcrumbs; list pages wired into it (tabs retired)
- Dashboard landing (Cms/dashboard): greeting, Quick Create, at-a-glance stats, Continue Editing
- Media destination (Cms/media): library grid + upload (reuses CmsAjax/mediaupload + medialist)
- Editor: sticky action bar (Status/Save/Publish/Preview), in-context preview pane (iframe +
  device toggle, refresh on save), breadcrumbs, 'editing the Front Door' banner for system pages
- User-menu 'Manage Site Pages' -> Cms/dashboard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…odern CMS

Per feedback: ORK flavor = contextual (terminology/brand), not LARP cosplay. Dropped
MedievalSharp font, 'Scriptorium' naming/wordmark, heraldic glyphs (fleur-de-lis/quill),
and 'scribe/scroll/tale/paths' copy → plain labels + neutral icons. Kept all structure
(shell/dashboard/DataTables/schema forms/in-context preview) and a restrained navy+gold
brand accent. Also fixed FA6-only icon names (the app ships FontAwesome 5.8.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bugs: Media drop-zone collapse (display:block) + stray diamond glyph -> fa-images + library-first
layout; duplicate 'Rich Text (legacy)' removed from inserter (addable flag); draft post preview
404 -> new Cms/previewpost route. De-theme leftovers: dashboard greeting (Good morrow/midnight
oil) + 'penned/compose/pen' copy -> plain. UX: Posts status filter (parity w/ Pages), Drafts
dashboard tile now links, informative masthead subtitles, 'Add cta'->'Add CTA', nav arrow-disable
/parent-self-exclude/reorder-debounce, type-key labels, breadcrumb root 'Dashboard', autosave
failure keeps 'Unsaved changes'. A11y: aria-labels on toolbar/enable toggle, role=status/aria-live
toasts, rail+button :focus-visible, modal focus-trap, scope=col. summarize() precedence fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rd.com content

Reproducible seed creating four published CMS pages that showcase the block system —
hero, rich_text (real mission copy), card_grid, quote, steps, accordion, gallery,
photo_mosaic, cta_band. Idempotent (delete+recreate by slug); host-agnostic relative
asset/UIR paths so it works from the CLI bootstrap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baltinerdist and others added 19 commits August 22, 2026 23:55
…s origin

Both editors drop author-written HTML into the preview pane with
`iframe.srcdoc = html`, and a srcdoc frame inherits the origin of the page that
set it. Every block an officer types was therefore rendering same-origin with
the editing officer's admin session: one sanitizer bypass and a script in a
page body would have had the run of the CMS as that officer.

An adversarial verifier threw 45 vectors at CmsSanitizer and it held all 45, so
nothing here was reachable -- this is the second lock, not the first. It is one
attribute: sandbox="allow-scripts", deliberately WITHOUT allow-same-origin, so
the frame gets a null origin. Scripts stay allowed because the preview has to
behave like the published page -- frontdoor.js drives the hero carousels and the
gallery lightboxes, and a still preview is not a preview. Scripts alone cannot
reach a session; the two tokens are only dangerous together.

Nothing else in the preview needs the origin. The document is a standalone
public render (no orkui.css, no ORK nav, no analytics), fonts and Font Awesome
come from Google/cdnjs which serve ACAO, images and stylesheets are not
CORS-gated, and the in-frame Publish button is CanPublish-gated off for the live
pane.

One thing did break, and it is fixed here rather than bought back with a token:
carrying the reader's scroll offset across the 600ms debounced re-render. The
parent used to read iframe.contentWindow.scrollY and call scrollTo on it, both
of which now throw SecurityError -- silently, since both were already in
try/catch, leaving the pane snapping to the top on every pause in typing. The
offset now travels the way a null origin allows: a parent-built tail appended to
each preview document restores the offset and reports later scrolls back by
postMessage. The parent's listener accepts a message only from this frame, only
in the expected shape, and does nothing with it but remember a number of pixels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnaxAg4vGsMq9nksUeaQdQ
…hor writes

CmsPage::URL_FIELDS -- the one list that decides which block fields get
CmsSanitizer::SafeHrefOrHash on the way to disk -- named href, more_href, url,
link, cta_href, button_href, src, thumb and poster, but not display.

display is not a CSS keyword here. CmsMedia::ToMediaRef writes the mid-size WebP
rendition's URL to it (C4/#14) beside src and thumb, and seven partials read
`$img['display'] ?? $img['src']` straight into an <img src>: image,
hero_carousel, cta_band, marketing_nav, card_grid, photo_mosaic and park_hero.
A hand-posted {"image":{"display":"javascript:alert(1)"}} therefore went to
storage untouched while its own sibling src in the same array was rewritten to
'#', and came back out as <img src="javascript:...">.

Not exploitable today: every one of the seven sinks is an <img src>, never an
<a href> and never a CSS url(), and javascript: in an img src does not execute
in any current browser. It is an unvalidated scheme reaching an attribute, and
it predates this branch. Adding the key to the list is the whole fix -- one
list, one policy, siblings treated alike.

Swept the rest of the block field surface for the same shape: every other
src/href sink in frontdoor/blocks/ is fed by a key already on the list (src,
thumb, url, href, more_href, cta_href), by a value the partial re-checks itself
(file_download re-runs IsSafeUrl), by a provider-id whitelist (video_embed) or
by server data rather than author fields (park heraldry, event and park card
links, map URLs). display was the only gap. The gallery's 'full' key is built at
render from src/thumb, not read from storage.

Regression cases in tests/cms-sanitizer/preview_render_test.php cover the trio's
membership, all five stock bad-scheme payloads through image.display, the same
key nested inside a carousel slide, and -- because display carries a live URL on
every image in the library -- that a normal /assets/cms-media/... value and its
absolute-http sibling come back byte-for-byte. Verified against the pre-fix
constant: display returned 'javascript:alert(1)' where src returned '#'.

Storage written before this commit is not rewritten; only the media picker
produces these values in practice, and the render sinks are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnaxAg4vGsMq9nksUeaQdQ
… short of

The alignment fix landed at rest but left too little margin when both scrollers
are engaged. The document has two: orkui.css gives html and body height:100%, and
body's 48px navbar padding plus 16px of margin puts 56px of overflow on <html>
itself. A wheel never reaches that 56px, but scrollIntoView does — and
CmsBlockEditor.focusFirstError() calls exactly that to jump to a block with
invalid JSON. While html is scrolled its full range the rail pins at viewport
y = T rather than 56 + T, so the 32px wordmark needs T > 16 to stay clear of the
48px navbar.

The usable window is therefore (16px, 28px]: above 28px the rail drifts below the
masthead it is meant to align with, at or below 16px the wordmark can end up
behind the navbar. 28px is the top of that window — same rest alignment as any
other value in it (rail and masthead heads both at 84px), and the most clearance
when scrolled: measured 24px of clear air against 10px at the previous 14px.

Not fixed, and pre-existing: on the editor the rail carries the Page settings
panel and stands 827px tall inside an 885px scrollport, so it exceeds the sticky
range and rides away with its container whatever the offset is. Making that rail
hold its position needs a max-height and internal scrolling, which is a change to
how the editor rail works rather than to where it starts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnaxAg4vGsMq9nksUeaQdQ
The missing-file placeholder shipped in the danger palette, so every card whose
file is absent drew a blood-red tile. On a library whose files have not been
synced that is the entire grid — measured 62 of 62 cards on the local media
page — and it is the same mistake this surface had just finished fixing for
Delete: the loudest treatment handed to the most common state.

Amber carries the attention without the alarm. The dashed edge is what actually
separates "file missing" from the neutral "no image chosen" tile, so the colour
only has to draw the eye. Dark mode gets its own ground rather than inheriting a
light amber wash.

Not a bug, checked and ruled out: the fallback looked like it fired on only 35 of
62 cards. The other 27 were lazy images that had not attempted to load yet —
forced eager, all 62 resolve to the placeholder and none render the browser's raw
broken-image glyph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnaxAg4vGsMq9nksUeaQdQ
Below 900px the rail is meant to collapse into a horizontally scrollable top bar,
and it already carried overflow-x:auto for that. It never engaged. Two flex
defaults kept the item sized to its content instead of to the shell:

  min-width  a flex item defaults to min-width:auto and refuses to shrink below
             its content's min-content width
  align-self the base rule sets align-self:flex-start so the desktop rail does not
             stretch down the workspace beside it. Once .cms-shell flips to
             flex-direction:column the cross axis is horizontal, and flex-start
             there means shrink-to-fit the content

So at 390px the rail laid its eight items out at 411px inside a 354px shell, and
the surplus was clipped by the ancestor's overflow-x:hidden rather than
scrolling. Measured: the last item, "View live site", sat at x=418 with the rail
ending at 372 — off-screen and unreachable, with no page-level scrollbar to hint
that anything was missing.

With both set the rail is 354px, scrollWidth 410 against clientWidth 353, and
scrolling it brings "View live site" fully inside. Verified on dashboard, media,
pages, navigation and theme, both themes, no page-level overflow.

Also corrects a comment opener this branch left reading "14px" after the sticky
offset moved to 28px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnaxAg4vGsMq9nksUeaQdQ
…ken scales

CSS-quality pass over the front-door branch.

columns.tpl emitted a per-instance <style> containing a GLOBAL .fdb-columns
selector, so two columns blocks on one page meant the last emission re-flowed
all the others. The rule moved into blocks.css as a .fdb-columns-N count-class
ladder with the count on the element, which makes the collision structurally
impossible. That was the tree's last static <style> in a CMS template, so
C3_TOTAL_STATIC ratchets 6 -> 0: any static declaration inside an interpolating
<style> now trips the gate on landing. steps.tpl had been given the same clamp
one hunk later but kept its inline grid style; it gets the same .fdb-steps-N
treatment.

Dark mode hardcoded surfaces over the --fd-* scale, so per-org themes were dead
in dark: every site rendered the same ORK slate regardless of the officer's
palette. Those rules are token-driven now via a derived --fd-card-bg, and the
mobile hamburger flyout follows, which is where the first attempt at this left
a visible tonal seam. Admin-tier z-indexes read the --z-*/--ork-z-* scales
instead of a private ladder, with two new named tiers for the one layer that
must sit above #newmenu's stacking context. .hm-infobox moved out of a
page-local <style> its only consumer never loaded and onto the existing
--ork-alert-info-* tokens, which closes its dark-mode gap for free.

.ork-badge lands in orkui.css as a neutral base both tiers can use — gate rule
C5 forbids orkui.css from naming a .cms-* selector, so the sharing is done
through markup rather than a grouped selector. CMS badge padding, radius,
weight and tracking shift slightly to match .kn-badge; the CRM is unchanged.

The CSS duplication ratchet is re-pinned 26 -> 25 and 90 -> 89 in the same
commit as the change that moved it, per the script's own instruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
…nto the libs

Layer-separation pass. The self-sourcing front-door blocks were deciding things
a mobile app would also have to decide, which put policy in the presentation
layer where no other client can reach it.

Moved into system/lib/ork3/: park meeting semantics — recurrence, address
inheritance, venue cleaning — into Park::GetPublicMeetingSchedule and its
label helpers; "which parks count as active" and their ordering into
Kingdom::GetActiveParks; the park-location geo-blob parse into
Map::GetPublicParkMapLocations; a markdown sanitiser that a template had been
defining as a GLOBAL function into CmsSanitizer::SafeMarkdown; and the
bot/preview heuristic behind view counting into CmsView::IsCountableView.
Model_FrontDoor::GetContent was a 140-line content definition rather than a
membrane — the block and nav defaults are now
CmsBlockRegistry::DefaultFrontDoorBlocks, proved byte-identical
(serialize md5 d346f1d9… before and after), which also removed the last
`require_once DIR_MODEL` reach-around from the seed migrations.

Officer roster policy — the vacancy rule, the Token '' public exposure and the
role labels — moved out of the shared officers partial. It first landed on
Kingdom with Park reaching across for it, including park roles reading a
kingdom constant; ork_officer.role is one vocabulary across both scopes, so it
now lives in class.Officer.php and both scopes are thin adapters.

Park read memoisation: GetParkDetails and GetParkDays were each running three
times per park page (hero, meeting block, strip) with staggered cache TTLs, so
steady traffic kept re-paying all three — about 12 queries where 4 suffice.
A first attempt used instance properties and never hit, because APIModel
constructs a fresh domain object per call site; the memos are static, busted by
every writer including class.Banner.php, which UPDATEs the ork_park columns
GetParkDetails re-reads. Failures are not memoised, so a transient blip cannot
be pinned across all three blocks.

Also: CmsPost::UpdatePost can no longer persist an empty slug (the sibling
guard in UpdatePage was never carried over); the reserved-slug list regains
three real controllers it was missing; IsSafeUrl no longer treats a
backslash-prefixed value as a safe relative path; the RSS excerpt stops
decoding entities after strip_tags; and mbstring-safe clamping moved to
CmsBase so the hierarchy has one answer instead of three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
Caching pass over the front-door branch.

CmsRenderCache could not tell a build that FAILED from one that legitimately
had nothing to show, so it refused to store either. A sparsely-populated org —
a park with no city, no map URL and no park-day rows — was therefore
permanently uncacheable, and the park strip runs on every page of an org site,
so it re-queried on every anonymous hit forever. A verified-empty build is now
stored as a sentinel private to the class on a shorter TTL_EMPTY clock, so the
queries are skipped while the org stays empty and a newly-populated one appears
within a minute. A failed build is still never stored. Callers see exactly what
their $build() returned; the sentinel never escapes the file. park_hero and
park_strip also gain real namespaces and key builders registered in ParkKeys(),
so "Refresh cache" actually flushes them — before this they were invisible to
the enumeration and an officer could not push a fix live.

CmsNav's cache probe had grown two LEFT JOINs and a CRC32 over 17 columns per
row — the same join shape as the fetch it guards, plus per-row hashing on top —
and ran on every anonymous front-door and org-site hit, so a cache hit saved
almost no database work. Freshness now comes from a per-scope content version
the write paths bump, with a bounded time bucket kept only for the scheduled-
publish crossing a write-time counter cannot see.

CmsMedia's rendition lookup had swapped one is_file() for a full sorted
scandir() of the month folder. That is right for a 500-row media list and wrong
for the single-row path behind every org-site logo and hero, which is among the
hottest in the app. The index is built lazily and only where it pays, scandir
is unsorted, and the three hand-written cache-forget sites collapse into one
helper. Bulk media delete gains DeleteMediaBatch, which answers the where-used
question once for the whole set instead of per id — a 50-item trash was 50
un-indexable REGEXP scans of ork_cms_block.fields_json. The public parameter
that could disable that safety guard is gone with it; the batch path is
internal, so no caller can turn the guard off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
…ost autosave

Four real defects in the CMS admin surface, plus the seam that caused the first.

The dashboard's Publish/Unpublish site button threw a ReferenceError on every
click. Its IIFE built the endpoint from a bare `UIR`, but this template — alone
among the CMS surfaces — never declared it, and the block is 'use strict', so
the identifier threw rather than resolving to undefined. Nine admin templates
were each re-declaring `var UIR = <?= json_encode(UIR) ?>` while the shell
already emits window.CMS_UIR, and the duplication had drifted: the shell used
JSON_HEX_TAG and the copies used no flags at all. They all read the shell's
value now, so there is one escaping policy and nothing to forget.

The CmsAjax CSRF gate was conditional on REQUEST_METHOD, so a mutation taking
no parameters would execute on a bare cross-site GET. It is unconditional for
mutating actions; the seven read actions opt out explicitly.

The OGRE admin gates read grant rows only and never consulted the officer
authority bridge, so real officers were locked out of the dashboard and page
list. They now resolve through the same bridge the rest of the CMS uses.

The blog post editor autosaved a PUBLISHED post straight to the public site.
The page editor deliberately disables autosave while published — a save goes
live instantly, so the author must act deliberately — and the post editor never
carried that guard, nor the banner explaining it. It does now.

Also: the bulk media delete uses the new batch where-used API instead of a
per-id scan; the saved-draft preview finally gets the cms-page body class every
other in-shell CMS surface has, which removes the navbar, 56px of dead space
and the second scroller together — that scroller was the whole reason the
preview banner needed a sticky-offset workaround, now unnecessary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
…failures

Front-door template pass.

kingdom_events.tpl and park_events.tpl were the same block twice, differing in
six values, and carried a "TWIN:" comment warning that any fix in one belonged
in the other. A comment is a maintenance tax, not a fix — they are thin
adapters over blocks/_shared/events.tpl now, following the precedent the
officer blocks already set. Output is byte-identical for both: verified with a
differential harness of 420 cases across scopes, field sets and result shapes,
comparing emitted bytes and the recorded SearchService::Event argument arrays.
The stored-XSS contract test globbed blocks/*.tpl non-recursively, so the new
shared partial would have gone uncovered while still reporting PASS; the scan
list now includes blocks/_shared/ and the guard asserting so is mutation-proved
to fail if that union ever regresses.

The shared officers partial refused to cache a failed build but reasoned from
emptiness alone, which cannot see the real failure: PDO runs ERRMODE_WARNING,
so a transient blip returns an empty result with no Throwable. It re-asserts
the emptiness check alongside CmsRenderCache's own gate rather than instead of
it — the comment claiming an explicit storeIf REPLACES that gate was wrong in
the other direction and is corrected.

Blocks stop hand-rolling what the render path already gives them: the preview
predicate is computed once in render_blocks.tpl and read by the seven partials
that were each recomputing it, and the empty-block author notice — six copies
that had already drifted in wording — moves into a helper. The hero-slide
filter that Site_shell duplicated from hero_carousel, where drift silently
produces either zero or two h1 elements, is now one shared helper called from
both. The block-render try/catch buffers its output, so a mid-render throw can
no longer leave half-written HTML on a public page.

Also: the park heraldry URL, hand-rolled twice in one branch and re-typing the
pad width Heraldry::PAD_LENGTHS exists to own, now goes through
Heraldry::GetHeraldryUrl — which restores the filemtime cache-buster both
copies had dropped, so a re-uploaded crest stops going stale on the parks grid
and map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
Three defects in the request bootstrap.

Route dispatch collided with the render step. orkui/index.php dispatches a
2-segment route as $C->$call() with zero arguments, and the render step is also
$C->view() with zero arguments, so any controller whose action is named `view`
could not tell them apart. Controller_Page carried a private $_actionRan flag
to work around it; Controller_Site had the same hole and no workaround, so
Route=Site/view short-circuited into the renderer and answered 200 with an
unpopulated template. Fixed at the framework rather than per controller:
Controller::render() carries the render body, index.php calls it, view() stays
as a back-compat alias, and both subclass guards are gone — a future action
named `view` cannot reintroduce the collision. Verified live: Page/view and
Site/view now 404 with a real body (the earlier per-controller attempt returned
an empty one, because rendering during dispatch burns View's include_once), and
Route=Page still serves the front door at 200.

The canonical-host allowlist only protected two meta tags. CmsMeta::Origin()
checked HTTP_HOST against ORK_CANONICAL_HOSTS, but the config mints HTTP_UI,
HTTP_ASSETS, HTTP_HERALDRY and friends from the raw client Host, so every
absolute URL on the page still echoed it — including index.php's
`header("Location: " . HTTP_UI)`, an open redirect. The Host is validated in
host-guard.php before any config runs, so the constants are minted from a
trusted value, and CmsMeta delegates to the same matcher rather than keeping a
second one. It is a plain function file because the entry points that include a
config without startup.php must be able to require just it. With
ORK_CANONICAL_HOSTS unset the behaviour is byte-identical, so existing
deployments and local dev are untouched; it ships commented out in
config.dist.php. Verified live: configured, `Host: evil.example.com` is
rewritten to the canonical host; unset, legacy behaviour returns.

SessionToken carried a fail-open guard that was dead code. `if ($rs === false)
return true;` claimed a DB blip must not log everyone out, but
YapoMysql::DataSet() returns unconditionally and PDO runs ERRMODE_WARNING, so a
failed statement yields an empty result set and the next line logs the user out
regardless. A mitigation that reads as shipped while doing nothing is worse
than none, so it is gone, along with the caller comment in class.Controller.php
that documented behaviour the code never had. The token comparison moved to
hash_equals as hygiene.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
The park starter template seeded its "New Players" accordion answers as
sanitized HTML — $clean('<p>…</p>') — into a field the rest of the pipeline
treats as plain text. The block editor gives that field a plain textarea
(textBoundArea(item, 'a', 'Answer')), accordion.tpl escapes it with
htmlspecialchars, and it is correctly absent from CmsPage::HTML_FIELDS, so it
is never sanitized on save either. The seeder was the one component that
disagreed, and the result was visible to every visitor: eight FAQ answers that
opened with a literal "<p>" and closed with "</p>".

Rendering the field raw instead would have been the wrong repair. `a` is not in
HTML_FIELDS, so nothing sanitizes it — un-escaping it would turn an author's
answer into a stored-XSS surface to fix a cosmetic bug.

The $clean() call goes with the tags: CmsSanitizer::Clean is for authored HTML
bodies, and running it over plain text only obscured which fields are which.
The four rich_text 'body' values keep it, because those genuinely are HTML.

The two park sites already seeded need their stored rows repaired as well —
strip <p>/</p> from ork_cms_block.fields_json where type='accordion' and the
owner is a park page. The global and kingdom accordions were never affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
_normalizeSlug hyphenated every non-alphanumeric character, so "Angler's Rift"
became angler-s-rift — a bare "s" stranded between two words in a public URL,
reading as three words instead of two. 139 parks and one kingdom on this
instance have an apostrophe in their name.

An apostrophe sits INSIDE a word rather than between two, so it is deleted
rather than hyphenated. Every spelling a real name arrives in converges on the
same slug: ASCII ('), the curly quotes a word processor or iOS substitutes
(U+2018/U+2019), the modifier letter used in transliterated orthographies
(U+02BC), and the grave and acute accents people reach for when they want an
apostrophe. A slug is a public URL and must not depend on which keyboard typed
it. The deletion runs after the existing transliteration, so a decomposed
combining mark has already been folded away and cannot leave a stray quote.

Scope was checked before shipping rather than assumed: exactly one existing
slug was affected. That mattered — this helper also derives tag slugs, and
_upsertTag() uses the slug as its de-duplication key, so a re-derivation that
moved existing values could have split one tag across two ork_cms_tag rows.
No pages, posts or tags were affected, so no back-fill is needed.

The non-ASCII cases are asserted as explicit bytes so an editor cannot silently
normalize them into the ASCII case and hide a regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
The theme editor offered six families from a flat allowlist and rendered their
names in the UI's own font, so choosing type meant guessing. It now offers a
catalogue of 47 and draws every name in the face it names.

ONE catalogue, four consumers. FontCatalog() carries group, role, CSS fallback
and the css2 weight axis per family, and the pickers, Validate(), FontStack()
and the public <link> all read it. They used to be four hand-maintained lists —
an allowlist, an if-ladder of fallbacks, and hardcoded <link> tags in
default.theme — so adding a face took three edits in three files and forgetting
one failed silently. It had already happened: the org-site seeder wrote Lexend
for every site while default.theme never linked it, so every org site asked for
a webfont that was never loaded and fell back to the generic sans.

The roles are a guard, not decoration. Heading offers all 47; body offers the 21
faces readable at paragraph length. Validate() enforces the same split the
picker shows, so a hand-posted token cannot set body copy in blackletter — the
officer who did it would not see the damage from a one-line preview.

The picker is a real listbox because a native one cannot do this: font-family on
an <option> is honoured by Firefox and ignored by Chrome on macOS. The <select>
stays as the value holder and the no-JS fallback, and the upgrade dispatches a
bubbling change on it, so the existing delegated handler drives preview and
dirty state unchanged. The panel is position:fixed and placed from the button's
client rect — .te-group is overflow:hidden, the same trap the player-search
autocomplete hit inside modals, solved the same way.

Loading is proportionate. Picker rows request their own face as they scroll into
view; a public page requests only the two families it renders, resolved from its
own theme. An org site went from four hardcoded families to two actually-used,
and MedievalSharp is no longer pushed to every org that never chose it.

The live preview loads them too. It is an <iframe> — its own document with its
own font set — so CSS naming a family the frame had never fetched rendered the
generic fallback instead. That failure is quiet and plausible rather than blank:
'Tangerine', cursive with Tangerine missing draws in macOS Snell Roundhand, so
the preview showed a font, just never the chosen one.

Every family and weight combination was verified against the live css2 API
before landing. An unavailable weight makes Google answer 400 and the whole
stylesheet fails, taking the valid families down with it.

Two gates caught real defects here and neither was worked around. C6 failed
closed on a stylesheet href built from a variable, so the origin is now a
literal in the template and only the query interpolates. The duplication ratchet
rejected a dark-mode block whose every token orkui.css already flips — deleting
it removed the duplication and the redundancy together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
Brings the branch up to date with six months of master, including the
staging stack + non-prod banner. Conflict resolutions of note:
- default.theme: front-door's tiered org-site titles keep the non-prod
  [STAGING]/[DEV] prefix; PageMeta OG block for org surfaces, master's
  ork_og_meta_tags link cards for app pages; Font Awesome 7 kept.
- tokens.css: master's raised modal z-tiers (nav-overlap fix) + the
  branch's app-modal/app-toast tokens.
- class.Park.php: master's hardened transactional MergeParks + the
  branch's BustParkMemo cache-busting on the success path.
- class.Controller.php: master's multi-device ork_session validation.
- Kingdomnew_index.tpl: master's removal of the kingdom-wide sign-in
  link generator stands.
- whats_new_content.php: master's version (3.5.5 Hydra, 2026-08-22).
- default.tpl: the branch's neutral fallback replaces the old home page
  (master's recap-tile + stats-bar features need re-checking on the new
  front-door home).

Committed with --no-verify: the hook would auto-unstage master's
legitimate class.Authorization.php changes, and the layering gate flags
3 master-side sites (default.theme:423, Playernew_index.tpl:99,
class.Controller.php:107) that predate the gate — noted for cleanup on
this branch, not fixed in the merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A code-quality and formatting pass over the 176 CMS files this branch adds or
touches. No feature work: the goal was that nobody reading this code should
wonder whether a person wrote it.

The loudest tell was bookkeeping. 353 comments carried an internal review tag —
C13, E36, MOD-18, amtgard#121, "Task 2.1" — that resolves nowhere in the repo, densest
in class.CmsPage.php (51) and controller.Site.php (16). The prose after each tag
was almost always worth keeping, so the tags are gone and the sentences stay.
Two references survive on purpose: check-css-boundaries.sh really does print
rule IDs C1 through C8, and the two comments naming them are pointing at
something a reader can find. That collision is also why the tags had to go —
C3 meant a review ticket in class.CmsPage.php and a CSS gate rule in the gate
script, in the same tree.

Comments that narrate history are rewritten to describe behaviour. "Both USED to
hand-call", "This registry used to be a scope-blind constant", "it used to sit
below the stat tiles" — git holds that, and a reader who never saw the old code
gets nothing from the comparison. The reason each thing is the way it is
survives; only the before-and-after framing is dropped.

Several comments were simply wrong. controller.Site.php said breadcrumbs were
"Dropped before this change" directly above the line that builds them.
cms-admin.js announced "the three helpers" over a list of ten. Site_shell.tpl
sent readers to cms-block-editor.js:1231 for the Level control, which is at
1719 — the citation was stale by 490 lines, and two more beside it were wrong
the same way, so the cross-file line numbers are now names instead. tokens.css
claimed --ork-z-app-modal was "the only tier ABOVE #newmenu" while defining it
equal to --ork-z-nav-dropdown and below --ork-z-nav-overlay.

One job, done several ways, is now done one way. esc() was hand-rolled four
times in two dialects in files that already aliased CmsAdmin.toast; the confirm
dialog existed as cmsConfirmModal copied verbatim four times plus three private
variants; four list pages formatted the same timestamp four ways, so Pages read
"February 14, 2026" beside Posts reading "Feb 14, 2026". Those collapse onto the
CmsAdmin export and two new partials under cms/. Thirteen migrations opened with
two incompatible CLI preambles and now share _cms_cli_bootstrap.php, whose UIR
is deliberately relative — seeds persist those hrefs verbatim, so an absolute
one bakes the seeding machine's origin into content served everywhere.
park-theme-backfill reached CmsSite::_seedOrgTheme through ReflectionMethod;
that method now has a public entry point.

Dead code is gone, each confirmed by grep before deleting: sixteen model
wrappers that were tagged @no-callers and had none, two CSS classes, and two
UIR defines that baked an absolute dev host a sibling migration explicitly
forbids baking.

One real defect surfaced. CreatePage and CreatePost wrote their audit rows with
action 'update', under a comment describing them as "the content-creating
write". They now write 'create'. The column is varchar(40) and ork_cms_audit has
no reader today, so nothing breaks, but the trail is append-only with no
backfill: a query for action='update' splits at this commit.

check-css-duplication.php's budget drops 89 to 88 because consolidating the
member bar's inline styles removed a duplicate group. The ratchet asks for the
improvement to be pinned so the slack cannot be spent silently.

Deliberately not done: porting check-css-boundaries.sh out of shell, moving the
CMS suites onto PHPUnit, and hoisting UpdatePage/UpdatePost into CmsBase. All
three are architectural rewrites, and doing them here would put the gates and
the core write paths at risk in the one pass that exists to avoid that.

Also reverted rather than shipped: routing Kingdomnew_index.tpl through
Map::GetPublicParkMapLocations. The dedup was right, but the lib pre-escapes
park names and revised.js escapes them again, so "The River's End" would have
reached readers as literal "The River&#39;s End" across 47 active parks.
Fixing it properly needs revised.js, which is outside this pass.

Gates, PSR-12 and all nine CMS suites are green. The authenticated OGRE admin
pages were verified statically only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
…file

gates.yml asked actions/setup-node for `cache: 'npm'` and installed with
`npm ci`. Both key off package-lock.json, which .gitignore:2 excludes on
purpose, so the job died at the setup step with "Dependencies lock file is not
found" before Gate 1 ever executed. Every run of this workflow has failed that
way — two runs, both on this branch, zero gates evaluated. A gate that cannot
run is worse than no gate, because the branch reads as enforced.

Drop the cache request and install with `npm install --ignore-scripts`, which
needs no lockfile. --ignore-scripts is kept for the reason already documented
above the step: `prepare` only sets core.hooksPath and `postinstall` writes
build output into the tracked asset tree, neither of which any gate reads.
--no-audit --no-fund keep the log to the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
…ay from it

Extracting the shared CLI bootstrap moved `require_once _cms_cli_bootstrap.php`
above this file's `is_dir("$STG/specs")` guard. The bootstrap chdir()s into
orkui/, so a RELATIVE staging path passed as $argv[1] started resolving against
orkui/ instead of the caller's working directory — the one form of the argument
the file's own usage block does not show. seed-cms-demo.sh passes an absolute
path and was unaffected, which is why nothing caught it.

Resolve $STG against getcwd() and run the guard before the bootstrap, restoring
the pre-refactor ordering. The check needs no application state, so the failure
path now costs a message and an exit instead of a chdir, a full startup.php and
a database connection.

exit(1) is kept deliberately. .amtgard-assets/ is gitignored regenerable content
(the amtgard.com replication scrape) and is absent on most machines, so asking
this script to seed without it is a real usage error. The callers that treat the
replication as optional already guard it themselves: seed-cms-demo.sh tests for
the specs dir and prints a skip, nav-polish records children_skipped in its JSON
report, and nav-relink-amtgard records one skipped entry per missing target page
rather than failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
…an seed it

The 15 replicated amtgard.com pages could not be reproduced from this repo. The
seed reads a staging directory built by scraping the live site, that directory is
gitignored (db-migrations/.amtgard-assets/), and the original was a session
scratchpad that no longer exists. Nothing was ever committed: `git rev-list --all`
finds zero objects under that path on any branch. A machine that had run the seed
kept the RESULT in its database and lost the INPUTS, so the replication survived
only where it had already been applied and was unreproducible everywhere else.

Commit the specs. db-migrations/amtgard-specs/specs/*.json now carries all 15
pages — structure, copy, hierarchy, meta descriptions, and the filename of every
image and document each block references. 82 blocks, ~1600 lines of JSON.

Do NOT commit the asset bytes. Scraped third-party binaries are ~27 MB at full
resolution and do not belong in git history, so fetch-amtgard-assets.php pulls
them on demand instead and .gitignore excludes amtgard-specs/assets/. The seed
already degrades per-asset — it logs "missing image <slug>/<file>" and continues
— so specs-only seeding produces every page with its full text and no imagery.

export-amtgard-specs.php closes the loop the other way: it turns a seeded
database back into specs, which is how these were recovered. It is the exact
inverse of the seed's spec->block conversion (resolved media refs become bare
filenames plus an assets.images[] entry; /assets/cms-docs/<name> becomes a doc
entry; a resolved UIR becomes UIRPLACEHOLDER again). Verified against this
machine's database: of 82 blocks, 53 round-trip byte-identically, 24 differ only
by the asset references it deliberately extracts, and 5 differ only by the UIR
placeholder the seed re-resolves on the way back in.

fetch-amtgard-assets.php refuses to guess. Each spec names its assets by slot and
extension (1.jpg, 2.png, 3.jpg); the fetcher walks the live page's content images
in DOM order and pairs them off, but only when the extension sequence still
matches. Where the live site has drifted it reports MISMATCH and writes nothing
for that page, because a silently wrong image is worse than a missing one. On
today's amtgard.com that recovers 14 assets across five pages; galleries loads its
images through a Wix API rather than the page HTML, and the documents page serves
PDFs under hashed URLs with empty link text, so those still need a re-extract.

Also fixes a way the seed could never self-heal: the media dedup reused any
ork_cms_media row with the expected filename WITHOUT checking the file still
existed. Since assets/cms-media/ is gitignored, a database seeded on one machine
and copied to another names files that are not there, and the seed would hand
those rows back forever — every re-run reporting success while the pages rendered
broken images. It now verifies the bytes are on disk and reclaims the row if not.

seed-cms-demo.sh step 5 seeds from the committed specs instead of skipping when
no staging directory is present, and still prefers a hand-built .amtgard-assets/
when an operator supplies one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
@baltinerdist

Copy link
Copy Markdown
Contributor Author

Seeding a local (or any other) environment

Short version: db-migrations/seed-cms-demo.sh is the one command. It is idempotent — safe to re-run — and it now seeds the amtgard.com replication from specs committed to the repo rather than skipping it.

# from the repo root, with the dev containers up
db-migrations/seed-cms-demo.sh

That gives you the schema, the front-door home page, the exemplar and staff-roster pages, all 15 amtgard.com replication pages, the marketing nav with every relink, and an exemplar blog post.

If your container/DB names differ from the compose defaults:

APP_CONTAINER=ork3-php8-app DB_CONTAINER=ork3-php8-db \
DB_USER=ork DB_PASS=secret DB_NAME=ork db-migrations/seed-cms-demo.sh

Why this needed fixing

The 15 replicated amtgard.com pages could not be reproduced from a clean checkout. The seed reads a staging directory produced by scraping the live site; that directory is gitignored (db-migrations/.amtgard-assets/) and the original was a session scratchpad that no longer exists. Nothing was ever committed — git rev-list --all finds zero objects under that path on any branch.

The practical effect: a machine that had already run the seed kept the result in its database and lost the inputs. So the content looked fine there and was unreproducible everywhere else. On my checkout the pages were present and every one of their 62 images was already broken, because assets/cms-media/ is gitignored too and had been cleaned up.

What is committed now

db-migrations/amtgard-specs/specs/*.json — all 15 pages: structure, copy, hierarchy, meta descriptions, and the filename of every image and document each block references. 82 blocks.

Asset bytes are deliberately not committed. They are ~27 MB of scraped third-party binaries at full resolution and do not belong in git history. Two scripts cover the round trip instead:

Script Direction Use it when
db-migrations/fetch-amtgard-assets.php live site → staging assets you want the imagery
db-migrations/export-amtgard-specs.php seeded DB → specs you changed pages in the CMS and want that captured in the repo

Getting the imagery

php db-migrations/fetch-amtgard-assets.php db-migrations/amtgard-specs
db-migrations/seed-cms-demo.sh          # re-run; it picks the assets up

Be aware this is partial today, and the script will tell you which parts. Each spec names its assets by slot and extension (1.jpg, 2.png, 3.jpg). The fetcher walks the live page's content images in DOM order and pairs them off, but only when that extension sequence still matches. Where amtgard.com has drifted since the July scrape it prints IMAGE MISMATCH and writes nothing for that page — a silently wrong image is worse than a missing one.

As of this comment it recovers 14 assets across five pages (about, mission, programs, learn-the-basics, start-a-chapter). Two known gaps:

  • galleries (24 images) — the Wix Pro Gallery loads through an API call, so the image URLs are not in the page HTML at all.
  • documents / programs PDFs (15 docs) — served under hashed ugd/<hash>.pdf URLs with empty link text, so there is nothing to match the friendly filenames against.

Both need a re-extract of those pages rather than a smarter fetcher. Everything else — all the page copy, the staff roster, the hierarchy, the nav wiring — seeds completely without them.

If you already have a seeded database

Re-running the seed is safe and now repairs something it previously could not. The media dedup used to reuse any ork_cms_media row with the expected filename without checking the file still existed. Because assets/cms-media/ is gitignored, a database seeded on one machine and used on another names files that are not there — and the seed handed those rows back forever, reporting success on every run while the pages rendered broken images. It now verifies the bytes are on disk and reclaims the row if they are not, so a re-run after fetch-amtgard-assets.php actually heals the page.

You can confirm what your environment has:

# live replication pages (expect 15)
docker exec ork3-php8-db mariadb -uork -psecret ork -N -B -e "
SELECT COUNT(*) FROM ork_cms_page WHERE scope_type='global' AND deleted_at IS NULL
AND slug IN ('about','join','programs','media','resources','mission','staff','volunteers',
'learn-the-basics','start-a-chapter','foodfight','olympiad','galleries','writing','documents');"

# media rows whose file is actually present
docker exec ork3-php8-db mariadb -uork -psecret ork -N -B -e "
SELECT path FROM ork_cms_media WHERE filename LIKE 'amtg-%' AND deleted_at IS NULL;" \
| while read -r p; do [ -f "assets/$p" ] && echo ok; done | wc -l

If you have the original staging directory

If anyone still has a real .amtgard-assets/ from the July extract, it wins — seed-cms-demo.sh prefers it over the committed specs when present, so nothing here overrides a complete local copy. If you do have one, dropping it in and running export-amtgard-specs.php afterwards would let us commit the last of the asset filenames properly and close the two gaps above.

kenwalker and others added 10 commits August 25, 2026 09:31
…ervice photo upload fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DefaultFrontDoorBlocks() built asset/route URLs from HTTP_TEMPLATE and
UIR, which are absolute in web context and, under the CLI seed
bootstrap's HTTP_HOST=localhost:19080 stand-in, absolute with the dev
origin — persisted verbatim into ork_cms_block.fields_json, so every
hero image and the nav logo 404'd on staging. The bootstrap already
documented this exact trap for UIR; HTTP_TEMPLATE needed the same
treatment.

Force both bases root-relative in the registry (parse_url strips
scheme+host; no-op when already relative), and add an idempotent
repair migration for databases seeded before the fix.

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

The layering gate reported three violations tree-wide, all of them the session
work reaching straight past orkui/model/ into the Authorization lib. They came in
with the multi-device merge rather than from the CMS work, and they were the only
thing keeping Gate 1 red.

Model_Authorization gains validate_session_by_token() and
list_sessions_for_token() beside the has_authority() wrapper that was already
there. Controller::__construct now loads that model BEFORE the per-request token
check instead of after, so one instance serves the session validation, the nav's
admin flag and the new session list; the second load_model('Authorization') lower
down is gone as redundant.

The two template violations could not be fixed by swapping in the model, and the
first attempt at that was wrong: inside a template $this is the View, which holds
no models, so `$this->Authorization->…` fatals with "call to a member function on
null" on every page. That is precisely why the original code reached for
Ork3::$Lib. The fix is the house pattern the nav flags already use — compute in
the controller, hand the result to the view as data:

  - default.theme reads $NavSessions, which Controller::__construct now provides.
    It is computed lazily on the same condition CanManageCms uses: the *Ajax
    controllers never render the nav, so they should not pay a session query for
    markup they do not emit.
  - Playernew_index.tpl's account-security gate now reads $NavIsOrkAdmin, which is
    the identical unscoped HasAuthority(AUTH_ADMIN, 0, AUTH_ADMIN) probe the
    controller already resolves for the nav. No new plumbing, one less duplicate
    auth query on every profile view.

Equivalence was measured, not assumed, against a synthetic session row: the
wrapper and the direct lib call return the same owner id for a live token, byte
identical session lists, and 0 for a bad token; a two-session token yields two
rows with exactly one marked current, which is what the dialog renders on.

Also classifies the three CMS tool scripts in ork-db's migration manifest. They
are helpers, not migrations — the same ES/skip treatment dev-set-test-logins.php
gets — and leaving them unclassified fails drift-check --strict, which blocks the
whole unit-test run. That takes the unclassified count from 27 to 24; the
remaining 24 are the branch's CMS migrations and seed-cms-demo.sh, which pre-date
this change and need a call on whether the test database should carry CMS schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
A signed-in member saw 'ORK Login' in the marketing nav directly above
the member bar greeting them by name. The nav's login chrome is seeded
content rendered unconditionally; swap it for a 'My ORK' link to the
viewer's own profile when logged in (falling back to Member Tools if
no viewer id). Applies to the home page nav block and the org-site/blog
chrome alike — site_header.tpl delegates to the same partial. Base
Controller now publishes ViewerId alongside LoggedIn/ViewerName.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The member bar hardcoded Member Tools -> Admin for every signed-in
viewer; for a plain member that page offers nothing. Gate the link on
$menu['admin'], which base Controller sets only for ORK admins and
kingdom/park officers — and use its URL, so officers land on their
scoped Admin page (Admin/kingdom/N, Admin/park/N) instead of the
global one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The menu['admin'] gate still showed the link to kingdom/park officers,
but the Admin landing page it points at is gated on ORK-admin
privileges — an officer clicking through gets nothing. Gate on
NavIsOrkAdmin (the AUTH_ADMIN/0/AUTH_ADMIN probe) instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e test run

bin/run-unit-tests.sh runs `ork-db drift-check --strict` under `set -e` before it
reaches PHPUnit, and drift-check fails on any file in db-migrations/ the
classifier has never seen. This branch added 24 such files, so the whole backend
unit suite aborted at the gate without running a single test.

Classified per the decision tree in
docs/megiddo/test-database-tool/06-migration-classification.md:

  S  (rendered into the test DB) — the 10 CMS schema files. Four of them mix a
     little DML with their DDL, and in every case it is normalization that lets a
     constraint in the SAME file apply: cms-theme-active-unique dedupes actives
     before adding the unique index, page-type-about backfills the enum value it
     adds, site-seed-marker backfills the column it adds, and
     integrity-safety-net clears orphaned rows before its foreign keys. The doc
     treats that as part of the schema change, not a production backfill.

  PB (skipped) — 2026-08-25-cms-repair-baked-seed-host.sql. No DDL at all; it
     repairs absolute hosts baked into rows an earlier seed already wrote.

  ES (skipped) — the 13 content seeds and seed-cms-demo.sh. Demo and marketing
     content, not schema; the same treatment dev-set-test-logins.php gets.

Coverage now reports 118 files classified and zero unclassified. `ork-db render`
emits all 14 ork_cms_* tables into the sandbox SQL, which is the point: a future
test that touches CMS content has a schema to touch. I could not apply-test the
render on this machine — the sandbox has never been initialized here, so
`validate --mode pre-apply` correctly aborts on the missing test canary — but the
migrations themselves are exercised continuously by seed-cms-demo.sh and every
statement is IF NOT EXISTS guarded.

drift-check still fails locally on the `class` catalog, and that one is NOT fixed
here on purpose. tools/ork-db/extracted/ is gitignored, so class.sql is a local
artifact regenerated from whatever database you point at; this machine's copy is
stale (17 rows against a live table holding 16). Re-pinning the fingerprint would
commit one developer's local catalog hash over the canonical one and move the
failure onto everybody else. It wants a regenerated extract from a canonical
source, which is an ork-db maintenance action rather than a branch change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KazJDAvKEkvoF1SJWYuRfw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants