app
Hero Studio
Public Made by Adomby adom
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. Every hero wears its P
← Commit history
Publish 0.12.0
8 files changed
+371−56
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