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.16.0
10 files changed
+397−69
SKILL.md+55−17@@ -16,7 +16,7 @@ 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"] [--type app,service] [--timeout 900] [--port N]+hero-studio pose <owner>/<slug> --surface webview|pup [--title "T"] [--brief "one-liner"] [--type app,service] [--headline "H"] [--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)@@ -149,7 +149,13 @@ 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.+(HARD FAIL: locks Generate), near-edge insets, 4+ line title wraps, TEXT+COLLIDING WITH THE VITRINE (the fix is ALWAYS to move or resize the vitrine,+never to shrink the title; a title under 6.5cqw draws its own warning because+dock thumbnails render it illegibly), OPTICAL alignment (the title's ink edge+vs the subtitle's, canvas-measured, because glyph side bearing shifts pixels+off the box edge), and an informational published-headroom line (export px+between the lowest text and the marquee band). Findings render in the gate and publish to `state.lint` ([{id, level: warn|fail, msg}]); the AI should fix warns without being asked. FULL-HERO IMPORTS are never reported all-clear: the linter discloses that@@ -192,9 +198,12 @@ follow before letting the human generate: 1. Capture the app's REAL UI at the slot size pose printed (also live in the left panel, click-to-copy), so nothing crops. In pup:- `adom-desktop browser_open_window '{"sessionId":"vitrine-shot","profile":"vitrine-shot","url":"<app url>"}'`+ `adom-bridge-cli pup_open_window '{"sessionId":"vitrine-shot","profile":"vitrine-shot","url":"<app url>"}'` then size the viewport to the slot aspect and- `adom-desktop browser_screenshot '{"sessionId":"vitrine-shot"}'`.+ `adom-bridge-cli pup_screenshot '{"sessionId":"vitrine-shot"}'`.+ (Legacy boxes still ship `adom-desktop` with `browser_*` verb names; the+ studio's own pup ladder auto-resolves whichever generation the box has,+ hero-studio#31, and doctor prints which one answered.) 2. Load it: `hero-studio vitrine --file <shot.png>` (the open studio updates live within a second). @@ -208,14 +217,28 @@ so every command lands in front of the human as you run it: ```bash hero-studio set theme=aurora gradAngle=220 # or: set '{"layout":"showcase"}' hero-studio set fontSize.title=9.5 # dot-paths build nested patches+hero-studio set fontSize.leading=0.96 fontSize.gap=1.2 # title line-height + text-block gap+hero-studio set headline="Pose. Approve. Ship." # what BAKES as the title; page title stays metadata hero-studio set --list # the valid enum tokens (also in GET /state as `enums`)-hero-studio vitrine --file shot.png [--full] [--cover] [--chrome none|fake]+hero-studio preset bridge-family # apply a family design system in one call; no name lists them+hero-studio vitrine --file shot.png [--full] [--cover] [--chrome none|fake|bare] hero-studio bg --file tile.png [--cover] # YOUR brand texture as theme 'image'; composed text + linting still apply hero-studio state # read everything, incl. slotSize + enums+hero-studio compose preview.png # headless compose of the CURRENT pose: verify staging without a browser (no provenance stamp; never publishable) hero-studio toast "trying the showcase layout" --kind info hero-studio reroll # new procedural circuitry pattern+hero-studio instances # every live studio on ports 8846-8866 (port, page, gate open?)+hero-studio stop --port N # polite shutdown of one instance; no zombie servers ``` +HEADLINE vs TITLE (hero-studio#33): the baked hero text is the HEADLINE+(`state.headline`, `--headline`, or the Headline field at the gate); when+empty, the page title is used. Punching up the hero copy therefore never+mutates page metadata, and Apply/changelog only carry title edits the human+made on purpose. The gate always shows the LIVE page: pose and the page+dropdown re-fetch title/brief from the wiki (#30), so a stale fixture row+cannot be ratified.+ Invalid enum values are REJECTED with the valid list (400 from /state); an ok:true means the value really landed. state.lint is computed by the OPEN studio page, so it is empty until a browser surface has rendered the pose;@@ -225,8 +248,10 @@ band scan) mirrors there for the driving AI to fix before review. The vitrine's FAKE titlebar is auto-off whenever an imported screenshot is present: a real window shot carries its own chrome, and stacking the fake bar above it reads as fake (hero-studio#11). The CSS mock keeps the fake bar.-Override either way with `--chrome none|fake`, the "Draw the fake window-titlebar" checkbox, or `set '{"vitrine":{"chrome":"none"}}'`.+Override either way with `--chrome none|fake|bare`, the "Draw the fake window+titlebar" checkbox, or `set '{"vitrine":{"chrome":"none"}}'`. `bare` goes+further than `none`: no titlebar, no frame border, no background, no shadow;+the import is placed as-is with its alpha preserved (the bridge-family look). Layouts now include `showcase` (the vitrine gets ~65% of the hero behind a gradient scrim, Adom-USB style). Themes add `aurora`, `mesh`, and `circuitry`,@@ -236,10 +261,18 @@ the seed rides the provenance stamp and `reroll` re-rolls it. Full-hero import is the ratification path for an existing image: import the image, check "Use the imported image as the FULL hero", ratify the checklist as the human, Generate, and the export is that image with a legitimate studio-provenance stamp (the type pill and slug bake on top). This is the official-mechanism for keeping a good pre-gate hero through a deliberate hero change;-the registry accepts any studio-stamped PNG, so no server blessing exists or is-needed.+provenance stamp. This is the official mechanism for keeping a good pre-gate+hero through a deliberate hero change; the registry accepts any studio-stamped+PNG, so no server blessing exists or is needed.++Turning full-import ON auto-disables ALL composed overlays (pill, scores,+author, slug): imports usually carry their own, and the sim-on-top double+pill was a whole rejection class (hero-studio#32). Re-enabling one afterwards+is allowed; it renders with an amber dashed fence in the studio marking it as+a simulation (the fence never bakes into the export). When a full import has+every overlay off, Generate BYPASSES the browser entirely (provenance+`renderer: passthrough`): your pixels are decoded, normalized to 2000x1250,+and stamped, with zero recompression drift (hero-studio#36). ## What the human controls @@ -253,11 +286,14 @@ needed. ## What exports -The exported PNG carries: background art, title, subtitle, screenshot, the type-pill (top right), and the full wiki slug (bottom right). The pill and slug BAKE-into the image; the wiki page shows the hero exactly as exported, with no overlay-band. The only surface that overlays dynamic info (stats, author) is the homepage-marquee, and it brings its own, so keep the lower band of the image calm.+The exported PNG carries: background art, headline (or title), subtitle,+screenshot, and the type pill (top right). The pill BAKES into the image; the+wiki page shows the hero exactly as exported, with no overlay band. The baked+bottom-right slug is now OFF by default (hero-studio#29: it doubled with the+marquee's live slug and read as debug text); `set overlay.slug=true` bakes it+deliberately. The only surface that overlays dynamic info (stats, author,+slug) is the homepage marquee, and it brings its own, so keep the lower band+of the image calm. ## Drive the posing state (posing aid, not generation) @@ -277,7 +313,9 @@ State fields: `page` (owner/slug), `layout`, `theme`, `gradAngle`, `titleMode`, (imported content; src is a data URL; cover crops instead of letterboxing), `marqueePreview` (composite today's homepage-marquee overlay for judging composition and title redundancy; never exported), `fontSize.title/sub/desc`-(cqw; author-adjustable image-zone type, defaults tuned for dock legibility),+(cqw) plus `fontSize.leading` (title line-height) and `fontSize.gap` (scales+the gaps between title/subtitle/detail), `headline` (what bakes as the title;+empty = page title), `approved` ({page, file, ts}: the persistent gate-closed record, set on Generate and shown as the green Approved banner), `guides`.
bin/hero-studio+30−1@@ -7,6 +7,35 @@ DIR="$(cd "$(dirname "$SELF")/.." && pwd)" case "$1" in pose) shift; exec node "$DIR/pose.mjs" "$@" ;; doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;- vitrine|set|state|toast|reroll|changes|bg) exec node "$DIR/drive.mjs" "$@" ;;+ vitrine|set|preset|state|toast|reroll|changes|bg|compose|instances|stop) exec node "$DIR/drive.mjs" "$@" ;;+ -h|--help|help)+ # hero-studio#36: --help used to fall through to the server catch-all and+ # silently START an instance; help must never have side effects.+ cat <<'HELP'+hero-studio: pose package heroes in front of a human, generate approved PNGs++ hero-studio start the studio server (foreground; --port <n>)+ hero-studio pose <owner/slug> --surface webview|pup+ open the pose gate and block for approval+ [--title T] [--brief B] [--type app|skill|component[,more]]+ [--headline H] [--fresh-vitrine] [--steal] [--no-fallback]+ [--port <n>] [--timeout SECS]+ hero-studio doctor verify both render paths, print exact fixes+ hero-studio vitrine --file <png> [--full] [--cover] [--chrome none|fake|bare]+ hero-studio bg --file <image> [--cover]+ hero-studio set '<json>' | key=value [...] | set --list+ hero-studio preset [name] apply a family design system; no name lists them+ hero-studio state print live state (includes enums + lint)+ hero-studio changes this review session's ledger (page + pose edits)+ hero-studio compose [out.png] headless compose of the current pose (no stamp)+ hero-studio toast "text" [--kind info|success|warn]+ hero-studio reroll re-roll the circuitry theme pattern+ hero-studio instances list live studios on ports 8846-8866+ hero-studio stop [--port <n>] shut one instance down politely++All drive verbs take --port <n> (or HERO_STUDIO_PORT) to target one of several+concurrent instances. Full reference: the package SKILL.md on the wiki.+HELP+ exit 0 ;; *) exec node "$DIR/server.js" "$@" ;; esac
doctor.mjs+9−5@@ -150,16 +150,20 @@ export async function runChecks() { // desktop relay answering plus a proxy URL the host can reach the studio on. let pupOk = false; const proxied = !!process.env.VSCODE_PROXY_URI;- const ad = await run('adom-desktop', ['browser_status', '{}'], 12000);- const adAnswered = ad.ok && !ad.stdout.includes('"error"');+ // Post-rename boxes answer via adom-bridge-cli with pup_* verbs (#31); the+ // adapter resolves whichever generation this box has.+ const { makeBridge } = await import('./bridge.mjs');+ const bridge = await makeBridge(process.env.ADOM_TARGET || '');+ const ad = await bridge.call('status', {}, 12000);+ const adAnswered = ad.ok && !ad.out.includes('"error"') && !ad.out.includes('_adom_use_cli_instead'); if (adAnswered && proxied) { pupOk = true;- pass('pup fallback', 'Adom Desktop answers; the host browser can render');+ pass('pup fallback', bridge.generation + ' answers (' + bridge.verbs.status + '); the host browser can render'); } else { fail('pup fallback', !adAnswered- ? 'Adom Desktop did not answer browser_status (no host relay from this container)'+ ? 'the desktop bridge did not answer (' + bridge.generation + ', ' + bridge.verbs.status + '); no host relay from this container' : 'VSCODE_PROXY_URI is not set, so the host browser cannot reach the studio',- 'optional when the local renderer works; otherwise start Adom Desktop on the host and retry');+ 'optional when the local renderer works; otherwise start the desktop bridge app on the host and retry'); } // 6. python3 + PIL stamp the provenance metadata after the render (and crop
drive.mjs+52−3@@ -3,7 +3,7 @@ // The open studio follows within a second (SSE + poll), so the human watches // the pose change as commands land. //-// hero-studio vitrine --file <png> [--full] [--cover] [--chrome none|fake] load a real screenshot+// hero-studio vitrine --file <png> [--full] [--cover] [--chrome none|fake|bare] load a real screenshot // hero-studio set '<json>' | key=value [...] patch posing state // hero-studio state print current state // hero-studio toast "<text>" [--kind info|success|warn] narrate to the human@@ -37,7 +37,7 @@ try { const file = path.resolve(args[fi + 1]); const ci = args.indexOf('--chrome'); const chrome = ci >= 0 ? args[ci + 1] : '';- if (chrome && chrome !== 'none' && chrome !== 'fake') die('--chrome takes none|fake (default: auto, which is none for imported screenshots)');+ if (chrome && !['none', 'fake', 'bare'].includes(chrome)) die('--chrome takes none|fake|bare (bare: no frame, bg, or shadow at all; default: auto, which is none for imported screenshots)'); const out = await jf('vitrine-file', { path: file, full: args.includes('--full'),@@ -101,8 +101,57 @@ try { console.log('{"ok":true}'); } else if (verb === 'reroll') { console.log(JSON.stringify(await jf('reroll', {})));+ } else if (verb === 'preset') {+ // named family design systems (#34.2); no arg lists what exists+ const name = args.find((a) => !a.startsWith('--'));+ const r = await fetch(BASE + 'preset', {+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name || '' }),+ });+ const out = await r.json().catch(() => ({}));+ if (!r.ok || out.ok === false) {+ if (out.valid) { console.log(JSON.stringify({ presets: out.valid }, null, 2)); process.exit(name ? 1 : 0); }+ die('ERROR: ' + (out.error || 'preset rejected'));+ }+ console.log(JSON.stringify(out));+ console.error('preset ' + out.applied + ' applied; the open studio updates within a second');+ } else if (verb === 'instances') {+ // every live studio on this box (#26): scan the port neighborhood+ const ports = new Set([PORT]);+ for (let p2 = 8846; p2 <= 8866; p2++) ports.add(p2);+ const rows = [];+ await Promise.all([...ports].map(async (p2) => {+ try {+ const ac = new AbortController();+ const t = setTimeout(() => ac.abort(), 900);+ const r = await fetch('http://localhost:' + p2 + '/state', { signal: ac.signal });+ clearTimeout(t);+ if (!r.ok) return;+ const st = await r.json();+ if (!st || !st.page) return;+ rows.push({ port: p2, page: st.page, gateOpen: !!st.verify, posedAt: st.posedAt || null, approved: st.approved ? st.approved.file : null });+ } catch {}+ }));+ rows.sort((a, b) => a.port - b.port);+ console.log(JSON.stringify({ instances: rows }, null, 2));+ if (!rows.length) console.error('no live studio instances on ports 8846-8866; `hero-studio` or `hero-studio pose ...` starts one');+ } else if (verb === 'stop') {+ // polite shutdown of one instance (#26); refuses nothing, targets by --port+ const out = await jf('shutdown', {});+ console.log(JSON.stringify(out));+ console.error('studio on :' + (out.port || PORT) + ' is shutting down');+ } else if (verb === 'compose') {+ // headless compose of the CURRENT pose (#36.2): verify staging without a+ // browser. Not the gate: no provenance stamp, never publishable.+ const fsm = await import('fs');+ const outFile = path.resolve(args.find((a) => !a.startsWith('--')) || 'compose.png');+ const r = await fetch(BASE + 'compose.png');+ if (!r.ok) die('ERROR: compose render failed (' + r.status + '): ' + (await r.text()).slice(0, 300));+ const buf = Buffer.from(await r.arrayBuffer());+ fsm.writeFileSync(outFile, buf);+ console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length }));+ console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)'); } else {- die('verbs: pose | vitrine | set | state | toast | reroll');+ die('verbs: pose | vitrine | set | preset | state | toast | reroll | changes | bg | compose | instances | stop'); } } catch (e) { die('ERROR: ' + e.message + '\n hint: is the studio server up? `hero-studio` starts it; `hero-studio pose <ref> --surface ...` does everything');
package.json+2−2@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.15.0",+ "version": "0.16.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": [@@ -91,4 +91,4 @@ "render-pup.mjs", "screenshots/hero.png" ]-}+}
page.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.15.0",+ "version": "0.16.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+25−22@@ -15,6 +15,7 @@ import { fileURLToPath } from 'url'; import path from 'path'; import fs from 'fs'; import { runChecks, printReport } from './doctor.mjs';+import { makeBridge } from './bridge.mjs'; const DIR = path.dirname(fileURLToPath(import.meta.url)); let BASE = 'http://localhost:8846/';@@ -66,23 +67,13 @@ const jf = async (p, opts) => { if (!r.ok) throw new Error(p + ' -> ' + r.status); return r.json(); };-// Multiple Adom Desktops on one relay need addressing (#24 ask 3):-// --target <name> or ADOM_TARGET rides every adom-desktop call.+// Multiple desktops on one relay need addressing (#24 ask 3): --target+// <name> or ADOM_TARGET. All desktop-bridge calls go through the adapter+// (hero-studio#31: the Adom Bridge v2 rename changed the binary AND the verb+// prefix; the adapter resolves both at runtime). const AD_TARGET = flags.target || process.env.ADOM_TARGET || '';-const adArgs = (a) => (AD_TARGET ? [...a, '--target', AD_TARGET] : a); const sh = (cmd, a, timeout = 20000) => new Promise((res) => {- execFile(cmd, cmd === 'adom-desktop' ? adArgs(a) : 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, cmd === 'adom-desktop' ? adArgs(a) : 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 ?? '?')) : '',- }));+ execFile(cmd, a, { timeout }, (err, stdout) => res(err ? null : stdout)); }); // 1. pick an instance (hero-studio#15: concurrent studios). Explicit@@ -158,6 +149,9 @@ await jf('state', { confirm: { title: false, subtitle: false, vitrine: false, type: false }, flags: { title: false, subtitle: false, vitrine: false, type: false }, lint: [],+ // #33: the baked headline is pose state, distinct from the page title;+ // --headline sets it, otherwise reset so the previous page's cannot leak+ headline: flags.headline || '', posedAt: Date.now(), // Clear the previous package's vitrine so a failed auto-pull can never // leave sibling A's screenshot in sibling B's hero -- but PRESERVE staged@@ -169,6 +163,13 @@ await jf('state', { }), }); +// 2a-live. re-pull title/brief/type from the wiki (#30/#33): the seeded row+// above is only a fallback; the LIVE page wins when it exists.+try {+ const lv = await jf('refresh-page?page=' + ref, { method: 'POST' });+ if (lv && lv.ok) console.error('live page: "' + lv.title + '"');+} catch {}+ // 2b. fresh change ledger for THIS review session (#20): everything the human // edits at the gate (page fields + pose deltas) accumulates from here and // rides back in this command's final JSON as `changes`.@@ -195,21 +196,23 @@ if (surface === 'pup') { // 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 bridge = await makeBridge(AD_TARGET);+ console.error('desktop bridge: ' + bridge.generation + ' (verbs: ' + bridge.verbs.open_window + ' family)'); const steps = [];- const status = await shv('adom-desktop', ['browser_status', '{}']);- steps.push(['browser_status', status]);+ const status = await bridge.call('status', {}, 15000);+ steps.push([bridge.verbs.status, status]); const hasSession = status.ok && status.out.includes(SESSION);- const looksOk = (r) => r.ok && !r.out.includes('"error"');+ const looksOk = (r) => r.ok && !r.out.includes('"error"') && !r.out.includes('_adom_use_cli_instead'); let opened = 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]);+ ? await bridge.call('navigate', { sessionId: SESSION, url: proxyBase }, 30000)+ : await bridge.call('open_window', { sessionId: SESSION, profile: SESSION, url: proxyBase }, 60000);+ steps.push([hasSession ? bridge.verbs.navigate : bridge.verbs.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 it dead. for (let i = 0; i < 6 && !looksOk(opened); i++) { await new Promise((r) => setTimeout(r, 5000));- const st = await shv('adom-desktop', ['browser_status', '{}']);+ const st = await bridge.call('status', {}, 15000); if (st.ok && st.out.includes(SESSION)) opened = { ok: true, out: st.out, errOut: '', fail: '' }; } }
public/index.html+99−8@@ -202,11 +202,11 @@ label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:po mask:url('adom-logo.svg') no-repeat left center / contain; } .h-title {- font-family:'Familjen Grotesk'; font-weight:700; font-size:var(--fs-title, 8cqw); line-height:1.02;- letter-spacing:-.14cqw; color:#e6edf3; margin-bottom:1.8cqw;+ font-family:'Familjen Grotesk'; font-weight:700; font-size:var(--fs-title, 8cqw); line-height:var(--fs-leading, 1.02);+ letter-spacing:-.14cqw; color:#e6edf3; margin-bottom:calc(1.8cqw * var(--fs-gap, 1)); } .h-title { max-width:30cqw; }-.h-sub { font-size:var(--fs-sub, 2.5cqw); line-height:1.45; color:#e8eef4; margin-bottom:1.2cqw;+.h-sub { font-size:var(--fs-sub, 2.5cqw); line-height:1.45; color:#e8eef4; margin-bottom:calc(1.2cqw * var(--fs-gap, 1)); max-width:30cqw; text-shadow:0 1px 6px rgba(0,0,0,.45); } .hero[data-theme=gradient] .h-sub { color:#f6fafd; } .h-sub .accent { background:var(--grad); -webkit-background-clip:text; background-clip:text; color:transparent; font-weight:500; }@@ -476,6 +476,7 @@ body[data-mode=component] .mode-app { display:none; } #lintbox .lint-warn { color:#e8a13c; } #lintbox .lint-fail { color:#f85149; font-weight:600; } #lintbox .lint-ok { color:#3fb950; font-size:12px; }+#lintbox .lint-info { color:#8b949e; } #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; }@@ -516,6 +517,14 @@ body.zen main { max-width:none; } #tourpop .tp-row { display:flex; gap:8px; } .tour-above { position:relative !important; z-index:901 !important; } .hero.no-chrome .window .titlebar { display:none; }+/* bare (#34.1): the import IS the artwork; no frame, no bg, no shadow, alpha kept */+.hero.bare-chrome .window .titlebar { display:none; }+.hero.bare-chrome .window { background:transparent; border:none; box-shadow:none; border-radius:0; }+/* simulated-overlay fence (#32.3): overlays re-enabled OVER a full import are+ sims of elements the import may already carry; amber marks them as such */+.sim-fenced { outline:2px dashed #d29922; outline-offset:3px; }+/* the fence is studio chrome: it must never bake into the exported PNG */+body[data-render] .sim-fenced { outline:none !important; } .tour-glow { outline:3px solid var(--accent-bright) !important; outline-offset:3px; border-radius:4px; box-shadow:0 0 24px rgba(0,230,220,.35) !important; } @@ -705,9 +714,12 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh <label class="chk"><input type="checkbox" id="chk-title"> Title (big headline)</label> <label class="chk"><input type="checkbox" id="chk-subtitle"> Value-prop subtitle</label> <label class="chk"><input type="checkbox" id="chk-description"> Detail line</label>- <div class="brandrow"><label>Title size</label><input type="range" id="fs-title" min="5" max="11" step="0.1" value="8"><span class="hint" id="fs-title-val">8</span></div>+ <div class="brandrow"><label>Title size</label><input type="range" id="fs-title" min="5" max="13" step="0.1" value="8"><span class="hint" id="fs-title-val">8</span></div> <div class="brandrow"><label>Subtitle size</label><input type="range" id="fs-sub" min="1.8" max="3.5" step="0.1" value="2.5"><span class="hint" id="fs-sub-val">2.5</span></div> <div class="brandrow"><label>Detail size</label><input type="range" id="fs-desc" min="1.0" max="2.0" step="0.05" value="1.35"><span class="hint" id="fs-desc-val">1.35</span></div>+ <div class="brandrow" data-tip="Title line-height: tighten multi-line titles without touching size"><label>Title leading</label><input type="range" id="fs-leading" min="0.92" max="1.3" step="0.01" value="1.02"><span class="hint" id="fs-leading-val">1.02</span></div>+ <div class="brandrow" data-tip="Scales the vertical gaps between title, subtitle and detail line"><label>Text gap</label><input type="range" id="fs-gap" min="0.5" max="2" step="0.05" value="1"><span class="hint" id="fs-gap-val">1</span></div>+ <div class="brandrow" data-tip="What bakes as the hero title. The page title is metadata and stays untouched; leave empty to use it (hero-studio#33)"><label>Headline</label><input type="text" id="headline-input" placeholder="(page title)" style="flex:1;min-width:0"></div> <div class="hint">The defaults are tuned to read at dock size and set you up for success. You are the author: adjust if your name or copy needs it, thoughtfully.</div> <h3>Title placement</h3>@@ -1013,7 +1025,9 @@ function render() { hero.style.setProperty('--fs-title', (fs.title || 8) + 'cqw'); hero.style.setProperty('--fs-sub', (fs.sub || 2.5) + 'cqw'); hero.style.setProperty('--fs-desc', (fs.desc || 1.35) + 'cqw');- for (const [id, val, dflt] of [['fs-title', fs.title, 8], ['fs-sub', fs.sub, 2.5], ['fs-desc', fs.desc, 1.35]]) {+ hero.style.setProperty('--fs-leading', String(fs.leading || 1.02));+ hero.style.setProperty('--fs-gap', String(fs.gap == null ? 1 : fs.gap));+ for (const [id, val, dflt] of [['fs-title', fs.title, 8], ['fs-sub', fs.sub, 2.5], ['fs-desc', fs.desc, 1.35], ['fs-leading', fs.leading, 1.02], ['fs-gap', fs.gap, 1]]) { $(id).value = val || dflt; $(id + '-val').textContent = (val || dflt); }@@ -1089,8 +1103,14 @@ function render() { // an import is present; the CSS mock keeps it. Explicit choice wins. const chromeMode = vit.chrome || ((vit.src && !vit.full) ? 'none' : 'fake'); hero.classList.toggle('no-chrome', chromeMode === 'none');+ hero.classList.toggle('bare-chrome', chromeMode === 'bare'); $('vitrine-chrome').checked = chromeMode === 'fake'; hero.classList.toggle('fullimport', !!vit.src && !!vit.full);+ const simOverImport = !!(vit.src && vit.full);+ for (const sid of ['pill-tr', 'slug-br']) {+ const sEl = $(sid);+ if (sEl) sEl.classList.toggle('sim-fenced', simOverImport);+ } $('vitrine-img').src = (!vit.full && vit.src) || ''; $('fullimport-img').src = (vit.full && vit.src) || ''; $('vitrine-full').checked = !!vit.full;@@ -1126,7 +1146,9 @@ function render() { const showTitleInImage = state.show.title && state.titleMode !== 'overlay-only'; $('el-logo').style.display = (state.show.logo && !foreign) ? 'flex' : 'none'; $('el-title').style.display = showTitleInImage ? 'block' : 'none';- $('el-title').textContent = p.title;+ $('el-title').textContent = state.headline || p.title;+ const hlIn = $('headline-input');+ if (hlIn && document.activeElement !== hlIn) hlIn.value = state.headline || ''; $('el-sub').style.display = state.show.subtitle ? 'block' : 'none'; $('el-sub').innerHTML = accentize(firstSentence(p.brief, 80)); $('el-desc').style.display = state.show.description ? 'block' : 'none';@@ -1284,7 +1306,24 @@ $('sel-comp').addEventListener('change', e => send({ comp: { id: e.target.value document.querySelectorAll('#grp-imgsrc .opt').forEach(b => b.addEventListener('click', () => send({ comp: { imgSrc: b.dataset.v } }))); document.querySelectorAll('#grp-clayout .opt').forEach(b => b.addEventListener('click', () => send({ comp: { layout: b.dataset.v } }))); document.querySelectorAll('#grp-mpn .opt').forEach(b => b.addEventListener('click', () => send({ comp: { mpn: b.dataset.v } })));-$('sel-page').addEventListener('change', e => { send({ page: e.target.value }); });+$('sel-page').addEventListener('change', e => {+ const v = e.target.value;+ send({ page: v });+ // #30: dropdown rows are pose-time snapshots; pull the live page so a+ // just-renamed title or brief cannot be ratified stale.+ fetch('refresh-page?page=' + encodeURIComponent(v), { method: 'POST' })+ .then(r => r.json())+ .then(j => {+ if (!j || !j.ok) return;+ return fetch('api/pages').then(r => r.json()).then(rows => {+ pages = rows;+ const sel = $('sel-page');+ sel.innerHTML = pages.map(p => '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');+ sel.value = v;+ render();+ });+ }).catch(() => {});+}); document.querySelectorAll('#grp-layout .opt').forEach(b => b.addEventListener('click', () => send({ layout: b.dataset.v }))); document.querySelectorAll('#grp-theme .swatch').forEach(b => b.addEventListener('click', () => send({ theme: b.dataset.v }))); document.querySelectorAll('#grp-titlemode .opt').forEach(b => b.addEventListener('click', () => send({ titleMode: b.dataset.v })));@@ -1600,6 +1639,52 @@ function runLint() { 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)' }); } + // 5. text vs vitrine (#25): the title owns its column. The fix is ALWAYS to+ // move or resize the VITRINE (layout, size), never to shrink the title:+ // small type is invisible on the dock, and #25's six-hour session shrank the+ // title four times before anyone questioned the vitrine.+ for (const t of texts) {+ if (vitrine && overlapPx(t.box, vitrine) > 9) {+ items.push({ id: 'vitrine-' + t.id, level: 'warn', el: t.id, msg: 'the ' + t.name + ' collides with the vitrine: resize or move the VITRINE (layout picker, vitrine size), do not shrink the ' + t.name + ' to fit' });+ }+ }+ const fsNow = (state.fontSize || {});+ if (vis(tEl) && (fsNow.title || 8) < 6.5) {+ items.push({ id: 'title-timid', level: 'warn', el: 'el-title', msg: 'title is at ' + fsNow.title + 'cqw, under the 6.5cqw family floor; dock thumbnails render this illegibly. If it was shrunk to clear the vitrine, resize the vitrine instead' });+ }++ // 6. optical alignment (retrospective #35 fix 4): glyph side bearing shifts+ // the INK edge off the box edge; boxes can align while the pixels do not.+ if (vis(tEl) && texts.length >= 2) {+ try {+ const cv2 = (runLint._cv || (runLint._cv = document.createElement('canvas').getContext('2d')));+ const inkLeft = (el) => {+ const ch = (el.textContent || '').trim().charAt(0);+ if (!ch) return null;+ const cs = getComputedStyle(el);+ cv2.font = cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;+ const mm = cv2.measureText(ch);+ return el.getBoundingClientRect().left - (mm.actualBoundingBoxLeft || 0);+ };+ const a = inkLeft(tEl);+ const b = inkLeft($(texts[1].id));+ if (a != null && b != null) {+ const exportPx = Math.round(Math.abs(a - b) * 2000 / H.width);+ if (exportPx > 5) {+ items.push({ id: 'ink-align', level: 'warn', el: 'el-title', msg: 'optical alignment: the title\'s ink edge sits ' + exportPx + 'px (export scale) ' + (a > b ? 'right' : 'left') + ' of the ' + texts[1].name + '\'s because of glyph side bearing; reword the first word or accept it deliberately' });+ }+ }+ } catch {}+ }++ // 7. published headroom (#28): how much calm space is left under the lowest+ // text before the marquee band, in EXPORT pixels; informational.+ if (texts.length && state.mode === 'app') {+ const lowest = Math.max.apply(null, texts.map(t => t.box.bottom));+ const px = Math.round((bandTop - lowest) * 2000 / H.width);+ items.push({ id: 'headroom', level: 'info', msg: 'published headroom: ' + px + 'px between the lowest image-zone text and the marquee band at export scale (2000x1250)' });+ }+ 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));@@ -1869,7 +1954,13 @@ $('btn-reroll').addEventListener('click', () => { fetch('reroll', { method: 'POS /* image-zone type sizes: instant local feedback, debounced send */ let fsTimer = null;-for (const [id, key] of [['fs-title', 'title'], ['fs-sub', 'sub'], ['fs-desc', 'desc']]) {+let hlTimer = null;+$('headline-input').addEventListener('input', e => {+ clearTimeout(hlTimer);+ const v = e.target.value;+ hlTimer = setTimeout(() => send({ headline: v }), 350);+});+for (const [id, key] of [['fs-title', 'title'], ['fs-sub', 'sub'], ['fs-desc', 'desc'], ['fs-leading', 'leading'], ['fs-gap', 'gap']]) { $(id).addEventListener('input', e => { const v = Number(e.target.value); $(id + '-val').textContent = v;
render-pup.mjs+10−6@@ -9,17 +9,21 @@ // Exit 0 with the cropped PNG at out.png; nonzero with a plain message. import { execFile } from 'child_process'; import fs from 'fs';+import { makeBridge } from './bridge.mjs'; const [,, url, out] = process.argv; if (!url || !out) { console.error('usage: render-pup.mjs <url> <out.png>'); process.exit(1); } const SESSION = 'hero-render';-// ADOM_TARGET addresses a specific desktop when several share the relay (#24)-const AD_TARGET = process.env.ADOM_TARGET || '';-const sh = (args, timeout = 20000) => new Promise((res) => {- execFile('adom-desktop', AD_TARGET ? [...args, '--target', AD_TARGET] : args, { timeout }, (err, stdout, stderr) =>- res({ ok: !err, out: String(stdout || '') + String(stderr || '') }));-});+// ADOM_TARGET addresses a specific desktop when several share the relay (#24);+// the adapter resolves the binary + verb prefix (post-rename boxes, #31).+const bridge = await makeBridge(process.env.ADOM_TARGET || '');+const sh = async (args, timeout = 20000) => {+ // args[0] is a legacy browser_* verb name; map by suffix through the adapter+ const suffix = args[0].replace(/^browser_/, '');+ const r = await bridge.call(suffix, args[1] ? JSON.parse(args[1]) : {}, timeout);+ return { ok: r.ok, out: r.out + r.errOut };+}; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const die = (msg) => { console.error('pup renderer: ' + msg); process.exit(1); };
server.js+114−4@@ -24,19 +24,20 @@ let state = { theme: 'midnight', // midnight | pcb | onion | pill | gradient show: { logo: true, title: true, subtitle: true, description: false }, titleMode: 'image-only', // overlay-only | image-only | both; image-only per John: the overlay is dynamic data, not a second title- overlay: { pill: true, scores: false, author: true, slug: true }, // scores are live-replaced on the marquee; sim defaults them off+ overlay: { pill: true, scores: false, author: true, slug: false }, // slug defaults OFF (#29: the baked slug doubled with the marquee's live slug and was the top rejection cause); scores are live-replaced on the marquee; sim defaults them off 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+ vitrine: { src: '', full: false, cover: false, chrome: '' }, // chrome: ''=auto | 'fake' | 'none' (no titlebar) | 'bare' (no frame/bg/shadow at all, alpha preserved) // 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 slotSize: null, // measured vitrine slot in export px, posted back by the UI ({w,h}) bgImage: { src: '', cover: false }, // imported background art for theme 'image' (hero-studio#24) circuitSeed: Date.now() % 2147483647, // procedural circuitry theme seed; /reroll re-rolls- fontSize: { title: 8, sub: 2.5, desc: 1.35 }, // cqw; author-adjustable, defaults tuned for dock legibility+ fontSize: { title: 8, sub: 2.5, desc: 1.35, leading: 1.02, gap: 1 }, // cqw sizes + title line-height + text-block gap (em); author-adjustable, defaults tuned for dock legibility+ headline: '', // what BAKES as the hero title; '' = use the page title (hero-studio#33: title != headline) approved: null, // { page, file, ts } set on Generate: the persistent gate-closed record gradAngle: 155, guides: true,@@ -59,7 +60,7 @@ const POSE_TRACKED = [ 'mode', 'theme', 'layout', 'gradAngle', 'titleMode', 'show.logo', 'show.title', 'show.subtitle', 'show.description', 'overlay.pill', 'overlay.scores', 'overlay.author', 'overlay.slug',- 'brand.accent', 'brand.bg', 'fontSize.title', 'fontSize.sub', 'fontSize.desc',+ 'brand.accent', 'brand.bg', 'fontSize.title', 'fontSize.sub', 'fontSize.desc', 'fontSize.leading', 'fontSize.gap', 'headline', 'vitrine.chrome', 'vitrine.full', 'vitrine.cover', ]; const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);@@ -79,6 +80,20 @@ function notePoseDiff(before) { // The valid enum sets (hero-studio#24 asks 1+2): validated on every /state // write and exposed on GET /state as `enums`, so a driving AI never has to // read index.html to learn the tokens.+// Named presets (#34.2): the family design systems lived in N private build+// scripts; a preset applies the family tokens in one call. bridge-family+// carries the constants pup measured and kicad/fusion/nb copied (title 12cqw,+// bare right-side vitrine, overlays off, calm band).+const PRESETS = {+ 'bridge-family': {+ layout: 'showcase', theme: 'midnight', titleMode: 'image-only',+ fontSize: { title: 12, sub: 2.2, desc: 1.35 },+ overlay: { pill: true, scores: false, author: false, slug: false },+ vitrine: { chrome: 'bare' },+ guides: false,+ },+};+ const ENUMS = { layout: ['bleed-bottom', 'bleed-right', 'bleed-top', 'bleed-corner-br', 'bleed-corner-tr', 'contained', 'mirror', 'showcase'], theme: ['midnight', 'pcb', 'onion', 'pill', 'grid', 'gradient', 'aurora', 'mesh', 'circuitry', 'image'],@@ -146,6 +161,11 @@ const server = http.createServer((req, res) => { return res.end(JSON.stringify({ ok: false, error: 'invalid ' + k + ' \'' + body[k] + '\'', valid })); } }+ const turningFullOn = body.vitrine && body.vitrine.full && !(state.vitrine && state.vitrine.full);+ if (turningFullOn && !body.overlay) {+ body.overlay = { pill: false, scores: false, author: false, slug: false };+ logLine('info', 'full import: composed overlays auto-disabled (#32); re-enable explicitly to simulate them (they render amber-fenced)');+ } const before = snapshotPose(); merge(state, body); notePoseDiff(before);@@ -281,6 +301,77 @@ const server = http.createServer((req, res) => { finish(200, { ok: true, bytes: buf.length, theme: 'image' }); }); }+ if (p === '/preset' && req.method === 'POST') {+ return readBody(req, (err, body) => {+ const name = body && body.name;+ if (!name || !PRESETS[name]) {+ res.writeHead(400, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: false, error: 'unknown preset', valid: Object.keys(PRESETS) }));+ }+ const before = snapshotPose();+ merge(state, PRESETS[name]);+ notePoseDiff(before);+ logLine('info', 'preset applied: ' + name);+ broadcast('state', state);+ res.writeHead(200, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify({ ok: true, applied: name, patch: PRESETS[name] }));+ });+ }+ if (p === '/shutdown' && req.method === 'POST') {+ // instance lifecycle (#26): a polite stop verb so zombies don't accumulate+ logLine('info', 'shutdown requested');+ res.writeHead(200, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify({ ok: true, port: PORT }));+ setTimeout(() => process.exit(0), 150);+ return;+ }+ if (p === '/refresh-page' && req.method === 'POST') {+ // Live wiki truth for the posed page (#33/#30): re-fetch title/brief/type+ // from the registry so the gate never shows a stale pose-time snapshot.+ const ref = url.searchParams.get('page') || state.page;+ const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';+ const headers = {};+ try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+ return (async () => {+ const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };+ try {+ const r = await fetch(REG + '/api/pages/' + ref, { headers, redirect: 'follow' });+ if (!r.ok) return finish(200, { ok: false, error: 'page fetch ' + r.status });+ const pj = await r.json();+ const pg = pj.page || pj;+ const [owner, slug] = ref.split('/');+ const i = PAGES.findIndex((x) => x.owner === owner && x.slug === slug);+ const live = { owner, slug, title: pg.title, brief: pg.brief, type: pg.type === 'hardware' ? 'component' : pg.type, kind: pg.kind };+ const entry = Object.assign({ author: '', score: 0, stars: 0, installs30d: 0, tags: [] }, i >= 0 ? PAGES[i] : {}, Object.fromEntries(Object.entries(live).filter(([, v]) => v != null)));+ if (i >= 0) PAGES[i] = entry; else PAGES.unshift(entry);+ logLine('info', 'live refresh: ' + ref + ' title="' + pg.title + '"');+ broadcast('state', state);+ finish(200, { ok: true, title: pg.title, brief: pg.brief });+ } catch (e) { finish(200, { ok: false, error: e.message }); }+ })();+ }+ if (p === '/compose.png') {+ // Headless compose (#36.2): the current composed hero WITHOUT provenance,+ // so an agent can verify staging (slug bands, double pills, alignment)+ // without driving a browser. Costs one headless render (~3s).+ if (generating) { res.writeHead(503); return res.end('generate in progress'); }+ const tmp = path.join(__dirname, 'generated', 'compose-preview.png');+ fs.mkdirSync(path.dirname(tmp), { recursive: true });+ const child = spawn(process.execPath, [path.join(__dirname, 'render-hero.mjs'), 'http://localhost:' + PORT + '/?render=1', tmp], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });+ let eb = '';+ child.stderr.on('data', (c) => { eb += c; });+ const t = setTimeout(() => child.kill(), 60000);+ child.on('close', (code) => {+ clearTimeout(t);+ if (code !== 0 || !fs.existsSync(tmp)) {+ res.writeHead(500, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: false, error: eb.trim().split('\n').pop() || 'compose render failed' }));+ }+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });+ res.end(fs.readFileSync(tmp));+ });+ return;+ } if (p === '/reroll' && req.method === 'POST') { state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647; broadcast('state', state);@@ -403,6 +494,25 @@ const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };+ // Passthrough (#36.1): a full-import with every overlay off composes+ // NOTHING in the browser, so a headless re-render can only recompress and+ // color-shift the author's pixels. Decode the import straight to disk and+ // let finalize do the standard 2000px normalize + provenance stamp.+ const ov = state.overlay || {};+ const fullImport = state.vitrine && state.vitrine.full && state.vitrine.src;+ if (fullImport && !ov.pill && !ov.scores && !ov.author && !ov.slug) {+ const b64 = String(state.vitrine.src).match(/^data:image\/png;base64,(.+)$/);+ if (b64) {+ try {+ fs.writeFileSync(outFile, Buffer.from(b64[1], 'base64'));+ logLine('info', 'full-import passthrough: composing nothing, stamping the imported pixels directly');+ finalize('passthrough');+ return;+ } catch (e) {+ logLine('warn', 'passthrough write failed (' + e.message + '); falling back to the render ladder');+ }+ }+ } // Renderer ladder: local headless Chrome first (full fidelity), then the // host browser via pup for containers that cannot run Chrome (#7). const attempts = [];