HardYards/web/world/editor.html
type-two 16dee119d6 Sprint 14 gate 1: the yard editor core + two shipped bugs it found
web/world/editor.html + js/editor.js. Loads any existing site or an empty
template and renders the REAL dressed world through the same
loadSite/createWorld/dress path the game boots -- not a diagram, and no
second renderer to keep in sync. Place/drag/delete on the ground plane by
raycast, writing the SAME named-key site-JSON shapes world.js already reads
(the gnome pattern; there is deliberately no generic props[] and the editor
does not invent one). Live validateSite into a panel, canonical
stable-key-order export with a loud _INVALID key, visible _design/_why
authoring fields, and an export panel that says export != shipped.

Published the DOM seam contract in editor.html's header so B's SCORE IT and
C's wind authoring can start against it immediately -- mountPanel + a class
kit, the way E's .letterhead kit worked in Sprint 12.

Two rendering bugs in world.js that the editor surfaced in its first ten
minutes, both verified by looking at the yard rather than by reasoning:

- the graybox house was built unconditionally and dress() only retires it
  when a house GLB loads, so site_02_corner_block -- which declares no house
  -- has been shipping a 16x3x6 m grey slab across night 3's whole north
  horizon, in solids, on the public URL.
- SHED alone was bound raw while SHED_TABLE/GNOME/BIKE all get their degrees
  converted, so SHED.rotY was undefined, the shed's quaternion was all NaN,
  and three.js silently declines to draw a NaN world matrix. The Hendersons'
  shed has never rendered; ladder.js has been leaning the ladder on thin air.

Also: createWorld threw on a site with no shedTable (unreachable for both
shipped sites, reached by the editor template on its first frame). Now null,
which every consumer already guards and which CONTRACT.world permits.

Selftest 362/0/0. Asserts for the two fixes land with the round-trip test in
the next commit -- pushing now because B and C are blocked on the seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:46:01 +10:00

217 lines
8.9 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HARD YARDS — yard editor</title>
<!--
The yard editor. Lane A owns this file (SPRINT14 gate 1).
It lives in web/ and therefore DEPLOYS with the game, and that is intended:
site JSON carries no secrets, and a public "build a yard" toy is a feature.
Nothing here is loaded by index.html — the game never imports editor.js.
── THE DOM SEAM CONTRACT (Lane B's SCORE IT, Lane C's wind authoring) ────────
B and C build INTO this page. Everything they need is below and in editor.js;
the same way E's invoice kit got .letterhead/.brief/.jobsheet in SPRINT12,
this page publishes classes and mount points so three lanes can edit one UI
without editing one file.
MOUNT: never append to #ed-side by hand. Call
const { root, body } = EDITOR.mountPanel({ id: 'score', title: 'SCORE IT', order: 40 });
and fill `body`. `order` fixes the rail's vertical order across lanes so two
lanes landing in the same sprint can't fight over it. Reserved orders:
10 site 20 palette 30 inspector 40 SCORE IT (B) 50 wind (C)
80 validation 90 export
Calling mountPanel twice with one id returns the SAME panel (idempotent), so a
re-render is a re-fill, not a duplicate.
CLASSES (the contract — style with these, don't invent):
.ed-panel .ed-panel-title .ed-panel-body panel chrome (mountPanel makes these)
.ed-row one label+control line
.ed-label the label half of a row
.ed-btn .ed-btn.primary .ed-btn.danger buttons
.ed-num .ed-sel .ed-text number / select / text+textarea
.ed-card .ed-card-head .ed-card-row .ed-kv a RESULT card (B's score goes here)
.ed-ok .ed-warn .ed-err verdict colouring, on any element
.ed-note muted small print
.ed-tag inline pill (a flag, a count)
Anything you need that isn't here: add it, and say so in THREADS — a class
that only one lane knows about is the thing this contract exists to prevent.
The scene seams (three.js side) are on the EDITOR object — see editor.js's
header for the full API. The short version C needs:
EDITOR.overlay a THREE.Group, cleared on every rebuild — gizmos live here
EDITOR.raycastGround(ev) → {x,y,z}|null
EDITOR.registerTool({...}) / EDITOR.setTool(id)
EDITOR.site / EDITOR.markDirty() read+mutate the live site, then say so
EDITOR.on('change'|'rebuild'|'select', fn)
-->
<style>
:root {
color-scheme: dark;
--bg: #10161b;
--panel: #172129;
--panel2: #1d2831;
--line: #2b3a45;
--ink: #dde5ea;
--dim: #8598a5;
--accent: #7ee0ff;
--ok: #7fce6a;
--warn: #ffc46b;
--err: #ff6b6b;
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%; overflow: hidden;
background: var(--bg); color: var(--ink);
font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
}
#ed-root { display: flex; height: 100%; }
/* --- viewport ---------------------------------------------------------- */
#ed-viewport { position: relative; flex: 1 1 auto; min-width: 0; }
#ed-canvas { display: block; width: 100%; height: 100%; }
#ed-hint {
position: absolute; left: 10px; bottom: 10px; margin: 0;
color: #fff; text-shadow: 0 1px 3px #000; white-space: pre; pointer-events: none;
opacity: .85;
}
#ed-readout {
position: absolute; right: 10px; top: 10px; margin: 0; text-align: right;
color: #fff; text-shadow: 0 1px 3px #000; white-space: pre; pointer-events: none;
}
#ed-toolbar {
position: absolute; left: 10px; top: 10px; display: flex; gap: 6px; flex-wrap: wrap;
max-width: calc(100% - 220px);
}
/* --- side rail --------------------------------------------------------- */
#ed-side {
flex: 0 0 340px; height: 100%; overflow-y: auto; overflow-x: hidden;
background: var(--panel); border-left: 1px solid var(--line);
}
#ed-side::-webkit-scrollbar { width: 9px; }
#ed-side::-webkit-scrollbar-thumb { background: #2f3f4b; border-radius: 5px; }
/* --- the class contract ------------------------------------------------ */
.ed-panel { border-bottom: 1px solid var(--line); }
.ed-panel-title {
margin: 0; padding: 9px 12px; font-size: 11px; font-weight: 700;
letter-spacing: .14em; color: var(--accent); background: var(--panel2);
display: flex; align-items: center; justify-content: space-between; gap: 8px;
cursor: pointer; user-select: none;
}
.ed-panel-title::after { content: '▾'; color: var(--dim); font-size: 10px; }
.ed-panel.collapsed .ed-panel-title::after { content: '▸'; }
.ed-panel.collapsed .ed-panel-body { display: none; }
.ed-panel-body { padding: 10px 12px 12px; }
.ed-row { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; }
.ed-row:last-child { margin-bottom: 0; }
.ed-label { flex: 0 0 84px; color: var(--dim); }
.ed-row > .ed-num, .ed-row > .ed-sel, .ed-row > .ed-text { flex: 1 1 auto; min-width: 0; }
.ed-btn {
font: inherit; padding: 4px 9px; border-radius: 4px; cursor: pointer;
background: #24313b; color: var(--ink); border: 1px solid var(--line);
}
.ed-btn:hover { background: #2d3d49; }
.ed-btn:active { transform: translateY(1px); }
.ed-btn.on { background: #1d4a5c; border-color: #3d7f96; color: #cdf3ff; }
.ed-btn.primary { background: #1f5a34; border-color: #2f7d49; color: #d6ffdf; }
.ed-btn.primary:hover { background: #266c3e; }
.ed-btn.danger { background: #58211f; border-color: #7d2b2b; color: #ffd9d6; }
.ed-btn[disabled] { opacity: .45; cursor: not-allowed; }
.ed-num, .ed-sel, .ed-text {
font: inherit; padding: 3px 6px; border-radius: 4px;
background: #0e1418; color: var(--ink); border: 1px solid var(--line);
}
.ed-num { width: 74px; }
textarea.ed-text { width: 100%; resize: vertical; min-height: 62px; line-height: 1.5; }
.ed-card {
border: 1px solid var(--line); border-radius: 5px; background: var(--panel2);
padding: 8px 10px; margin: 0 0 8px;
}
.ed-card-head {
font-weight: 700; letter-spacing: .06em; margin: 0 0 6px;
display: flex; justify-content: space-between; gap: 8px;
}
.ed-card-row { display: flex; justify-content: space-between; gap: 10px; padding: 1px 0; }
.ed-kv { color: var(--dim); }
.ed-ok { color: var(--ok); }
.ed-warn { color: var(--warn); }
.ed-err { color: var(--err); }
.ed-note { color: var(--dim); font-size: 11px; line-height: 1.5; }
.ed-tag {
display: inline-block; padding: 0 5px; border-radius: 3px; font-size: 10px;
background: #24313b; border: 1px solid var(--line); color: var(--dim);
}
.ed-tag.ed-err { background: #3a1618; border-color: #7d2b2b; }
.ed-tag.ed-ok { background: #12321c; border-color: #2c6b3c; }
ul.ed-list { margin: 0; padding: 0; list-style: none; }
ul.ed-list li {
padding: 3px 6px; border-radius: 3px; cursor: pointer;
display: flex; justify-content: space-between; gap: 8px;
}
ul.ed-list li:hover { background: #22303a; }
ul.ed-list li.sel { background: #1d4a5c; color: #cdf3ff; }
#ed-boot-error {
position: absolute; inset: 0; display: none; padding: 28px;
background: #10161b; color: var(--err); white-space: pre-wrap; overflow: auto;
}
</style>
<!--
Required. world.js's dress() pulls GLTFLoader, which imports the BARE
specifier 'three'. Without this map the throw is swallowed by dress()'s
per-asset try/catch and every rating_hint silently becomes 1.0 — the
invincible-house gotcha in docs/MANUAL.md. It has cost this repo a day; do
not remove it from any page that dresses a yard.
-->
<script type="importmap">
{ "imports": { "three": "./vendor/three.module.js", "three/addons/": "./vendor/addons/" } }
</script>
</head>
<body>
<div id="ed-root">
<div id="ed-viewport">
<canvas id="ed-canvas"></canvas>
<div id="ed-toolbar"></div>
<pre id="ed-readout"></pre>
<pre id="ed-hint"></pre>
<div id="ed-boot-error"></div>
</div>
<div id="ed-side"></div>
</div>
<script type="module">
import { createEditor } from './js/editor.js';
try {
await createEditor({
canvas: document.getElementById('ed-canvas'),
side: document.getElementById('ed-side'),
toolbar: document.getElementById('ed-toolbar'),
readout: document.getElementById('ed-readout'),
hint: document.getElementById('ed-hint'),
});
} catch (err) {
// The editor is a tool, so it fails LOUD and on the glass. index.html's boot
// failure writes into a hidden #dev div and shows a stranger a blank page
// (SPRINT14 pool item) — this page does not repeat that.
const box = document.getElementById('ed-boot-error');
box.style.display = 'block';
box.textContent = `EDITOR BOOT FAILED\n\n${err && err.stack ? err.stack : err}`;
throw err;
}
</script>
</body>
</html>