Files
website/client/src/lib/heroLayout.js
wtclaude 0c4eacfa4a
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / server-tests (pull_request) Successful in 31s
refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)
Phase 3's acceptance criterion 1, made real. Three things, one review:

**The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO
namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/
userShard`, the uo-link and town-crier calls, `player.shard` — with zero core
consumers since slice 3 deleted the views. module-uo vendors its own bindings.
The five assertions core's `apiClient.test.js` made about those URLs moved with
them (Module-uo#5); the encoding test that used `governorHistory` now uses a
core route.

**The copy.** Core is the platform, not one game's site, so its words are
game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was
never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`,
the default hero, `brand.js`'s tagline and description, the seeded wiki
categories, and two user-visible NavEditor strings that named a module's admin
screen by its proper name. Which game an instance is for is the operator's to
say — BRAND_* vars, the hero editor, CMS pages — and every real instance already
does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing
live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts
what is absent, so renaming one adds a duplicate page to every install.

Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas
tables slice 1 took away, and the two settings rows core seeded for a module
(`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live
defect — see Module-uo#5, which takes ownership of both and repairs the
one-shot migration core's ordering had disabled.

**The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`,
first step of the server-tests job because it needs no dependencies. It reads
CODE, not prose — file names, import specifiers, route path literals, declared
identifiers and property names — per §5.2, so core's English may still say
"shard" where saying it is worth more than the word costs.

Two things it gets right only because getting them wrong was tried first: it
matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains
"ultIma", four times in this repo), and it strips comments and string bodies in
one character walk (a comment contains quotes, a string contains `//`) — the
`checkImports.js` lesson. It has its own 17-test suite, because a boundary check
that silently stops checking is worse than none. The three §6.5 grandfathering
allowlists are exempt by name, and an exemption that stops matching fails the
build rather than lingering.

BREAKING CHANGE: core no longer seeds `game_account_signup` or
`uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install
running core without module-uo keeps whatever rows it already has and gains no
new ones — nothing in core reads either key.

Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a
`## Shard integration (uo-link)` section and the architecture diagram. That is
documentation, which §5.2 does not cover, and it belongs with the phase-closing
docs pass rather than half-done here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 21:41:18 -05:00

137 lines
5.3 KiB
JavaScript

// Shared hero-layout helpers used by the public portal and the admin editor.
// Runic Gateway default hero emblem; the instance hero image (BRAND_HERO)
// overrides it at runtime, threaded in as `defaultImage` by the portal.
export const DEFAULT_HERO_IMAGE = '/assets/img/runic-emblem.png'
// Default hero background: the emblem centered behind the text as a medallion,
// under a symmetric dark overlay tuned to keep centered hero copy legible.
// Two layers (overlay gradient + image) so the per-layer background-size in
// `heroBackground` can contain the square emblem while the overlay stays full-bleed.
export function heroBgStack(image) {
return (
'linear-gradient(180deg,rgba(11,15,20,0.62) 0%,rgba(11,15,20,0.48) 38%,rgba(11,15,20,0.52) 58%,rgba(11,15,20,0.86) 100%),' +
"url('" +
(image || DEFAULT_HERO_IMAGE) +
"')"
)
}
// Keep the emblem fully visible and centered, capped so it never overflows a
// narrow viewport; the overlay layer covers.
export const HERO_DEFAULT_SIZE = 'cover, min(74vh, 640px, 86vw)'
export const HERO_DEFAULT_POSITION = 'center, center'
export const HERO_BG = heroBgStack(DEFAULT_HERO_IMAGE)
// Single-stop dark overlay driven by the editor's opacity slider.
export function buildOverlay(opacity) {
return `linear-gradient(180deg,rgba(11,15,20,${opacity * 0.15}) 0%,rgba(11,15,20,${opacity}) 100%)`
}
// Background style for a layout. When `isDefault` and no custom image is set, use
// the exact original gradient stack; otherwise compose the overlay over the image.
export function heroBackground(layout, { isDefault = false, defaultImage } = {}) {
const bg = layout.background || {}
const fallback = defaultImage || DEFAULT_HERO_IMAGE
// Untouched default: emblem contained + centered behind the text (per-layer
// size/position so the overlay stays full-bleed while the square emblem fits).
if (isDefault && !bg.image_url) {
return {
backgroundColor: 'var(--bg-deep)',
backgroundImage: heroBgStack(fallback),
backgroundPosition: HERO_DEFAULT_POSITION,
backgroundRepeat: 'no-repeat',
backgroundSize: HERO_DEFAULT_SIZE,
}
}
return {
backgroundColor: 'var(--bg-deep)',
backgroundImage: `${buildOverlay(layout.overlay?.opacity ?? 0.72)}, url('${bg.image_url || fallback}')`,
backgroundPosition: `${bg.position_x || 'left'} ${bg.position_y || 'center'}`,
backgroundRepeat: 'no-repeat',
backgroundSize: bg.size || 'cover',
}
}
// Parse a stored layout string; return null if missing/malformed/wrong version.
export function parseLayout(str) {
try {
const l = str ? JSON.parse(str) : null
return l && l.version === 1 && Array.isArray(l.elements) ? l : null
} catch {
return null
}
}
// The current hardcoded hero as a HeroLayout, so the page is unchanged until
// staff publish their own. Font sizes use the existing clamp() strings so the
// default stays responsive (editor-created text uses px).
//
// The copy is deliberately game-neutral, and deliberately still copy: this is
// also the starting point the hero editor loads, so an instance that wants to
// name its game says so there, once, and the result is stored — rather than core
// shipping one game's words for every instance to overwrite in source.
export function defaultLayout(teaser, name = 'Runic Gateway') {
return {
version: 1,
background: { image_url: null, position_x: 'left', position_y: 'center', size: 'cover' },
overlay: { opacity: 0.72 },
elements: [
{
id: 'default-text',
type: 'text_block',
x: 50,
y: 42,
z: 1,
anchor: 'center',
props: {
align: 'center',
width: 760,
lines: [
{ text: 'Private game server', tag: 'span', fontSize: '0.74rem', color: '#c2d2e6', weight: 700, letterSpacing: '0.22em', transform: 'uppercase', font: 'sans' },
{ text: name, tag: 'h1', fontSize: 'clamp(3rem,8.5vw,5.75rem)', color: 'var(--head)', weight: 600, letterSpacing: '0.02em', lineHeight: 1, font: 'display', marginTop: 14 },
{ text: 'A private world in progress', tag: 'p', fontSize: '1.32rem', color: '#dbe2ea', italic: true, marginTop: 22 },
{ text: teaser, tag: 'div', html: true, fontSize: '1.06rem', color: '#c4cdd8', maxWidth: 600, marginTop: 22 },
],
},
},
{
id: 'default-buttons',
type: 'buttons',
x: 50,
y: 72,
z: 2,
anchor: 'center',
props: {
align: 'center',
gap: 12,
items: [
{ label: 'Enter the Website', to: '/site', variant: 'primary' },
{ label: 'Open the Wiki', to: '/wiki', variant: 'ghost' },
],
},
},
{
id: 'default-quick-links',
type: 'buttons',
x: 50,
y: 85,
z: 3,
anchor: 'center',
props: {
align: 'center',
gap: 10,
items: [
{ label: 'News', to: '/site/news', variant: 'ghost' },
{ label: 'Screenshots', to: '/site/screenshots', variant: 'ghost' },
{ label: 'Five on Friday', to: '/site/five-on-friday', variant: 'ghost' },
{ label: 'Monthly Newsletter', to: '/site/newsletter', variant: 'ghost' },
{ label: 'About', to: '/site/about', variant: 'ghost' },
],
},
},
],
}
}