diff --git a/client/src/components/PublicLayout.jsx b/client/src/components/PublicLayout.jsx index 35537f9..c93713f 100644 --- a/client/src/components/PublicLayout.jsx +++ b/client/src/components/PublicLayout.jsx @@ -1,12 +1,36 @@ import SiteHeader from './SiteHeader.jsx' import SiteFooter from './SiteFooter.jsx' +import { shellClass } from '../lib/pageShell.js' // 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 `
`, 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 (
{header && } - {children} + {bodyClass ?
{children}
: children}
) diff --git a/client/src/lib/pageShell.js b/client/src/lib/pageShell.js new file mode 100644 index 0000000..3eee530 --- /dev/null +++ b/client/src/lib/pageShell.js @@ -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` +} diff --git a/client/src/modules/shared.js b/client/src/modules/shared.js index 9d2a1df..5deb940 100644 --- a/client/src/modules/shared.js +++ b/client/src/modules/shared.js @@ -40,7 +40,8 @@ import { useSite } from '../contexts/SiteContext.jsx' import { request, ApiError, BASE } from '../api/client.js' // 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 // 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 @@ -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 // the price of the boundary being worth anything. // -// `AdminPage` appears in §3.4's table and is deliberately absent: core has no -// such component — admin views are plain markup inside AdminLayout — and -// inventing one to satisfy a table would be a core change with no consumer until -// Phase 3. The contract is amended rather than the code padded, and adding it -// later costs a minor bump, which is exactly the case the versioning is for. +// `AdminPage` was in an early draft of §3.4's table and is deliberately absent: +// core has no such component — admin views are plain markup inside AdminLayout — +// and inventing one to satisfy a table would be a core change with no consumer +// until Phase 3. The contract was amended rather than the code padded (it no +// longer lists it), and adding it later costs a minor bump, which is exactly the +// case the versioning is for. const ui = { PublicLayout, PageHeader, diff --git a/client/src/modules/version.js b/client/src/modules/version.js index 6f30105..73934de 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,14 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // 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 // 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 @@ -30,4 +38,4 @@ // 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 // 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' diff --git a/client/test/pageShell.test.js b/client/test/pageShell.test.js new file mode 100644 index 0000000..f4b08ff --- /dev/null +++ b/client/test/pageShell.test.js @@ -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
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') +}) diff --git a/server/src/modules/version.js b/server/src/modules/version.js index 94ca432..6ce1362 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,15 @@ // 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. +// 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 // 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 @@ -35,6 +44,6 @@ // 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 // 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 }