Skip to content

Enhancement: Project Front Door#486

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

Enhancement: Project Front Door#486
baltinerdist wants to merge 106 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.

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 10 commits July 1, 2026 19:02
EnsureSite now seeds a full starter site on FIRST creation only (guarded
behind the idempotency early-return + CreatePage UNIQUE self-guard + nav
ListItems count): Home (welcome + intro + kingdom_events, is_system) plus
About/History, Our Parks (kingdom_parks), Officers (kingdom_officers +
staff_roster), Documents (file_download), a scoped 'marketing' nav menu
linking them, and home_page_id → the seeded home. All draft, in the site's
own scope, fully editable — a seed, not a cage. Block field shapes verified
against each renderer partial.

Discovery: Directory prefetches one published-site slug map
(PublishedSlugMapByScope — single query, no N+1) and shows a "Visit site"
link (data-tip, FA5, dark-mode) on each kingdom/principality with a
published site.

Review fix: seed a welcome rich_text instead of hero_carousel (that block
bakes in a global stats ticker — cut per spec — and emitted an empty-src
img). Updated CmsSite unit queue for the post-seed re-read; 40 assertions
pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Found by seeding + publishing a real kingdom site and rendering /k/{slug}:

- UpdateSite silently failed when saving slug alongside other fields:
  ValidateSlug() calls $DB->Clear() internally, wiping the site_name bind
  set earlier, so the UPDATE ran with an unbound :site_name placeholder and
  the whole statement failed (name + slug both lost). Now gather all binds
  locally and apply them immediately before Execute — no intervening Clear.
- Seed pages were status='draft', so a freshly PUBLISHED site showed
  "being built" with dead nav links (public renderer only shows published
  pages). Seed pages published (+ published_at); the site-level status is
  the real go-live gate, nothing is public until the SITE is published.
- Seeded About page used type='about', which isn't in the ork_cms_page
  ENUM → truncated to ''. Use 'article'.
- Org site returned HTTP 500: default.theme's Era Phoenice popover calls
  EraPhoenice::format($_epToday), but Controller_Site doesn't set $_epToday.
  Gate the popover (global-only chrome) on empty($IsOrgSite).

Verified: published site renders home + nav + tie-back (200); draft →
coming-soon with no content leak; unknown slug → 404; Directory shows the
"Visit site" link. CmsSite unit suite (40 assertions) passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CMS admin built PUBLIC links for the global front door only, so a
kingdom-scoped page linked to Page/view/{slug} (a global lookup) → 404.
Fix the live-URL generation to target the org site:
- Pages list per-row link → Site/view/{siteSlug} (home) or
  Site/page/{siteSlug}/{pageSlug} (others), built in the controller
  (_pageLiveHref) and passed as each row's live_href.
- "View live site" (dashboard card + rail) → the org site home
  (Site/view/{siteSlug}) via SiteLiveUrl, set in _applyScopeData.
Global scope keeps the legacy Page/view route unchanged. Site row is
looked up once per request and memoized.

Verified: kingdom-scoped pages now generate Site/... URLs that all resolve
200; the old global Page/view/{slug} was the source of the 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An officer building a site could not see it: Controller_Site 404'd every
page on an unpublished (unbuilt/draft) site, even for the owner, so the
admin's live links led to "Page not found" and the seeded content looked
missing (it wasn't — the pages are published and full of blocks; the SITE
just wasn't published yet).

Now an authorized officer (HasAuthority AUTH_EDIT over the site's scope,
super-admins included) PREVIEWS their unpublished site — the render path
runs, draft pages/posts included, with a "Draft preview — not published"
banner and no_index. The PUBLIC still gets the gated states (unbuilt →
bare 404, draft → coming soon); unauthenticated requests are unchanged.

Verified: preview gate opens for an admin over the org; public gate still
404s unauthenticated; starter pages confirmed to carry blocks and render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Authorized officers (AUTH_EDIT over the org) now get the CMS edit FAB on
their org site — on published pages AND unpublished/draft previews alike —
linking into the SCOPED CMS admin (Cms/edit|editpost|posts + &scope=k:{id}),
so edits land in the org's own content. Home/page → edit FAB; post → edit +
new-post FAB; blog index → manage + new-post FAB. Reuses the same
HasAuthority gate as preview (memoized); the public sees no FAB (verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kingdom_parks block:
- "Sort order" option — park name (A–Z), city→name, or state→city→name.
- "Display heraldry" toggle — park crest on each card (has_heraldry-gated,
  shield fallback), plus the park's rank/title badge (Barony/Shire/…) for
  richer, more dynamic cards.

New kingdom_parks_map block:
- Interactive Google map of the kingdom's active parks with a click-to-open
  detail sidebar (heraldry, name, city/state, directions, description,
  profile link) — the Amtgard Atlas map/sidebar pattern, scoped to one
  kingdom via Map::GetParkLocations. Self-sourcing, instance-safe (unique id
  + one-time Maps loader/callback queue), fitBounds to the kingdom's parks,
  Parsedown-safe directions/description, dark-mode + responsive.
- Registered in the block catalog, allow-lists (composed/about/dynamic),
  starter defaults, CmsAjax type allowlist, and editor schema.

Starter template: Our Parks page now seeds heading → parks map → parks list
(map above list), list defaulting to city sort + heraldry on.

Verified in-browser: list renders with crests, rank badges, and city sort in
dark mode. The map matches the Atlas exactly; tiles are blank only on
localhost (the Google key is referer-restricted to prod — the Atlas is blank
locally too) and will render in production. Fix: fall back to the Atlas key
when GOOGLE_MAPS_API_KEY is defined-but-empty.

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

- Add-block chooser now uses a wide modal with responsive 3-column tiles
  (auto-fill minmax) — far less vertical scrolling.
- Each block tile shows its one-line description (already in the catalog) so
  officers know what a block is without guessing.
- Block groups are collapsible: the section header is a button with a
  rotating caret + a count badge; collapsed state persists across re-renders
  and is ignored while searching (matches stay visible).
- Moved Member Bar from Layout to the Dynamic group (it renders live data).

Verified in-browser in the scoped editor: 3 columns, descriptions, collapse,
and Member Bar under Dynamic — dark-mode clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New super-admin-only Cms/sites page: a cross-org directory of every started
site. Amtgard International (the global front door) is pinned at the top
(always live; Manage → global CMS dashboard, Visit → site root), then a
Kingdoms section and a Parks section list each org's site with its status
badge (Published/Draft/Unbuilt), published/total page + post counts, and
last-updated.

- Manage → the org's scoped CMS dashboard; Visit → its public /k|/p site.
- Publish/Unpublish inline via the existing CSRF-protected CmsAjax endpoints
  (super-admin passes their scope re-validation; badge + button toggle live).
- "New site" provisioning: pick a kingdom (name-sorted; ones with a site
  flagged) → opens its scoped dashboard, which auto-creates + seeds it.
- Hard gate: CmsAuth::IsSuperAdmin (all-zero AUTH_ADMIN) bounces non-supers
  via _denyRedirect BEFORE any data loads — the cross-org list can't leak to
  a tenant officer. Rail "All sites" entry shown only when caps.super.

New CmsSite::ListAllSites (one query: scope_type-gated kingdom/park name
join, collation-safe COALESCE, per-scope correlated page/post counts) +
GlobalPageCounts for the front-door card; thin model pass-throughs.

Verified in-browser as super-admin: overview renders, publish round-trip
toggles Unbuilt→Published live, dark-mode clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-lens review pass over the CMS module. Fixes:
- IsSafeUrl: percent-decode before the deny/scheme checks so encoded schemes
  (%6aavascript:) and encoded controls (java%09script:) can't bypass the URL
  guard (stored/DOM XSS). Tested against a 17-case battery.
- kingdom_parks_map: htmlspecialchars the park name before JSON→innerHTML.
- CmsMedia::ListMedia: distinct :search placeholders (a reused named param
  binds only once under emulated prepares → search matched filename only).
- DeletePage: read-back + ROLLBACK before COMMIT (was committing
  unconditionally → block-less zombie page on a silent DELETE failure).
- DeletePost: wrap the three DELETEs in a transaction + read-back rollback.
- DeletePage/DeletePost: opt-in scope-ownership guard (mirrors
  CmsNav::DeleteItem), threaded from the delete controllers — defense in depth.
- CmsAuth::RevokeRole: fail-closed authorization (was opt-in/bypassable),
  matching GrantRole.
- kingdom_officers: guard the Heraldry constructor (uncaught throw 500'd page).
- render_blocks: try/catch each block include so one bad block can't blank
  the whole page.
- rich_text.tpl: collapse to a thin alias include of richtext.tpl (dedup).

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

The org-site render fataled inside default.theme's logged-in-only global
chrome (the What's New + email-prompt modals, which use base-Controller data
$DB/session/release constants that Controller_Site never sets), truncating
the page mid-render BEFORE the editor FAB block. So a logged-in officer saw
no edit FAB on their site (unauthenticated visitors were unaffected — that
chrome is gated on the session token).

Gate that global chrome on empty($IsOrgSite) — the same fix already applied
to the Era Phoenice popover — so the org-site render reaches the FAB. The
global front door / CMS pages are unaffected (IsOrgSite unset → chrome still
renders). Verified in-browser: FAB now shows on the org home and opens the
scoped editor; the global front door still renders fully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@baltinerdist
baltinerdist deleted the feature/front-door branch July 7, 2026 23:34
@baltinerdist
baltinerdist restored the feature/front-door branch July 7, 2026 23:53
@baltinerdist baltinerdist reopened this Jul 7, 2026
baltinerdist and others added 16 commits July 8, 2026 08:26
Addresses the findings from the 11-expert CMS review across five sharded
waves (file-disjoint lanes, zero write-collisions). 46 files + 2 migrations.

Security & data integrity
- Sanitize at the authoritative choke point (CmsPage::ReplaceBlocks), not
  write-path-only; correct the misleading "sanitized at render" comments.
- Image decompression-bomb guard (reject >40MP before GD decode).
- InnoDB foreign keys + reference cleanup on delete; soft-delete (deleted_at)
  with Trash/Restore for pages/posts/media; append-only audit log; revisions
  with restore; optimistic-concurrency guard (base_version) on block saves;
  stable block-id upsert; concurrent same-slug create no longer clobbers.

Product & content
- Real scheduled publishing (published_at gate + lazy promote).
- Page hierarchy (parent_id) with nested paths + breadcrumbs.
- Reserved page-slug guard + 301 redirect table on slug rename.
- /p/ park namespace distinct from /k/ (canonicalizing 301).
- Per-page canonical + Open Graph (per-kingdom branding, hero og:image).
- Alt-text authoring end-to-end (+ decorative option); PII consent on
  staff roster; neutral byline fallback; author reassignment on revoke.
- 2-level nav; validated tag picker; permission page instead of login bounce.

Front-end & a11y
- Dark-mode section bands fixed (inline #fff removed); single-h1 outline;
  keyboard focus rings; carousel pause/play + reduced-motion; gallery
  focus-trap; surgical block-editor DOM ops (no TinyMCE teardown on reorder);
  loud/recoverable invalid-JSON errors; runtime theme re-skin.

Performance
- Theme-CSS cache; kingdom_officers N+1 (heraldry) eliminated; per-block
  inline-CSS dedupe for heaviest blocks; Google Maps per-env key + lazy-load
  with graceful fallback.

Deploy notes
- Run both migrations in filename order (integrity-safety-net, then
  routing-hierarchy) with the MariaDB client; CMS tables must be InnoDB.
- Set a per-env GOOGLE_MAPS_API_KEY or the parks map shows its fallback.

Known follow-ups (tracked, non-blocking): full block-fragment/nginx cache,
gallery inline-CSS dedupe, vendored TinyMCE self-host bundle, editor-engine
JS extraction, posts permanent-purge endpoint, Page/view canonical/OG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShyA7SCTysgREoQPBoxs1Q
Applies the high-value findings from a 58-reviewer multi-lens polish of the
CMS module (implementer→critic verified; block-save guard E2E-tested).

Security
- Close IDOR on CmsAjax restoremedia/purgemedia/mediaupdate (scope-ownership
  guards) + CmsSite.logo_media_id cross-scope check + lib-level guards on
  CmsMedia mutations (defense in depth).
- Scope the block-editor persona search (was leaking banned/inactive players
  cross-org); validate kingdoms_teaser more_href via IsSafeUrl (javascript:
  URI) and RecordRedirect target (open redirect); add a CLI-only guard to all
  web-reachable db-migrations/*.php seed scripts.

Stability
- update_page/update_post now _fail on a false save instead of reporting
  success; UpdatePage slug-rename does a dup pre-check + write-verify (no bogus
  301 on a silently-dropped rename); ReplaceBlocks detects a silently-dropped
  per-block UPDATE and rolls back; SetTags/SetActive/media-upload read back to
  verify the write landed.

Quality / perf / a11y
- Memoize CmsCan's officer scope-bridge; de-duplicate list-row mapping and
  block-type catalog; GetPageAncestors one round-trip per level; org-header nav
  ARIA state; steps responsive breakpoint; assorted dead-code and doc fixes.

Deferred findings (design decisions / larger refactors) are tracked in
docs/TICKETS-cms-polish.md — notably the soft-delete vs slug-uniqueness
regression, the CmsPage/CmsPost + editor-template de-duplication, and the
public-render N+1 / fragment-cache work.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShyA7SCTysgREoQPBoxs1Q
…ndering raw URL)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShyA7SCTysgREoQPBoxs1Q
…ewrite (idempotency)

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShyA7SCTysgREoQPBoxs1Q
…ff monograms

- Fix dead home hero/CTA '#' links (model.FrontDoor + home-relink migration → Atlas/CMS pages)
- Nav: relabel 'AI Programs'→'Programs', add Official Resources dropdown children (nav-polish migration)
- Wayfinding: breadcrumbs (Page_view + Controller_Page::view GetPageAncestors) + IsCmsPage brand-font flag
- Staff roster: initials monogram avatars instead of gray silhouettes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShyA7SCTysgREoQPBoxs1Q
…nd bands, external links

- richtext: constrained body always centered (fixes text pinned to left ~45%)
- card_grid: empty/'#'-href cards render non-link 'Coming soon' (no dead links); scrim/contrast
- hero_carousel: suppress stat ticker on non-home pages (no 0/0/0)
- staff roster CSS: monogram avatars, equal heights, centered solo card
- decouple --navy from dark-mode --fd-primary (brand bands stay navy, not periwinkle)
- external-link arrow + left-aligned body lists; mobile member-bar hidden
- active-nav state; breadcrumb styles; MedievalSharp loads on CMS pages

Carries related pre-existing front-door WIP in these files per repo owner direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShyA7SCTysgREoQPBoxs1Q
Unlinked roster cards (board/team leads/autocrat) open a self-contained,
brand-styled dialog showing the person's avatar, name, role, and the FULL
(untruncated) bio. Keyboard-accessible (Enter/Space open; ESC/backdrop/x close;
focus return + trap), no native dialogs. Single shared modal per page (global
guard) populated from the clicked card's data-* attributes. Linked cards
(member profiles) keep navigating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShyA7SCTysgREoQPBoxs1Q
Accumulated uncommitted CMS/front-door implementation across the branch:
blog + RSS, media, view analytics (CmsView), multisite/site routing,
sanitizer, nav, page/post libs, front-door block templates, CMS admin
shell/editor, migrations, docs, and nginx rewrites.

Excludes (intentionally NOT committed): system/lib/ork3/class.Authorization.php
(carries a local dev auth-bypass hack), scratchpad/ (working files), and
tests/scroll/ (unrelated scroll-generator snapshots).

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

Applies the problem-kind findings from a multi-lens distributed review of the
CMS/front-door platform, verified by an adversarial critic pass per shard.

Highlights:
- Persistence integrity: block-save verify compares all columns + order-insensitive
  JSON; zero-row/trashed UpdatePage/UpdatePost now fail; dup-guard distinguishes
  collision vs dropped write; RestoreRevision hard-fails on corrupt snapshot.
- Routing: slug/parent redirects + prefix fallback, post-rename 301s, leaf-only
  publish gating, cycle/cross-scope parent rejection, transliterated slugs.
- Caching/perf: GhettoCache for home page+blocks, site-by-slug, gated _promoteScheduled,
  in-memory admin path map (no N+1), clamped/cached teaser.
- Multi-site scope: fail-closed scope normalization, global grants keyed at 0,
  RevokeRole capability probe, scoped personlookup, nav resolves only live targets.
- Security: sanitizer size/depth budget, columns recursion depth cap, media
  where-used scan incl. embedded imgs, copyDoc MIME allowlist, CSRF on preview publish.
- UX/a11y: carousel slide inerting, dark-mode fixes, keyboard park-map list,
  consistent staff cards, sticky editbar offset, autosave-live warning.
- Migrations: relative UIR seeds, full demo chain, guarded FK adds, idempotent
  content seeds, hard-required sanitizer, new about-type enum migration.
- Model wrappers forward actor/publishedAt/scope; content audit + meta correctness.

Deferred (own focused passes): CmsAjax pure/wrapper split, tpl->lib orchestration
move, full draft workspace (min-viable autosave guard shipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gFvamqXW6vPKMx4mQiL5D
Implements 24 selected enhancement findings from the distributed review,
verified by an adversarial critic pass per shard + passing test suites.

- Front-door perf/a11y: content-visibility on block sections, block surfaces
  routed through --fd-* theme tokens (zero visual change), dark-mode .fd-path,
  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 constants, 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, deferred per-view analytics write off the render critical path,
  clearrendercache + runmaintenance actions, capability-named denials.
- Editors: shared cms-admin.js (toast/modal/CSRF-post, one source), view counts
  on Pages/Posts + top-content dashboard panel, window.CMS_CAPS, no raw type slug.
- Multi-site: single grant-driven edit-FAB helper (Page/Blog/Site), sitemap.xml.
- Tests: CmsSanitizer XSS regression, CmsThemeTokens math, block-field allowlist
  contract (all passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015gFvamqXW6vPKMx4mQiL5D
Mobile-review and a11y remediation across the front-door blocks, the CMS
admin shell, and the shared theme chrome.

Mobile layout
- CTA rows (cta_band, hero_carousel) wrap via flex/gap instead of
  margin-left on the nth button, so buttons stack instead of overflowing.
- steps grid steps 4-col -> 2-col at 760px -> 1-col at 480px.
- Path grid collapses to one column at 520px; heading block font sizes
  step down at 680px.
- Carousel dots + play/pause lift above the 2x2 stat ticker on phones.
- Mosaic tiles get a uniform 200px height (!important beats the inline
  height:100%) with object-fit:cover.
- Member bar actually hides on mobile (!important beats inline display:flex).
- Long org names ellipsize in the org header instead of shoving the
  hamburger off-screen.
- raw_html / rich-text tables and <pre> scroll inside their own container
  rather than forcing horizontal page scroll.
- CMS nav-manager rows wrap so action buttons keep a 40px touch target;
  block-editor icon buttons enlarge at 680px.
- Recovery-email input goes to 16px to stop iOS zoom-on-focus.

Contrast (WCAG AA)
- #b8860b (2.9:1 on white) darkened to #8a6608 / #8a5d00 / #7a5c00 across
  kickers, nav hover/active, officer roles, blog cards and the RSS link.
- Muted greys #8a93ab / #8a97ad lifted to #5a648a / #5b6675 in
  file_download and kingdom_parks_map, with dark-mode overrides.
- Dark mode added for the recovery-email modal and the parks-map empty
  states; --ork-warn defined globally so Cms_media's "In use" label is no
  longer an undefined token.

Accessibility
- Icon-only controls get aria-label + aria-hidden on the glyph across
  Cms_edit / Cms_index / Cms_nav / Cms_media and the theme nav.
- Search toggle becomes a real <button>; maintenance banner becomes a
  button with aria-expanded.
- Focus trap + focus restore for CMS admin modals; background inert for
  the staff-roster contact modal.
- Media tiles are keyboard-operable (role=button, tabindex, Enter/Space)
  with a focus-visible ring.
- Kingdom profile tabs get role=tablist/tab/tabpanel + aria-selected;
  title= swapped for the project data-tip pattern.
- Rail and recent-action labels are visually hidden rather than
  display:none, keeping the accessible name.
- Directory section titles promoted from <span> to <h2> (with the global
  heading-pill reset).
- Scrollable tables get tabindex=0 + role=region so keyboard users can
  reach them; decorative nav carets marked aria-hidden.

Other
- Carousel supports touch swipe (passive listeners, horizontal-only).
- CMS/blog pages suppress the stacked app nav via body.cms-page.
- Page_view breadcrumb "Home" uses UIR instead of a hardcoded path.
- photo_mosaic surfaces an empty-state hint in the CMS preview.
- events_feed date and the render_blocks failure placeholder now use the
  themed .fd-ev-date / .fd-block-error classes instead of inline styles,
  so their dark-mode overrides actually apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019PrAgxszhNEo5yiR1urCw6
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.

1 participant