Merge pull request 'feat(modules): PublicLayout takes a shell, MODULE_API_VERSION 1.5.0' (#148) from fix/public-layout-shell into edge

Reviewed-on: #148
This commit is contained in:
2026-08-12 19:26:42 +00:00
6 changed files with 138 additions and 10 deletions

View File

@@ -1,12 +1,36 @@
import SiteHeader from './SiteHeader.jsx' import SiteHeader from './SiteHeader.jsx'
import SiteFooter from './SiteFooter.jsx' import SiteFooter from './SiteFooter.jsx'
import { shellClass } from '../lib/pageShell.js'
// Standard page chrome for the public site + wiki. // Standard page chrome for the public site + wiki.
export default function PublicLayout({ section = 'website', header = true, children }) { //
// ── `shell` — added in MODULE_API_VERSION 1.5.0 ────────────────────────────
//
// This component supplies the chrome and NOT the body: every core public page
// wraps its own content in `<div className="shell-… page-body">`, which is what
// centres it in a max-width column, gives it its top and bottom padding, and —
// through `page-body { flex: 1 }` — pushes the footer to the bottom of the
// viewport. Nine of nine core pages do it, so the omission has never shown.
//
// A module page cannot: it is handed `PublicLayout` through the UI kit
// (MODULE_API.md §3.4) and has no way to learn about two class names that appear
// in no contract. The Integration Kit's acceptance run built a module exactly as
// the kit teaches and it rendered full-bleed at x=0 with the footer riding up
// under the content — the precise failure §3.4 says the kit exists to prevent
// ("a module page that does not look like the site it is installed in").
//
// So the wrapper moves behind the component a module already has. `shell` is
// OPT-IN and omitting it is exactly today's behaviour, which is why core's own
// nine pages are untouched by this change — they keep their own wrapper, and a
// page wanting an unusual body still writes its own. The width mapping and its
// fallback are in lib/pageShell.js, where the DOM-less test runner can reach them.
export default function PublicLayout({ section = 'website', header = true, shell, children }) {
const bodyClass = shellClass(shell)
return ( return (
<div className="page"> <div className="page">
{header && <SiteHeader section={section} />} {header && <SiteHeader section={section} />}
{children} {bodyClass ? <div className={bodyClass}>{children}</div> : children}
<SiteFooter /> <SiteFooter />
</div> </div>
) )

View File

@@ -0,0 +1,26 @@
// The page-body shell core's public pages sit in, as plain JS.
//
// Extracted from PublicLayout.jsx for the reason lib/adminNav.js was: the client
// test runner has no DOM and cannot import a .jsx file at all
// (client/test/moduleRegistry.test.js says the same about modules/shared.js), so
// anything with a rule worth asserting has to live outside the component.
//
// The rule worth asserting here is the fallback. `shell` is part of the module
// contract as of MODULE_API_VERSION 1.5.0 (MODULE_API.md §3.4), which means the
// value can come from a module core has never seen, written against a version of
// this list that is older or newer than the one running. An unknown width must
// therefore still produce a wrapper: a module page at the wrong width looks like
// the site, and a page with no wrapper does not — it renders full-bleed with the
// footer riding up under it, which is the defect the prop exists to fix.
const SHELLS = { narrow: 'shell-narrow', mid: 'shell-mid', wide: 'shell-wide' }
export const SHELL_WIDTHS = Object.keys(SHELLS)
// Returns the className for a page body, or null when no shell was asked for —
// null is "render children bare", which is every core page written before 1.5.0
// and stays the default forever.
export function shellClass(shell) {
if (!shell) return null
return `${SHELLS[shell] || SHELLS.narrow} page-body`
}

View File

@@ -40,7 +40,8 @@ import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError, BASE } from '../api/client.js' import { request, ApiError, BASE } from '../api/client.js'
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These // The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// seven are what the smallest UO page already needs beyond React and the router: // eight exports — five table rows in §3.4, since `PageState` contributes three —
// are what the smallest UO page already needs beyond React and the router:
// without them a module either reaches into core's tree — violating the // without them a module either reaches into core's tree — violating the
// zero-import rule the whole boundary rests on — or ships its own copies, which // zero-import rule the whole boundary rests on — or ships its own copies, which
// means a module page that does not look like the site it is installed in, and // means a module page that does not look like the site it is installed in, and
@@ -50,11 +51,12 @@ import { request, ApiError, BASE } from '../api/client.js'
// is a MAJOR one. That is a real constraint on core's own refactoring and it is // is a MAJOR one. That is a real constraint on core's own refactoring and it is
// the price of the boundary being worth anything. // the price of the boundary being worth anything.
// //
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no // `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
// such component — admin views are plain markup inside AdminLayout — and // core has no such component — admin views are plain markup inside AdminLayout —
// inventing one to satisfy a table would be a core change with no consumer until // and inventing one to satisfy a table would be a core change with no consumer
// Phase 3. The contract is amended rather than the code padded, and adding it // until Phase 3. The contract was amended rather than the code padded (it no
// later costs a minor bump, which is exactly the case the versioning is for. // longer lists it), and adding it later costs a minor bump, which is exactly the
// case the versioning is for.
const ui = { const ui = {
PublicLayout, PublicLayout,
PageHeader, PageHeader,

View File

@@ -11,6 +11,14 @@
// that the two files can drift, so a test asserts they agree // that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both. // both.
// 1.5.0 — `PublicLayout` takes an optional `shell` prop ('narrow' | 'mid' |
// 'wide') that renders the `shell-… page-body` wrapper core's own pages write by
// hand. Additive: omitting it is 1.4.0's behaviour, so §3.4's "changing a kit
// component's props is major" does not bite — nothing already written changes
// meaning. It exists because the kit's acceptance run proved a module cannot
// discover the wrapper: the class names are theme.css's and appear in no
// contract, so a module page rendered outside the site's column while doing
// everything the kit said (docs/modules/kit-acceptance.md).
// 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a // 1.4.0 — a rule, not a member: §2.7 forbids a module opening a connection to a
// game server from the website process (it talks to a sidecar, which owns the // game server from the website process (it talks to a sidecar, which owns the
// durable copy). Nothing on window.__rg changed and nothing on the server's ctx // durable copy). Nothing on window.__rg changed and nothing on the server's ctx
@@ -30,4 +38,4 @@
// but the two halves state ONE version: a module declares a single coreApi range // but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server // and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question. // answered 1.1.0 would be two answers to one question.
export const MODULE_API_VERSION = '1.4.0' export const MODULE_API_VERSION = '1.5.0'

View File

@@ -0,0 +1,59 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { shellClass, SHELL_WIDTHS } from '../src/lib/pageShell.js'
// `PublicLayout`'s `shell` prop (MODULE_API.md §3.4, MODULE_API_VERSION 1.5.0).
// The component itself is .jsx and unreachable from this runner — there is no DOM
// here — so the rule lives in lib/pageShell.js and is asserted here, and the
// rendering is proved in a browser (MODULE_API.md §7.7), which is where the
// defect that produced this prop was found in the first place.
const HERE = path.dirname(fileURLToPath(import.meta.url))
test('no shell means no wrapper — the behaviour every page had before 1.5.0', () => {
// null, not an empty string: PublicLayout branches on it to render `children`
// bare, and '' would render a <div class=""> that changes core's nine pages.
assert.equal(shellClass(undefined), null)
assert.equal(shellClass(null), null)
assert.equal(shellClass(''), null)
assert.equal(shellClass(false), null)
})
test('each documented width maps to its theme.css class, plus page-body', () => {
assert.equal(shellClass('narrow'), 'shell-narrow page-body')
assert.equal(shellClass('mid'), 'shell-mid page-body')
assert.equal(shellClass('wide'), 'shell-wide page-body')
})
test('page-body is always present — it is what pushes the footer down', () => {
// `.page` is a flex column and `.page-body { flex: 1 }` is the only thing
// filling it. A width class on its own centres the content and still lets the
// footer ride up under it, which is half the reported defect and the half that
// is easy to lose in a refactor.
for (const w of SHELL_WIDTHS) {
assert.match(shellClass(w), /\bpage-body\b/)
}
})
test('an unknown width still renders a wrapper, at the narrow default', () => {
// The value can arrive from a module built against a different version of this
// list, so the failure mode has to be "wrong width" and never "no wrapper".
assert.equal(shellClass('enormous'), 'shell-narrow page-body')
assert.equal(shellClass(true), 'shell-narrow page-body')
assert.equal(shellClass('NARROW'), 'shell-narrow page-body')
})
test('every width this module offers is a class theme.css actually defines', () => {
// The contract now names these widths to module authors, so a rename in
// theme.css has to fail here rather than silently in a module's page.
const css = fs.readFileSync(path.join(HERE, '../src/styles/theme.css'), 'utf8')
for (const w of SHELL_WIDTHS) {
const cls = shellClass(w).split(' ')[0]
assert.ok(css.includes(`.${cls} {`), `theme.css defines .${cls}`)
}
assert.ok(css.includes('.page-body {'), 'theme.css defines .page-body')
})

View File

@@ -9,6 +9,15 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version. // has nothing to say about a website module) and from any module's own version.
// 1.5.0 — a CLIENT addition: `PublicLayout` takes an optional `shell` prop that
// renders the page body wrapper core's own pages write by hand (MODULE_API.md
// §3.4). Minor, not major: §3.4 makes *changing* a kit component's props a major
// bump because that breaks a call already written, and adding an optional one
// breaks nothing — omitting `shell` is 1.4.0's behaviour exactly. Nothing on the
// server changed; this file bumps for the reason below. Found by the Integration
// Kit's acceptance run (docs/modules/kit-acceptance.md), where a module built
// exactly as the kit teaches rendered outside the site's page column.
//
// 1.4.0 — no member changed. §2.7 gained one prohibition: a module does not open // 1.4.0 — no member changed. §2.7 gained one prohibition: a module does not open
// a connection to a game server from the website process; it talks to a sidecar, // a connection to a game server from the website process; it talks to a sidecar,
// which owns the durable copy of the game's state. Minor rather than major // which owns the durable copy of the game's state. Minor rather than major
@@ -35,6 +44,6 @@
// an admin action a module performs belongs in core's one audit log, the // an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module // extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor. // reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.4.0' const MODULE_API_VERSION = '1.5.0'
module.exports = { MODULE_API_VERSION } module.exports = { MODULE_API_VERSION }