feat(modules): PublicLayout takes a shell, MODULE_API_VERSION 1.5.0
The Integration Kit's acceptance run (Phase 5 slice 3) put a cold agent in front
of the kit alone and asked it to build a module for a second game. It built one
that works — and its page rendered outside the site.
PublicLayout supplies the chrome and not the body. Every core public page wraps
its own content in `<div className="shell-... page-body">`: the centred column,
the top and bottom padding, and — through `page-body { flex: 1 }` — the thing
that pushes the footer to the bottom of the viewport. Nine of nine core pages do
it, so the omission has never shown. A module cannot do it: it receives
PublicLayout through the UI kit and those two class names appear in no contract.
The result was a page at x=0 with the footer riding up under the content, which
is the exact failure MODULE_API.md §3.4 says the kit exists to prevent.
So the wrapper moves behind the component a module already has:
<PublicLayout shell="narrow"> // or "mid" / "wide"
`shell` is opt-in and omitting it is 1.4.0's behaviour exactly, so core's nine
pages are untouched and keep their own wrapper. An unrecognised width falls back
to narrow rather than to nothing — a module page at the wrong width still looks
like the site; a page with no wrapper does not.
1.5.0 is minor, not major. §3.4 makes *changing* a kit component's props major
because that breaks a call already written; adding an optional one breaks
nothing. module-uo's `coreApi: "^1.3.0"` still resolves.
The width map and its fallback live in client/src/lib/pageShell.js rather than in
the component, for the reason lib/adminNav.js does: the client runner has no DOM
and cannot import .jsx at all, so a rule inside a component is a rule no test can
reach. Five tests cover it, including that every width it offers is a class
theme.css actually defines — the contract now names those widths to module
authors, so a rename has to fail here instead of silently in someone's page.
Also from the same run: modules/shared.js called the UI kit "seven" members while
exporting eight (§3.4's table has five rows because PageState contributes three),
and its note said AdminPage "appears in §3.4's table" when the table dropped it in
Phase 2 PR 7.
742 server + 192 client tests pass (+5). routes.manifest.json and the OpenAPI
spec regenerate byte-identical — no route changed.
Verified in a browser against the acceptance module (MODULE_API.md §7.7), which
is the only place this seam is visible: the untouched build renders full-bleed,
and shell="narrow" lands the page in the same column as core's own.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<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 (
|
||||
<div className="page">
|
||||
{header && <SiteHeader section={section} />}
|
||||
{children}
|
||||
{bodyClass ? <div className={bodyClass}>{children}</div> : children}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
|
||||
26
client/src/lib/pageShell.js
Normal file
26
client/src/lib/pageShell.js
Normal 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`
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
59
client/test/pageShell.test.js
Normal file
59
client/test/pageShell.test.js
Normal 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')
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user