Enhancement: Project Front Door - #486
Conversation
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>
…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'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
Seeding a local (or any other) environmentShort version: # from the repo root, with the dev containers up
db-migrations/seed-cms-demo.shThat 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.shWhy this needed fixingThe 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 ( 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 What is committed now
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:
Getting the imageryphp db-migrations/fetch-amtgard-assets.php db-migrations/amtgard-specs
db-migrations/seed-cms-demo.sh # re-run; it picks the assets upBe aware this is partial today, and the script will tell you which parts. Each spec names its assets by slot and extension ( As of this comment it recovers 14 assets across five pages (about, mission, programs, learn-the-basics, start-a-chapter). Two known gaps:
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 databaseRe-running the seed is safe and now repairs something it previously could not. The media dedup used to reuse any 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 -lIf you have the original staging directoryIf anyone still has a real |
…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
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
Directory/index(linked from the nav).Model_FrontDoor→frontdoor/render_blocks.tpl→frontdoor/blocks/{type}.tpl).CMS (v2)
Cms/index): page & post management, drag-orderable block editor with TinyMCE, media library + image picker, draft / publish / preview / autosave.CmsCan()with a bridge to the existingHasAuthorityso kingdom/park officers gain rights when scoped pages arrive. A gated "Manage Site Pages" entry appears in the user drop-down.scope_type/scope_id— global now; kingdom/park drop-in later with no migration).Data / migrations
ork_cms_*tables (db-migrations/2026-06-23-cms-foundation.sql) + idempotent seed scripts (home page, marketing nav).assets/cms-media/dir (runtime uploads gitignored;.gitkeeptracked).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:
lastInsertIdguard via read-back;ReplaceBlocks/Reordertransactions).CmsBaserefactor 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 referencevar(--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.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=selector (re-checked server-side viaHasAuthority— 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.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.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:
IsSafeUrlpercent-encode XSS bypass closed (%6aavascript:/java%09script:, tested), park-name escaping in the map block, media-search placeholder fix,DeletePage/DeletePostatomicity (read-back + rollback) + opt-in scope-ownership guards,RevokeRolefail-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.
UpdatePage/UpdatePostnow fail loudly; the dup-slug guard distinguishes a real collision from a dropped write;RestoreRevisionhard-fails on a corrupt snapshot._promoteScheduled, an in-memory admin path map (kills an N+1), clamped and cached teaser.RevokeRolecapability probe, scoped person lookup, nav that resolves only live targets.columns, media where-used scan that includes embedded<img>,copyDocMIME allowlist, CSRF on preview-publish.Distributed review — 24 enhancements
content-visibilityon 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.CmsPage::HTML_FIELDS/URL_FIELDS, content-mutation audit trail,PurgeTrashed+ orphan-block sweep + unused-tag prune, block-only saves bump ownerupdated_at/updated_by, DB-enforced one-active-theme-per-scope.clearrendercache+runmaintenanceactions.cms-admin.js(one source for toast / modal / CSRF-post), view counts on Pages & Posts plus a top-content dashboard panel,window.CMS_CAPS.sitemap.xml.Mobile & accessibility remediation
A mobile-review pass over the front-door blocks, the CMS admin shell, and the shared theme chrome.
margin-lefton 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 withobject-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.#b8860b(2.9:1 on white) darkened across kickers, nav hover/active, officer roles, blog cards and the RSS link; muted greys lifted infile_downloadand the parks map. Dark mode added for the recovery-email modal and the parks-map empty states;--ork-warndefined globally so the media library's "In use" label is no longer an undefined token.aria-labelwitharia-hiddenon the glyph; the search toggle becomes a real<button>and the maintenance banner anaria-expandedbutton; focus trap + focus restore on CMS admin modals and backgroundinerton the staff-roster modal; media tiles are keyboard-operable with a focus ring; kingdom-profile tabs getrole=tablist/tab/tabpanel; icon-rail labels are visually hidden rather thandisplay:noneso the accessible name survives; Directory section titles promoted to<h2>; scrollable tables gettabindex=0+role=region.Page_viewbreadcrumb usesUIRinstead of a hardcoded path;photo_mosaicsurfaces 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 andork_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.
CmsSiteinferred "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 explicittemplate_seeded_atmarker plus a separated repair path. The schema probe guarding that column usedtry/catch, which is unreachable under PDOERRMODE_WARNING— nowSHOW COLUMNS._normalizeSlugusediconv('ASCII//TRANSLIT'), whose output is libc- and locale-dependent: the same org name derivedcreconomin the container andcr-economon 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::_slugifycarried 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 twoork_cms_tagrows; now routed through the shared helper.mediabulkdeleteaccepted ids without filtering them to the caller's scope; addedFilterOwnedIds(int-cast into theINlist, never the no-op escape shim) with a cap that fails closed. Media reference counts ignoreddeleted_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.America/Chicagoclock against a UTC database, keeping live ancestors redacted for ~5h after publish.is_liveis now computed in SQL.innerHTMLon a detached div, and a detached<img onerror>still fires in Chrome; replaced with a DOM-freestripHtml()that decodes entities in a single pass so&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 (Æ,É) it existed for.updated_at DESClist 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}. Newpark_meeting,park_officersandpark_eventsblocks, 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 isAUTH_PARK.CmsSite::OrgUnitNoun()returns Kingdom / Principality / Park so copy stops hard-coding "kingdom"; principalities need no schema change, beingork_kingdomrows with aparent_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 theCmsAjaxsave-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$PageMetaliteral 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 themarketing_navDB row that the live home page actually renders, so a PHP-only edit would have changed nothing — hencedb-migrations/2026-08-09-cms-nav-ork-login-label.php(idempotent, leaves deliberately-renamed labels alone, reads each write back sinceExecute()is void underERRMODE_WARNING). Also trimmed ~48px of dead clearance reserved for#newmenu, which the front door hides.Tests
New
tests/cms-tenancycovers the multi-tenant boundary inCmsMedia— scope filtering, storage metering, upload quota — code three review passes had read but nothing had ever executed.site_test.phpwas 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=DEVserved a blank 200 at the document root — theHTTP_UIredirect 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 couldreach 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	script:and a foreign<iframe>all stripped, benign markupintact, the three anchors keeping their text and losing their
hrefentirely.Six findings, all fixed in this branch
got
kingdom_events,kingdom_officers,kingdom_parksandkingdom_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 (includingpark_meeting, previously seeded nowhere), drops theparks page, and runs its copy through
OrgUnitNoun().park_meetingprinted a literal0as the venue name (medium).alternate_locationis a tinyint flag read as a name. The first fix made it worse — reading
locationinstead 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.
Site_shellalready promotes the title tothe page
<h1>, so the leading heading block rendered it twice.rich_text/cta_bandpainted blank full-width bands — now silent forvisitors, italic author hint in preview, matching the existing hero/mosaic pattern.
Route=Cms/editwith 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.
license_key: 'gpl'.db-migrations/2026-08-10-cms-park-seed-repair.phprepairs park sites already seededwith the kingdom template. Conservative by design: it retypes
kingdom_officers/kingdom_eventsto theirpark_*counterparts in place, disables rather than deletesthe 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-sitegains 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.mdTwo measurements decided most of the structure
Taken against a production-shaped restore of 342 active parks:
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
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).
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 —.jpgdevicescover-crop so their own background becomes the plate,
.pngdevices float matted. Theframe 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.
to
ork_cms_theme, so no two of the 342 look alike. Lightness is forced to 0.22specifically 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.
attendance claim, and never text that reads as instructions to the officer.
Bugs fixed along the way
and role to be empty; a vacancy has a role. 187 of 342 parks showed at least one.
MedievalSharpwas the default heading font and the seeder never created a themerow, so every org site shipped in a faux-medieval face.
hard-coded literals, so a wine-red park still got Bootstrap-blue links.
a park, whose most common visit is a member checking the time in a parking lot.
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.
_heraldryPath()built%05dfor both scopes, but kingdom files are pad-4 (0007.jpg). The probe nevermatched, 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_LENGTHSconstant, with a regression test..fd-kicker's font-size was invalid CSS.--fd-font-scaleis a length (1rem), socalc(var(--fd-font-scale, 1) * 0.6875rem)isrem × rem— dropped silently on everyfront-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_herohad no block-editor schema and so was uneditable; and the closing CTA band hadno "come to a park day" button — a URL-less park rendered zero buttons.
Honesty about seeded content
_parkIntroBody()inherits the park's ownork_park.description, but 123 of the 246parks 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.phpmust run after this lands. Any orgsite 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.tplsupportsplaceholder_image, but nothing seeds it and it is unreachablefrom 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 loadscms-base.css+ the public set +orgsite.css— andzero bytes of
orkui.css,tokens.cssorcms-admin.css. In-shell CMSsurfaces keep the CRM pair plus
orkshell-interop.css, the one designatedcoupling point. OGRE admin renders inside the shell so it may read
--ork-*(350 times) but never define one.
bin/check-css-boundaries.shholds the line with rules C0–C7 over 147 files.The scope is derived, not listed: everything under
frontdoor/is public tierand everything under
cms/is admin tier at any depth; CMS page templates arefound from the controllers carrying the
CmsScopeContexttrait; and a$TPL_ROOTtemplate that renders CMS chrome is classified by resolving itsincludes 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 gotzero rules and could link
orkui.cssat exit 0.The gate was then hardened against nine proven evasions, including a staged
symlink (
git show :pathreturns the link target, one short line that passeseverything), CSS identifier escapes, attribute-selector spellings of the same
coupling, and
<style>tags built by string concatenation in PHP. CMS PHP andJS are in scope too: a verifier put a stylesheet link and a
<style>onto alive org-site page from two directions —
frontdoor.jsviainsertAdjacentHTML, andcontroller.Site.phpechoing markup — and both gatesreturned exit 0. That blind set was 31 files.
Static CSS is now pinned tree-wide at
C3_TOTAL_STATIC=0, so any staticdeclaration inside an interpolating
<style>trips on landing, and aduplication ratchet (
MAX_GROUPS_2PLUS = 25,MAX_GROUPS_ANY = 89) fails inboth 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.csslinks only on the two surfaces thatemit 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.cssis 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.mdcarries 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 pagename 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 guardcomments and_rowInScope/_requireOwnedat each one.Defects that were real:
click — its
'use strict'IIFE read a bareUIRthis one template neverdeclared. Nine templates were each re-declaring it while the shell already
emits
window.CMS_UIR, with three different escaping policies between them.REQUEST_METHOD, so a mutationtaking no parameters executed on a bare cross-site GET.
locking real officers out of the dashboard and page list.
the page editor's deliberate published-guard was never carried over.
Route=Site/viewanswered200 with an unpopulated template. Fixed at the framework:
Controller::render()now carries the render body, and both per-controller workarounds are gone.
every absolute URL from the raw client
Host— includingindex.php'sheader("Location: " . HTTP_UI), an open redirect. The Host is validated beforeany config runs; unset, behaviour is byte-identical.
SessionToken's fail-open guard was dead code:YapoMysql::DataSet()returnsunconditionally and PDO runs
ERRMODE_WARNING, so the blip it claimed to absorbstill 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/GetParkDaysthreetimes each with staggered TTLs (~12 queries where 4 suffice); bulk media delete ran
an un-indexable
REGEXPscan ofork_cms_block.fields_jsonper id; the nav cacheprobe 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
APIModelconstructs a freshdomain 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
Hostwhen configured, thepreview renders
<body class="cms-page">with the navbar gone, and org sitesserve 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 css2weight axis per family, and the pickers,
Validate(),FontStack()and thepublic
<link>all read it. Those were four hand-maintained lists — anallowlist, an if-ladder of fallbacks, and hardcoded
<link>tags indefault.theme— so adding a face took three edits in three files andforgetting one failed silently. It already had: the org-site seeder wrote
Lexend for every site while
default.themenever linked it, so every org siteasked 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 splitthe 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-familyon an<option>is honoured by Firefox and ignored by Chrome onmacOS. The
<select>stays as the value holder and the no-JS fallback, and theupgrade dispatches a bubbling
changeon it so the existing delegated handlerdrives preview and dirty state unchanged. The panel is
position: fixedandplaced from the button's client rect —
.te-groupisoverflow: hidden, thesame 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.cssalready flips; deleting it removed the duplication andthe 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 templateseeded 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.tplescapes it, and it is correctly absent from
CmsPage::HTML_FIELDS. The seederwas the one component that disagreed, and eight answers opened with a literal
<p>. Rendering the field raw would have been the wrong repair: nothingsanitizes 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', cursivewith Tangerine missing renders inmacOS Snell Roundhand, so the preview showed a font, just never the picked one.
Apostrophes in public URLs
_normalizeSlughyphenated every non-alphanumeric, so "Angler's Rift" becameangler-s-rift— a bare "s" stranded between two words. 139 parks and onekingdom 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 are-derivation that moved existing values could have split one tag across two
ork_cms_tagrows.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 alwaysworth keeping, so the tags are gone and the sentences stay.
Two references survive on purpose:
check-css-boundaries.shreally does printrule 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 —
C3meant areview ticket in
class.CmsPage.phpand a CSS gate rule in the gate script, inthe same tree.
Comments that were wrong
controller.Site.phpsaid breadcrumbs were "Dropped before this change"directly above the line that builds them.
cms-admin.jsannounced "the threehelpers" over a list of ten.
Site_shell.tplsent readers tocms-block-editor.js:1231for the Level control, which is at 1719 — stale by490 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 alreadyaliased
CmsAdmin.toast. The confirm dialog existed ascmsConfirmModalcopiedverbatim 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
CmsAdminexport and two new partialsunder
cms/.Thirteen migrations opened with two incompatible CLI preambles and now share
_cms_cli_bootstrap.php, whoseUIRis deliberately relative — seeds persistthose hrefs verbatim, so an absolute one bakes the seeding machine's origin into
content served everywhere.
park-theme-backfillreachedCmsSite::_seedOrgThemethroughReflectionMethod; that method now has apublic entry point.
Dead code is gone, each confirmed by grep before deleting: sixteen model
wrappers tagged
@no-callersthat had none, two CSS classes, and twoUIRdefines baking an absolute dev host a sibling migration explicitly forbids
baking.
One real defect
CreatePageandCreatePostwrote their audit rows with action'update',under a comment describing them as "the content-creating write". They now write
'create'. The column isvarchar(40)andork_cms_audithas 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.shout of shell, moving the CMS suites ontoPHPUnit, and hoisting
UpdatePage/UpdatePostintoCmsBaseare allarchitectural 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.tplthroughMap::GetPublicParkMapLocationswaswritten and then reverted. The dedup was right, but the lib pre-escapes park
names and
revised.jsescapes them again, so "The River's End" would havereached readers as literal
The River's Endacross 47 active parks,plus
stripslashes()loss on 16 more. Fixing it properly needsrevised.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 replacementcharacter. 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.phpneeded its assertion re-scoped to_finishSeed's body, sincethe 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:99andclass.Controller.php:107. All three fail identically on the commit before thisone, and this commit touches none of those call sites; the gate re-scans
default.themeonly 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.