← Commit history
SKILL.md+33−4
@@ -16,11 +16,19 @@ gate, which opens the studio in front of the human and BLOCKS until they click Generate:  ```bash-hero-studio pose <owner>/<slug> --surface webview|pup [--title "T"] [--brief "one-liner"] [--timeout 900]-# blocks; exits 0 with {file, path, latest} JSON on stdout when the HUMAN generates,-# exit 2 on timeout, exit 1 on setup errors+hero-studio pose <owner>/<slug> --surface webview|pup [--title "T"] [--brief "one-liner"] [--type app,service] [--timeout 900] [--port N]+# blocks; exits 0 with {file, path, port, latest} JSON on stdout when the HUMAN generates,+# exit 2 on timeout, exit 1 on setup errors, exit 3 when the pup surface failed+# (the reason is printed; there is NO silent webview fallback) ``` +CONCURRENT STUDIOS: when the default port is mid-gate for a DIFFERENT page,+pose automatically starts a fresh instance on a free port and says so; two AI+threads can pose in parallel without clobbering each other. Every verb takes+`--port N` (or HERO_STUDIO_PORT) to target an instance; the tab is named+"Hero Studio :PORT" for non-default instances. `--type` takes a comma list+for multi-type pages (app,service renders a clustered pill group).+ `--surface` is REQUIRED: check the user's stored preference first with `adom-cli hydrogen probe` (window_mode: webview | pup) and pass that. Never open both surfaces for the same content. The pup path reuses a `hero-studio`@@ -101,6 +109,22 @@ and auto-pulls the new package's real screenshot, so per sibling only the vitrine content and the baked slug change. Pose sibling A, tune, Generate; pose sibling B, confirm, Generate. +## Needs-fixing verdicts and the layout linter++Every tour step and checklist item has a "Needs fixing" verdict, not just+approve-or-skip. A flag HOLDS Generate and lands in `state.flags` (SSE + GET+/state), so the DRIVING AI must watch for it: when a flag appears, adjust the+layout, font size, or copy, re-present, and the human re-ticks. Ticking a box+clears its flag.++The studio also runs a measured LAYOUT LINTER after every change: pill vs+vitrine overlap (a scrim is auto-applied so the pill reads as a badge), image+text intruding into the dynamic marquee band, text clipped by the hero edge+(HARD FAIL: locks Generate), near-edge insets, and 4+ line title wraps.+Findings render in the gate and publish to `state.lint`+([{id, level: warn|fail, msg}]); the AI should fix warns without being asked.+Contrast checking is not in v1.+ ## Verification mode (what the pose gate shows)  When opened by the pose gate, the studio presents verification chrome above the@@ -111,7 +135,12 @@ vitrine, then the type pill (where the human catches an AI's wrong app/skill/ component call); "Looks good" ticks that check and advances. The homepage- marquee overlay composites over the hero by default in the gate so the human SEES where the wiki URL, pill, and today's duplicated title land (annotated-with adom/wiki#80, which will drop the duplicate). Free exploration without+with adom/wiki#80, which will drop the duplicate). The overlay BREATHES, tags+itself DYNAMIC, and hold-to-hides (press the band to see the pure export), so+dynamic text is never mistaken for baked text. Tour dialogs are draggable by+their header and remember where you put them. The header's focus button (zen)+hides the panel and requests fullscreen so the hero gets the whole surface;+Esc restores. Free exploration without the checklist is lab mode, a plain open.  ## The vitrine: real content is MANDATORY, not a suggestion
data/pages.json+9−5
@@ -132,22 +132,26 @@   "updated_at": "2026-07-16T12:20:55.308Z"  },  {-  "owner": "adom",-  "slug": "adom-tts",   "type": "app",-  "title": "Adom TTS - Text to Speech",+  "title": "Adom TTS",   "author": "John Lauer",   "score": 607,   "stars": 0,   "installs30d": 99,-  "brief": "Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos , so you never record your own voice.",+  "brief": "Text to speech for every Adom surface, backed by the shared service-tts container",   "tags": [    "tts",    "voice",    "audio",    "demo"   ],-  "updated_at": "2026-07-17T01:56:01.851Z"+  "updated_at": "2026-07-17T01:56:01.851Z",+  "owner": "adom",+  "slug": "adom-tts",+  "types": [+   "app",+   "service"+  ]  },  {   "owner": "adom",
drive.mjs+5−1
@@ -10,7 +10,11 @@ //   hero-studio reroll                                    re-roll the circuitry pattern import path from 'path'; -const BASE = 'http://localhost:8846/';+// --port <n> / HERO_STUDIO_PORT target a specific instance (hero-studio#15).+const pi = process.argv.indexOf('--port');+const PORT = Number((pi >= 0 && process.argv[pi + 1]) || process.env.HERO_STUDIO_PORT || 8846);+if (pi >= 0) process.argv.splice(pi, 2);+const BASE = 'http://localhost:' + PORT + '/'; const args = process.argv.slice(2); const verb = args.shift(); 
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.11.2",+  "version": "0.12.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.11.2",+  "version": "0.12.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
pose.mjs+90−30
@@ -6,7 +6,9 @@ // the generated PNG path on stdout and exits 0. An AI that needs a hero runs // this and waits; it cannot continue without a human-made image. //-// Exit codes: 0 generated, 2 timeout (no human generated), 1 setup error.+// Exit codes: 0 generated, 2 timeout (no human generated), 1 setup error,+// 3 requested surface unavailable (pup failed; the reason is printed, and the+// gate deliberately does NOT fall back to a webview the user didn't ask for). import { execFile, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path';@@ -14,7 +16,7 @@ import fs from 'fs'; import { runChecks, printReport } from './doctor.mjs';  const DIR = path.dirname(fileURLToPath(import.meta.url));-const BASE = 'http://localhost:8846/';+let BASE = 'http://localhost:8846/';  const args = process.argv.slice(2); const flags = {};@@ -66,27 +68,68 @@ const jf = async (p, opts) => { const sh = (cmd, a, timeout = 20000) => new Promise((res) => {   execFile(cmd, a, { timeout }, (err, stdout) => res(err ? null : stdout)); });+// Verbose variant: keeps the output and failure mode, so a pup failure can be+// DIAGNOSED instead of swallowed (hero-studio#12: PUP_UNREACHABLE fired while+// the same relay demonstrably worked, and nothing said why).+const shv = (cmd, a, timeout = 20000) => new Promise((res) => {+  execFile(cmd, a, { timeout }, (err, stdout, stderr) => res({+    ok: !err,+    out: String(stdout || ''),+    errOut: String(stderr || ''),+    fail: err ? (err.killed ? 'timed out after ' + timeout + 'ms' : 'exit ' + (err.code ?? '?')) : '',+  }));+}); -// 1. ensure the server is up+// 1. pick an instance (hero-studio#15: concurrent studios). Explicit+// --port/HERO_STUDIO_PORT wins. Otherwise use the default port, EXCEPT when+// that server is mid-gate for a DIFFERENT page: then spawn a fresh instance on+// a free port so this pose can never clobber another thread's half-reviewed+// checklist.+let port = Number(flags.port || process.env.HERO_STUDIO_PORT || 8846);+const portPinned = Boolean(flags.port || process.env.HERO_STUDIO_PORT);+BASE = 'http://localhost:' + port + '/'; let state = null;-try { state = await jf('state'); } catch {-  spawn(process.execPath, [path.join(DIR, 'server.js')], { detached: true, stdio: 'ignore', env: process.env }).unref();+try { state = await jf('state'); } catch {}+if (state && !portPinned) {+  const gateBusy = state.verify && state.page && state.page !== ref+    && !(state.approved && state.approved.page === state.page);+  if (gateBusy) {+    const busyFor = state.page;+    let free = 0;+    for (let cand = port + 1; cand <= port + 40 && !free; cand++) {+      BASE = 'http://localhost:' + cand + '/';+      try { await jf('state'); } catch { free = cand; }+    }+    if (!free) { console.error('no free port found near ' + port); process.exit(1); }+    console.error('note: :' + port + ' is mid-gate for ' + busyFor + '; using a fresh instance on :' + free + ' so neither pose clobbers the other (hero-studio#15)');+    port = free;+    state = null;+  } else {+    BASE = 'http://localhost:' + port + '/';+  }+}+if (!state) {+  spawn(process.execPath, [path.join(DIR, 'server.js'), '--port', String(port)], { detached: true, stdio: 'ignore', env: process.env }).unref();   for (let i = 0; i < 20 && !state; i++) {     await new Promise((r) => setTimeout(r, 500));     try { state = await jf('state'); } catch {}   }-  if (!state) { console.error('hero-studio server did not come up on :8846'); process.exit(1); }+  if (!state) { console.error('hero-studio server did not come up on :' + port); process.exit(1); } } -// 2. make sure the studio knows this package, then pose it+// 2. make sure the studio knows this package, then pose it.+// --type accepts a comma list for multi-type pages (hero-studio#19,+// e.g. --type app,service): the first entry drives the mode, the full set+// renders as a pill group. const title = flags.title || slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');+const typeList = String(flags.type || 'app').split(',').map((t) => t.trim()).filter(Boolean); await jf('api/pages', {   method: 'POST', headers: { 'Content-Type': 'application/json' },   body: JSON.stringify(Object.fromEntries(Object.entries(-    { owner, slug, title, type: flags.type || 'app', brief: flags.brief },+    { owner, slug, title, type: typeList[0], types: typeList.length > 1 ? typeList : null, brief: flags.brief },   ).filter(([, v]) => v))), });-const poseMode = (flags.type === 'component') ? 'component' : 'app';+const poseMode = (typeList[0] === 'component') ? 'component' : 'app'; await jf('state', {   method: 'POST', headers: { 'Content-Type': 'application/json' },   body: JSON.stringify({@@ -94,6 +137,8 @@ await jf('state', {     // the gate presentation: verification chrome + a fresh checklist     verify: true, tour: true, marqueePreview: true,     confirm: { title: false, subtitle: false, vitrine: false, type: false },+    flags: { title: false, subtitle: false, vitrine: false, type: false },+    lint: [],     // clear the previous package's vitrine so a failed auto-pull can never     // leave sibling A's screenshot in sibling B's hero. Artwork state (theme,     // layout, angle, brand) deliberately carries across poses: that IS the@@ -107,33 +152,48 @@ await jf('state', { try { await jf('vitrine-from-page?page=' + ref, { method: 'POST' }); } catch {}  // 3. open the studio IN FRONT OF THE HUMAN on the surface they actually use.-const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', '8846');+const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', String(port)); if (!proxyBase) { console.error('VSCODE_PROXY_URI not set; cannot build the studio URL'); process.exit(1); }-let tabName = 'Hero Studio';+let tabName = port === 8846 ? 'Hero Studio' : 'Hero Studio :' + port;+const SESSION = port === 8846 ? 'hero-studio' : 'hero-studio-' + port; if (surface === 'pup') {-  // Adom Desktop pup window. Reuse an existing hero-studio session; never-  // spawn a duplicate window (browser_status first, then navigate-or-open).-  const status = await sh('adom-desktop', ['browser_status', '{}']);-  const hasSession = !!(status && status.includes('hero-studio'));+  // Adom Desktop pup window. Reuse an existing session for this instance;+  // never spawn a duplicate window (browser_status first, then+  // navigate-or-open). Every step's real output is kept for diagnosis.+  const steps = [];+  const status = await shv('adom-desktop', ['browser_status', '{}']);+  steps.push(['browser_status', status]);+  const hasSession = status.ok && status.out.includes(SESSION);+  const looksOk = (r) => r.ok && !r.out.includes('"error"');   let opened = hasSession-    ? await sh('adom-desktop', ['browser_navigate', JSON.stringify({ sessionId: 'hero-studio', url: proxyBase })])-    : await sh('adom-desktop', ['browser_open_window', JSON.stringify({ sessionId: 'hero-studio', profile: 'hero-studio', url: proxyBase })], 60000);-  if (opened === null && !hasSession) {+    ? await shv('adom-desktop', ['browser_navigate', JSON.stringify({ sessionId: SESSION, url: proxyBase })], 30000)+    : await shv('adom-desktop', ['browser_open_window', JSON.stringify({ sessionId: SESSION, profile: SESSION, url: proxyBase })], 60000);+  steps.push([hasSession ? 'browser_navigate' : 'browser_open_window', opened]);+  if (!looksOk(opened) && !hasSession) {     // A first open on a cold profile can outlive its exec timeout while the-    // window is still coming up; poll for the session before calling desktop-    // dead, or a slow start gets misread as PUP_UNREACHABLE.-    for (let i = 0; i < 6 && opened === null; i++) {+    // window is still coming up; poll for the session before calling it dead.+    for (let i = 0; i < 6 && !looksOk(opened); i++) {       await new Promise((r) => setTimeout(r, 5000));-      const st = await sh('adom-desktop', ['browser_status', '{}']);-      if (st && st.includes('hero-studio')) opened = st;+      const st = await shv('adom-desktop', ['browser_status', '{}']);+      if (st.ok && st.out.includes(SESSION)) opened = { ok: true, out: st.out, errOut: '', fail: '' };     }   }-  if (opened === null) {-    console.error('WARN [PUP_UNREACHABLE]: Adom Desktop did not answer; falling back to a webview tab so the gate still reaches the human.');-    await openWebview();-  } else {-    await sh('adom-desktop', ['browser_alert_window', JSON.stringify({ sessionId: 'hero-studio' })]);+  if (!looksOk(opened)) {+    // hero-studio#12: NO silent webview fallback. The user asked for pup;+    // landing them on a surface they didn't ask for (and fighting the+    // never-both-surfaces rule) is worse than failing with the reason.+    console.error('ERROR [PUP_OPEN_FAILED]: could not open the studio on the pup surface. What each step actually said:');+    for (const [name, r] of steps) {+      const detail = (r.fail ? r.fail + '; ' : '') + (r.out || r.errOut || '(no output)').trim().slice(0, 300);+      console.error('  ' + name + ': ' + detail.replace(/\n/g, ' '));+    }+    console.error('  likely causes: the Adom Desktop app is not running / the relay is down / the verb was');+    console.error('  refused (approval or caller-identity gate; see the adom-desktop-permissions skill) /');+    console.error('  a cold profile outliving every timeout.');+    console.error('  Fix the cause and re-run, or re-run with --surface webview if the human agrees.');+    process.exit(3);   }+  await sh('adom-desktop', ['browser_alert_window', JSON.stringify({ sessionId: SESSION })]); } else {   await openWebview(); }@@ -184,7 +244,7 @@ if (!(state.vitrine && state.vitrine.src)) {  // 4. block until a HUMAN clicks Generate (a new _gen seq) const baseline = (state._gen && state._gen.seq) || 0;-console.error('studio open in tab "' + tabName + '", posed for ' + ref + '. Waiting for a human to click Generate (timeout ' + timeoutSecs + 's)...');+console.error('studio open (' + surface + ', :' + port + ', tab "' + tabName + '"), posed for ' + ref + '. Waiting for a human to click Generate (timeout ' + timeoutSecs + 's)...'); const deadline = Date.now() + timeoutSecs * 1000; while (Date.now() < deadline) {   await new Promise((r) => setTimeout(r, 2000));@@ -193,7 +253,7 @@ while (Date.now() < deadline) {   const g = st._gen;   if (g && g.ok && g.seq > baseline) {     const abs = path.join(DIR, 'generated', g.file);-    console.log(JSON.stringify({ ok: true, ref, file: g.file, path: fs.existsSync(abs) ? abs : path.join(DIR, g.url), latest: path.join(DIR, 'generated', 'latest.png') }));+    console.log(JSON.stringify({ ok: true, ref, port, file: g.file, path: fs.existsSync(abs) ? abs : path.join(DIR, g.url), latest: path.join(DIR, 'generated', 'latest.png') }));     process.exit(0);   } }
public/index.html+225−13
@@ -286,6 +286,17 @@ label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:po .pill.skill { color:#b39dfb; border-color:rgba(140,107,247,.5); background:rgba(140,107,247,.12); } .pill.bootstrap { color:#79b8ff; border-color:rgba(88,166,255,.5); background:rgba(88,166,255,.12); } .pill.component { color:#e3b341; border-color:rgba(210,153,34,.5); background:rgba(210,153,34,.12); }+.pill.service { color:#ff9d76; border-color:rgba(248,110,73,.5); background:rgba(248,110,73,.12); }+/* pill group (#19): multi-type pages render one clustered badge box */+.pill-tr { display:flex; gap:.5cqw; }+/* pill over vitrine (#17): the scrim makes it read as a deliberate badge, not+   debris on the app's own corner UI. Placement stays top-right per contract. */+.pill-tr.scrim .pill { background:rgba(8,12,18,.72); backdrop-filter:blur(5px); box-shadow:0 .2cqw 1cqw rgba(0,0,0,.5); }+/* dynamic-vs-baked (#16): the overlay breathes, tags itself, and hold-hides */+.hero.show-mq #mqoverlay { animation:mqbreathe 3.4s ease-in-out infinite; cursor:pointer; }+@keyframes mqbreathe { 0%,100% { opacity:1; } 50% { opacity:.5; } }+#mqoverlay::after { content:'DYNAMIC \00b7 homepage adds this, NOT in your PNG \00b7 hold to hide'; position:absolute; right:1.2cqw; top:.6cqw; font-family:'JetBrains Mono',monospace; font-size:.85cqw; letter-spacing:.06em; color:#e8a13c; }+.hero.mq-hold #mqoverlay { display:none !important; } .obadge {   display:flex; align-items:center; gap:.42cqw; font-family:'JetBrains Mono',monospace;   font-size:.95cqw; color:#aeb8c4;@@ -443,8 +454,21 @@ body[data-mode=component] .mode-app { display:none; } #verifybar .vhead { font-size:13px; color:var(--text); margin-bottom:10px; line-height:1.5; } #verifybar .vchk { display:block; margin:6px 0; } #verifybar .vchk .warn { color:#e8a13c; }+#verifybar .vchk .flagged { color:#f85149; font-weight:600; margin-left:6px; } #v-generate { margin-top:10px; font-size:14px; padding:10px 18px; } #v-generate:disabled { opacity:.45; cursor:not-allowed; }+/* the layout linter (#18): the studio notices, the human decides */+#lintbox { margin-top:8px; }+#lintbox .lint-item { font-size:12.5px; margin:4px 0; cursor:pointer; line-height:1.4; }+#lintbox .lint-warn { color:#e8a13c; }+#lintbox .lint-fail { color:#f85149; font-weight:600; }+#lintbox .lint-ok { color:#3fb950; font-size:12px; }+#tourpop .tp-flag { background:rgba(248,81,73,.12); border:1px solid rgba(248,81,73,.6); color:#f85149; }+#tourpop .tp-head { cursor:grab; user-select:none; }+#tourpop.dragging .tp-head { cursor:grabbing; }+/* zen focus (#12): the hero gets the whole surface */+body.zen #panel { display:none; }+body.zen main { max-width:none; } /* new background families so heroes stop looking identical */ .hero[data-theme=aurora] {   background:@@ -540,6 +564,9 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh   <div class="vsep"></div>   <div class="subject"><b id="subj-title">...</b><span class="meta" id="subj-meta"></span></div>   <div class="actions">+    <button class="iconbtn" id="btn-zen" data-tip="Focus mode: hide the panel so the hero gets the whole surface (Esc restores); also requests browser fullscreen">+      <svg viewBox="0 0 24 24"><polyline points="9 3 3 3 3 9"/><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><polyline points="15 21 21 21 21 15"/></svg>+    </button>     <button class="iconbtn" id="btn-guides" data-tip="Toggle the lower-third guide line on the canvas">       <svg viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.6"/></svg>     </button>@@ -683,6 +710,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh       <label class="chk vchk"><input type="checkbox" id="v-sub"> Subtitle (value-prop) reads right</label>       <label class="chk vchk"><input type="checkbox" id="v-vitrine"> Vitrine (the app window) shows the right content</label>       <label class="chk vchk"><input type="checkbox" id="v-type"> <span id="v-type-label">Page type matches</span></label>+      <div id="lintbox"></div>       <button class="btn btn-primary" id="v-generate" disabled>Generate hero PNG</button>     </div>     <div id="hero" class="hero" data-theme="midnight" data-layout="bleed-bottom">@@ -803,10 +831,13 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh </main>  <div id="tourpop">-  <div class="tp-title" id="tp-title"></div>+  <div class="tp-head" data-tip="Drag me out of the way to inspect what is underneath">+    <div class="tp-title" id="tp-title"></div>+  </div>   <div class="tp-body" id="tp-body"></div>   <div class="tp-row">     <button class="btn btn-primary" id="tp-ok">Looks good</button>+    <button class="btn tp-flag" id="tp-flag" data-tip="Flag this item: Generate stays locked and the driving AI sees the flag in state, so it can adjust and re-present">Needs fixing</button>     <button class="btn" id="tp-skip">Skip tour</button>   </div> </div>@@ -904,14 +935,31 @@ function render() {    // verification chrome (the pose gate sets state.verify); per-item confirms   const confirm = state.confirm || {};+  const flags = state.flags || {};   $('v-title').checked = !!confirm.title;   $('v-sub').checked = !!confirm.subtitle;   $('v-vitrine').checked = !!confirm.vitrine;   $('v-type').checked = !!confirm.type;+  // "Needs fixing" verdicts (#14): flags hold Generate and are visible to the+  // driving AI via state, so it can adjust and re-present instead of the+  // human laundering a problem through an approve-only checklist.+  for (const [el, key] of [['v-title','title'],['v-sub','subtitle'],['v-vitrine','vitrine'],['v-type','type']]) {+    const label = $(el).parentElement;+    let mark = label.querySelector('.flagged');+    if (flags[key] && !mark) {+      mark = document.createElement('span');+      mark.className = 'flagged';+      mark.textContent = 'NEEDS FIXING (flagged; the AI sees this in state.flags)';+      label.appendChild(mark);+    } else if (!flags[key] && mark) mark.remove();+  }+  const types = (Array.isArray(d.types) && d.types.length ? d.types : [d.type]);   const modeMatchesType = (d.type === 'component') === (mode === 'component');-  $('v-type-label').textContent = 'Page type matches: page is "' + d.type + '", studio mode is "' + mode + '"' + (modeMatchesType ? '' : ' (MISMATCH: switch mode at the top of the panel)');+  $('v-type-label').textContent = 'Page type matches: page is "' + types.join(' + ') + '", studio mode is "' + mode + '"' + (modeMatchesType ? '' : ' (MISMATCH: switch mode at the top of the panel)');   $('v-type-label').classList.toggle('warn', !modeMatchesType);-  $('v-generate').disabled = !(confirm.title && confirm.subtitle && confirm.vitrine && confirm.type);+  const anyFlag = Object.values(flags).some(Boolean);+  const lintFail = (state.lint || []).some(l => l.level === 'fail');+  $('v-generate').disabled = !(confirm.title && confirm.subtitle && confirm.vitrine && confirm.type) || anyFlag || lintFail;    // author-adjustable image-zone type (hero-studio#4); cqw so preview == render   const fs = state.fontSize || {};@@ -933,8 +981,9 @@ function render() {   }   $('verifybar').style.display = state.verify && !approvedHere ? 'block' : 'none'; -  // walkthrough + procedural background+  // walkthrough + procedural background + measured layout lint   showTourStep();+  scheduleLint();   if (state.theme === 'circuitry') requestAnimationFrame(drawCircuitry);   $('btn-reroll').style.display = state.theme === 'circuitry' ? '' : 'none'; @@ -1030,9 +1079,11 @@ function render() {   // component mode for the MPN-in-overlay placement option   hero.querySelector('.lower').style.display = mode === 'app' ? 'none' : '';   const showTitleInOverlay = mode === 'component' || state.titleMode !== 'image-only';-  $('l-pill').style.display = state.overlay.pill ? '' : 'none';-  $('l-pill').textContent = d.type.toUpperCase();-  $('l-pill').className = 'pill ' + d.type;+  // Pill GROUP (#19): a page carrying several types (app + service) renders+  // them as one clustered badge box; the linter treats the cluster as one box.+  const ptr = $('pill-tr');+  ptr.style.display = state.overlay.pill ? '' : 'none';+  ptr.innerHTML = types.map(t => '<span class="pill ' + t + '">' + String(t).toUpperCase() + '</span>').join('');   $('l-trend').style.display = state.overlay.scores ? '' : 'none';   $('l-trend').querySelector('b').textContent = d.score.toLocaleString('en-US');   $('l-stars').style.display = state.overlay.scores ? '' : 'none';@@ -1277,12 +1328,18 @@ $('btn-reset').addEventListener('click', () => {  /* verification checklist + vitrine controls */ [['v-title','title'],['v-sub','subtitle'],['v-vitrine','vitrine'],['v-type','type']].forEach(([el, key]) => {-  $(el).addEventListener('change', e => send({ confirm: { [key]: e.target.checked } }));+  // ticking a box also clears any needs-fixing flag on that item (#14)+  $(el).addEventListener('change', e => send({ confirm: { [key]: e.target.checked }, flags: { [key]: false } })); }); $('verifybar').addEventListener('click', e => {   if (e.target.id !== 'v-generate' && !$('v-generate').disabled) return;   if ($('v-generate').disabled && (e.target.id === 'v-generate' || e.target.closest('#v-generate'))) {-    handleToast({ text: 'Confirm all four checks first; Generate unlocks when every item is ratified', kind: 'warn', seq: Date.now() });+    const flags = Object.entries(state.flags || {}).filter(([, v]) => v).map(([k]) => k);+    const fails = (state.lint || []).filter(l => l.level === 'fail');+    let why = 'Confirm all four checks first; Generate unlocks when every item is ratified';+    if (flags.length) why = 'Generate is locked: ' + flags.join(', ') + ' flagged as needs-fixing. Fix and re-tick, or the AI adjusts and re-presents';+    else if (fails.length) why = 'Generate is locked by the layout linter: ' + fails.map(f => f.msg).join(' | ');+    handleToast({ text: why, kind: 'warn', seq: Date.now() });   } }); $('vitrine-file').addEventListener('change', e => {@@ -1333,7 +1390,10 @@ function clearTourGlow() { function showTourStep() {   clearTourGlow();   const confirm = state.confirm || {};-  const remaining = TOUR_STEPS.filter(st => !confirm[st.key]);+  const flags = state.flags || {};+  // a flagged step is reviewed (verdict: needs fixing), so the tour moves on;+  // the flag itself keeps Generate locked until it is cleared+  const remaining = TOUR_STEPS.filter(st => !confirm[st.key] && !flags[st.key]);   if (!state.tour || !state.verify || remaining.length === 0) {     $('tourpop').style.display = 'none';     if (state.tour && remaining.length === 0) send({ tour: false });@@ -1377,16 +1437,168 @@ function placeTourPop(pop, target, r) {   }   document.querySelectorAll('.tour-above').forEach(n => n.classList.remove('tour-above'));   if (hides(spot)) target.classList.add('tour-above');-  pop.style.top = spot.top + 'px';-  pop.style.left = spot.left + 'px';+  // apply the human's drag displacement (#13): anchored spawn gives context,+  // the remembered offset keeps later steps out of the way they chose+  pop._anchor = { top: spot.top, left: spot.left };+  const top = Math.max(4, Math.min(window.innerHeight - 60, spot.top + tpDrag.dy));+  const left = Math.max(4, Math.min(window.innerWidth - 80, spot.left + tpDrag.dx));+  pop.style.top = top + 'px';+  pop.style.left = left + 'px'; } window.addEventListener('resize', () => { if (state.tour && state.verify) showTourStep(); });++/* ---- layout linter (#18): the studio notices, the human decides ----+   Most engineers are not graphic designers; the studio is the only party that+   reliably knows the geometry, so it encodes the judgment. Runs after every+   render (debounced), posts results to state.lint (visible to the driving AI),+   and hard failures (clipped text) lock Generate. Warnings advise. */+let lintTimer = null;+let lastLintKey = '';+function scheduleLint() { clearTimeout(lintTimer); lintTimer = setTimeout(runLint, 450); }+function runLint() {+  if (!state) return;+  const hero = $('hero');+  const H = hero.getBoundingClientRect();+  if (H.width < 60) return;+  const items = [];+  const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height });+  const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2;+  const boxOf = (el) => rel(el.getBoundingClientRect());+  const overlapPx = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));++  const ptrEl = $('pill-tr');+  const pill = vis(ptrEl) ? boxOf(ptrEl) : null;+  const winEl = hero.querySelector('.window');+  const vitrine = (winEl && winEl.offsetParent && state.mode !== 'component') ? boxOf(winEl) : null;+  const texts = [['title', 'el-title'], ['subtitle', 'el-sub'], ['detail line', 'el-desc']]+    .filter(([, id]) => vis($(id)))+    .map(([name, id]) => ({ name, id, box: boxOf($(id)) }));++  // 1. pill vs vitrine / pill vs baked text (#17). Pill placement is+  // contract-fixed top right, so the in-contract fix is a backdrop scrim+  // (reads as a deliberate badge) plus this warning.+  ptrEl.classList.toggle('scrim', !!(pill && vitrine && overlapPx(pill, vitrine) > 9));+  if (pill && vitrine && overlapPx(pill, vitrine) > 9) {+    items.push({ id: 'pill-vitrine', level: 'warn', el: 'pill-tr', msg: 'type pill sits on the vitrine (scrim applied so it reads as a badge). If the app has its own corner UI there, pick a layout that leaves the top-right clear' });+  }+  for (const t of texts) {+    if (pill && overlapPx(pill, t.box) > 9) items.push({ id: 'pill-' + t.id, level: 'warn', el: 'pill-tr', msg: 'type pill overlaps the ' + t.name });+  }++  // 2. image-zone text intruding into the dynamic marquee band (#14): the+  // homepage overlays its own info over the lower band; keep it calm.+  const bandTop = H.height * 0.76;+  for (const t of texts) {+    if (t.box.bottom > bandTop + 2) {+      items.push({ id: 'band-' + t.id, level: 'warn', el: t.id, msg: t.name + ' extends ' + Math.round(t.box.bottom - bandTop) + 'px into the dynamic marquee band; shrink the title font or shorten the copy so the lower band stays calm' });+    }+  }++  // 3. clipped text = hard fail (the registry would ship a broken hero);+  // near-edge text = warning. The vitrine is exempt: bleeding off the edge is+  // a deliberate layout feature.+  const inset = H.width * 0.015;+  for (const t of texts) {+    if (t.box.left < -1 || t.box.top < -1 || t.box.right > H.width + 1 || t.box.bottom > H.height + 1) {+      items.push({ id: 'clip-' + t.id, level: 'fail', el: t.id, msg: t.name + ' is CLIPPED by the hero edge; Generate stays locked until it fits' });+    } else if (t.box.left < inset || t.box.top < inset || H.width - t.box.right < inset) {+      items.push({ id: 'inset-' + t.id, level: 'warn', el: t.id, msg: t.name + ' is closer than 1.5% to the hero edge' });+    }+  }++  // 4. title wrap count: a 4-line title is almost never intended.+  const tEl = $('el-title');+  if (vis(tEl)) {+    const lh = parseFloat(getComputedStyle(tEl).lineHeight);+    const lines = lh ? Math.round(tEl.getBoundingClientRect().height / lh) : 1;+    if (lines >= 4) items.push({ id: 'title-wrap', level: 'warn', el: 'el-title', msg: 'title wraps to ' + lines + ' lines; shorten it or lower the title size (left panel)' });+  }++  renderLintBox(items);+  // publish to the server (deduped) so the driving AI sees measured findings+  const key = JSON.stringify(items.map(i => i.id + i.level));+  if (key !== lastLintKey) {+    lastLintKey = key;+    send({ lint: items.map(({ id, level, msg }) => ({ id, level, msg })) });+  }+}+function renderLintBox(items) {+  const box = $('lintbox');+  if (!state.verify) { box.innerHTML = ''; return; }+  if (!items.length) { box.innerHTML = '<div class="lint-ok">layout linter: no findings (pill, margins, band, clipping, wrap all clear)</div>'; return; }+  box.innerHTML = items.map((i, n) =>+    '<div class="lint-item lint-' + i.level + '" data-el="' + (i.el || '') + '" data-n="' + n + '">' ++    (i.level === 'fail' ? 'BLOCKS GENERATE: ' : '') + i.msg + '</div>').join('');+}+$('lintbox').addEventListener('click', (e) => {+  const item = e.target.closest('.lint-item');+  if (!item || !item.dataset.el) return;+  const el = $(item.dataset.el);+  if (!el) return;+  clearTourGlow();+  el.classList.add('tour-glow');+  setTimeout(() => el.classList.remove('tour-glow'), 2200);+});++/* draggable tour dialogs (#13): pointer-capture drag on the header, clamped;+   the offset persists across steps for this tour session */+let tpDrag = { dx: 0, dy: 0 };+(() => {+  const pop = $('tourpop');+  const head = pop.querySelector('.tp-head');+  let grab = null;+  head.addEventListener('pointerdown', (e) => {+    grab = { x: e.clientX - pop.offsetLeft, y: e.clientY - pop.offsetTop };+    pop.classList.add('dragging');+    head.setPointerCapture(e.pointerId);+  });+  head.addEventListener('pointermove', (e) => {+    if (!grab) return;+    const left = Math.max(4, Math.min(window.innerWidth - 80, e.clientX - grab.x));+    const top = Math.max(4, Math.min(window.innerHeight - 60, e.clientY - grab.y));+    pop.style.left = left + 'px';+    pop.style.top = top + 'px';+    if (pop._anchor) tpDrag = { dx: left - pop._anchor.left, dy: top - pop._anchor.top };+  });+  const drop = () => { grab = null; pop.classList.remove('dragging'); };+  head.addEventListener('pointerup', drop);+  head.addEventListener('pointercancel', drop);+})(); $('tp-ok').addEventListener('click', () => {   const step = TOUR_STEPS[tourIdx];-  if (step) send({ confirm: { [step.key]: true } });+  if (step) send({ confirm: { [step.key]: true }, flags: { [step.key]: false } });+});+$('tp-flag').addEventListener('click', () => {+  const step = TOUR_STEPS[tourIdx];+  if (!step) return;+  send({ flags: { [step.key]: true }, confirm: { [step.key]: false } });+  handleToast({ text: step.key + ' flagged as needs-fixing: Generate stays locked and the driving AI can read the flag from state and adjust', kind: 'warn', seq: Date.now() }); }); $('tp-skip').addEventListener('click', () => { clearTourGlow(); $('tourpop').style.display = 'none'; send({ tour: false }); }); +/* hold-to-hide the dynamic overlay (#16): press the band to see the pure+   export, release to bring the overlay back */+(() => {+  const mq = $('mqoverlay');+  const hero = $('hero');+  mq.addEventListener('pointerdown', (e) => { e.preventDefault(); hero.classList.add('mq-hold'); });+  document.addEventListener('pointerup', () => hero.classList.remove('mq-hold'));+  document.addEventListener('pointercancel', () => hero.classList.remove('mq-hold'));+})();++/* zen focus (#12): the hero gets the whole surface */+$('btn-zen').addEventListener('click', () => {+  const on = document.body.classList.toggle('zen');+  $('btn-zen').classList.toggle('on', on);+  try { on ? document.documentElement.requestFullscreen() : document.exitFullscreen(); } catch {}+});+document.addEventListener('keydown', (e) => {+  if (e.key === 'Escape' && document.body.classList.contains('zen')) {+    document.body.classList.remove('zen');+    $('btn-zen').classList.remove('on');+  }+});+ /* procedural circuitry background (seeded, gently different every roll) */ function mulberry32(a) {   return function () {
server.js+7−1
@@ -4,7 +4,11 @@ const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process'); -const PORT = 8846;+// Port: --port flag > HERO_STUDIO_PORT env > 8846. Multiple instances on+// separate ports = concurrent studios (hero-studio#15): one thread's pose+// cannot clobber another's when each pose owns its own server.+const portArg = process.argv.indexOf('--port');+const PORT = Number((portArg >= 0 && process.argv[portArg + 1]) || process.env.HERO_STUDIO_PORT || 8846); const ROOT = path.join(__dirname, 'public'); const PAGES = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pages.json'), 'utf8')); // The provenance stamp reads the REAL version so it stays truthful across@@ -24,6 +28,8 @@ let state = {   brand: { accent: '', bg: '' },  // non-adom owner palette; empty = studio default   verify: false,  // the pose gate turns this on: verification chrome + checklist   confirm: { title: false, subtitle: false, vitrine: false, type: false },+  flags: { title: false, subtitle: false, vitrine: false, type: false },  // needs-fixing verdicts (#14): hold Generate, visible to the driving AI+  lint: [],  // measured layout findings from the UI's linter (#18): [{id, level: warn|fail, msg}]   vitrine: { src: '', full: false, cover: false, chrome: '' },  // imported screenshot (data URL); full = whole-hero re-stamp; cover = crop-to-fill; chrome '' = auto (fake bar for the mock, none for imports), 'fake'|'none' = explicit   marqueePreview: false,  // composite today's homepage-marquee overlay over the hero (preview only)   tour: false,            // verification walkthrough: spotlight title -> subtitle -> vitrine -> type