Skip to content

Enhancement: Project Front Door#486

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

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

baltinerdist and others added 30 commits July 1, 2026 12:02
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…oller

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…, events, mosaic, kingdoms, CTA)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…DB clear, responsive, font load)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…cache-bust frontdoor.css

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…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) <[email protected]>
…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) <[email protected]>
…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 ([email protected] 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) <[email protected]>
…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) <[email protected]>
…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) <[email protected]>
…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) <[email protected]>
…ity / super-admin)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…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) <[email protected]>
- 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) <[email protected]>
…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) <[email protected]>
…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) <[email protected]>
…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) <[email protected]>
…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) <[email protected]>
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) <[email protected]>
…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) <[email protected]>
baltinerdist and others added 10 commits July 1, 2026 12:03
…CSS tokenization

The front-door CSS tokenization aliased --navy→--fd-primary and --ink→--fd-text.
The dark baseline overrides those tokens, silently changing .fd-dropdown background
and .fd-nav-toggle color in dark mode. Add two html[data-theme="dark"] rules that
restore the exact original computed values (#0b1120 and #1a2236) — zero visual change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Replace exact-match hardcoded hex colors with fd-* CSS custom property
tokens so block partials track the active theme. Zero visual change on
an unthemed page (token defaults == original literals). 18 replacements
across 11 partials:

- #fff as contrast text on navy bands → var(--fd-primary-contrast)
  (cta_band, photo_mosaic, kingdoms_teaser, member_bar, steps)
- background:#fff as panel/card bg → var(--fd-bg)
  (richtext, rich_text, events_feed, blog_feed, accordion, file_download)
- color:#1a2236 as body text → var(--fd-text)
  (blog_feed .bf-card-title, file_download .fdb-file-title)

Partials with no exact-match colors (f7f8fb, #667, decorative, etc.)
left untouched per conservative tokenization rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… override

The frontdoor.css [style*="background:#fff"] !important dark rule handles these
section wrappers; switching the inline to var(--fd-bg) silently dropped that cover
(dark bg shifted #1a2236 -> #0e1626). Revert just the 4 inline backgrounds; the
--fd-primary-contrast and CSS-class-rule tokenizations from Task 10 stay.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…/activate/reset

- Iframe preview: debounced 150ms fetch to CmsAjax/previewtheme; injects
  returned CSS at end of iframe <body> (wins frontdoor.css cascade); first
  preview fires on iframe load event
- Duplicate control sync: collectTokens() keys by token name (deduped);
  handleControlChange mirrors value to all [data-token=same] controls
  (main ↔ advanced) and all [data-hex-for=same] hex display fields
- Light/dark preview toggle: sets data-theme on iframe documentElement so
  the injected CSS dark block takes effect
- Inline contrast warnings: WCAG luminance/contrast formula ported to JS;
  .te-contrast-warn-inline chip appended near offending text-color control
  when ratio < 4.5; bar-level summary also wired
- Save: POST CmsAjax/savetheme (name=Default, tokens=JSON); stashes theme_id
- Apply to site: save then POST CmsAjax/activatetheme; toast "Theme applied
  to your site."
- Reset: tnConfirm modal (never native confirm()); POST CmsAjax/resettheme;
  reloads page to repopulate controls from factory defaults
- Added .te-contrast-warn-inline CSS with dark-mode override

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…, dark placeholder

- Remove window.THEME_CATALOG / THEME_FONTS / THEME_VALUES (never read by the IIFE)
- Drop unused $idSuffix parameter from $renderControl closure + all 4 call sites
- Add html[data-theme="dark"] .te-color-hex::placeholder rule to cms-admin.css

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
baltinerdist and others added 15 commits July 1, 2026 17:03
Design for per-kingdom public sites at /k/{slug} on the existing
scope-ready CMS: new ork_cms_site table, Controller_Site public router,
standalone chrome, scope-aware admin, three org-scoped dynamic blocks,
starter template + publish lifecycle. Kingdom-first, park-ready.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
New ork_cms_site table (one publishable, addressable website per org,
scope kingdom|park), class.CmsSite.php lifecycle + slug derive/validate
(charset, reserved-route blocklist, uniqueness) + idempotent EnsureSite,
thin model pass-through, and a standalone unit suite (40 assertions).

Migration idempotent + non-destructive; verified applied locally.
EnsureSite refuses unresolved (<=0) scope; UpdateSite hyphenates typed
slugs via DeriveSlug rather than silently stripping.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Controller_Site renders a kingdom's published pages/posts/blog by slug
through the shared block renderer inside standalone org chrome
(default.theme parameterized with $IsOrgSite — own header/logo/nav/footer,
subtle ORK tie-back, no global nav/member-bar/editor-FAB). Per-org theme
tokens injected from the site's own scope. Raw Site/view|page|blog|post
routes work today; nginx /k/{slug} pretty layer added (belt-and-suspenders).

Scope is always read from the resolved site row; page/post lookups pass
scope + publishedOnly. Lifecycle-safe error states: unknown slug AND
unbuilt sites both render a BARE 404 (no identity leak); draft → branded
coming-soon (no page bodies); missing sub-page → in-chrome 404.

Review fixes: escape <title> sink (site_name stored-XSS); unbuilt 404 no
longer leaks org name/logo/nav; empty "being built" home set no_index;
wired page/post meta_description into the (now escaped) description meta.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Thread a validated per-org scope through the existing global CMS admin
(additive; no-scope = byte-for-byte the old global front-door behavior).

- trait.CmsScope: parses ?scope=k:{id}/p:{id}, RE-VALIDATES server-side via
  HasAuthority(AUTH_EDIT) — client value is a selector, never authority;
  malformed/unauthorized → deny (no silent downgrade). _rowInScope IDOR
  predicate shared by both controllers.
- Cms + CmsAjax: every action/endpoint resolves scope, gates via
  CmsCan($uid,$cap,$scope), scope-filters lists, seeds new rows with scope.
  IDOR guard (_requireOwned) on every by-id mutation — an officer of one org
  cannot edit/publish/delete another org's page/post/media/nav.
- Publish gate: page.publish + site publish require AUTH_ADMIN (monarch/
  regent); publishsite/unpublishsite reject global scope. AUTH_EDIT officers
  get a clear locked state.
- "Manage Public Site" entry from the kingdom hero (AUTH_EDIT) → EnsureSite +
  scoped dashboard; context banner "Editing: {Org} — public site". Scope
  rides admin JS fetches; CSRF untouched.

Review fixes: IDOR-guard nav link target page_id/post_id (+ scope-bound nav
JOINs) to close cross-org title/slug disclosure; scope-check hero_media_id
on save; align public org_header nav to the 'marketing' menu the admin
writes (was 'site' → nav rendered empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Three new addable dynamic blocks that render live ORK data for the page's
org, mirroring the self-sourcing blog_feed pattern:
- kingdom_officers — current officers (persona/office) via Kingdom.GetOfficers
  (public Token='' path suppresses real names); pairs with authored
  staff_roster for Board of Directors.
- kingdom_parks — active parks via Kingdom.GetParks, deep-linked.
- kingdom_events — upcoming kingdom events via SearchService.

Each derives kingdom_id solely from render-time scope ($SiteNavScope*) set
by Controller_Site; non-kingdom/global context early-returns (renders
nothing, no kingdom_id=0 junk); graceful empty states; all ORK output
htmlspecialchars-escaped; dark-mode + responsive; FA5 icons. Registered in
the block catalog, allow-list, starter presets, and editor schema.

Review fix: officers avatar shows the shield icon by default and reveals
heraldry only onload, eliminating the guaranteed-404 broken-image flash
(GetHeraldryUrl has no has-heraldry signal).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
…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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
…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) <[email protected]>
@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
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