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.20.0
12 files changed
+201−201
SKILL.md+32−32@@ -20,11 +20,11 @@ pages (--type hardware or its legacy spelling component) ride the same wizard and the same composition: title, subtitle, and YOUR PART's render in the showcase slot, floating bare by default (no window frame, bar, or shadow; a fake app window around a 3D board render reads as a screenshot-of software). Load the render with `hero-studio vitrine --file+of software). Load the render with `hero-studio showcase --file <render.png>` (a chip-thumbnailer `*-icon.png` is alpha-keyed and floats cleanly); `--chrome none|fake` overrides the bare default. The studio's "component" MODE in Classic is a sample-parts design lab (built-in photos-of a JST connector) that ignores the posed page and the vitrine; posing+of a JST connector) that ignores the posed page and the showcase; posing never enters it. The title ALWAYS bakes (it IS the message) and its ink top-left is pixel-anchored at any font size (fixed leading + cap trim; the leading control is gone, the wire field remains inert).@@ -52,7 +52,7 @@ scales by gating the STYLE, not every part: its own title and render), generating locally, paced. Every export's provenance records the style, the gate that minted it, and the attestation, so a batch hero is always traceable to the human who set the- look. parts.json rows: {ref, title, kind?, vitrine?: <render path>,+ look. parts.json rows: {ref, title, kind?, showcase?: <render path>, headline?, subtitle?}. EXISTING pages (a fleet that already lives on the wiki, e.g. 136 of the@@ -130,8 +130,8 @@ 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; a SECOND pose of the SAME page refuses with POSE_BUSY (exit 4) naming when the open gate was posed: wait, --steal to take it over, or --port for a parallel-instance. Re-posing the same page PRESERVES staged vitrine content (a crashed-or timed-out gate never destroys a staged full-hero import); --fresh-vitrine+instance. Re-posing the same page PRESERVES staged showcase content (a crashed+or timed-out gate never destroys a staged full-hero import); --fresh-showcase starts clean. With multiple Adom Desktops on one relay, pass --target <name> (or set ADOM_TARGET); it rides every desktop call including the pup renderer. Every verb takes `--port N` (or HERO_STUDIO_PORT) to target an instance; the tab is named@@ -206,19 +206,19 @@ dependence on the desktop app running), not a blocker. To exercise or test a specific leg: `POST /generate {"renderer":"pup"}` (or `"local"`) pins it for one request; the `HERO_RENDERER` env on the server pins it globally. -If `hero-studio vitrine|set|state|toast|reroll` dies with MODULE_NOT_FOUND,+If `hero-studio showcase|set|state|toast|reroll` dies with MODULE_NOT_FOUND, the install is hero-studio 0.10.0, which shipped without drive.mjs; update the package (`adom-wiki pkg install adom/hero-studio`) or POST to the server-directly (`/vitrine-file` with `{"path": "<abs path>"}`).+directly (`/showcase-file` with `{"path": "<abs path>"}`). ## Platform-split siblings (twins) Policy: NO hero inheritance. Every sibling page (adom-desktop-macos and-friends) gets its own human pose, so the vitrine shows that platform's real+friends) gets its own human pose, so the showcase shows that platform's real window chrome. The flow is already cheap: artwork state (theme, layout,-gradient, brand) carries from pose to pose, and each pose clears the vitrine+gradient, brand) carries from pose to pose, and each pose clears the showcase 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;+showcase content and the baked slug change. Pose sibling A, tune, Generate; pose sibling B, confirm, Generate. ## Page fields, Apply, and the change ledger@@ -251,10 +251,10 @@ 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+showcase 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, 4+ line title wraps, TEXT-COLLIDING WITH THE VITRINE (the fix is ALWAYS to move or resize the vitrine,+COLLIDING WITH THE SHOWCASE (the fix is ALWAYS to move or resize the showcase, 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@@ -267,7 +267,7 @@ composed checks cannot see imported pixels, and an edge-density scan of the lower band flags text-like structure there (heuristic, hero-studio#22). Quoted prompt-looking text in the image zone warns too: sample prompts belong in page metadata (sample_prompts), which the dynamic overlay previews with a-copy button; a baked prompt is pixels nobody can copy. Cover-cropped vitrines+copy button; a baked prompt is pixels nobody can copy. Cover-cropped showcases get accounting (#41): the linter reports what percent of the source screenshot the crop discards and heuristically flags content cut mid-element at the crop lines (beheaded logos and headings read as broken). Contrast checking is@@ -279,7 +279,7 @@ When opened by the pose gate, the studio presents verification chrome above the hero: an explainer, a per-item checklist, and a Generate button that unlocks only when every item is confirmed (clicking it locked explains why). A WALKTHROUGH spotlights each item in place: "this is your title", subtitle,-vitrine, then the type pill (where the human catches an AI's wrong app/skill/+showcase, then the type pill (where the human catches an AI's wrong app/skill/ hardware call; hardware is the launch name for the old component type, which survives as a kind); "Looks good" ticks that check and advances. The homepage- marquee overlay composites over the hero by default in the gate so the human@@ -295,23 +295,23 @@ 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+## The showcase: real content is MANDATORY, not a suggestion -The floating app window in the hero is called the VITRINE (one word,-everywhere). A generic mock vitrine in a published hero is a failure. The pose+The floating app window in the hero is called the SHOWCASE (one word,+everywhere). A generic mock showcase in a published hero is a failure. The pose flow auto-pulls a screenshot from the package's wiki page; when that fails it-prints REPRIMAND [VITRINE_IS_GENERIC] with the exact recovery, which you MUST+prints REPRIMAND [SHOWCASE_IS_GENERIC] with the exact recovery, which you MUST 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-bridge-cli pup_open_window '{"sessionId":"vitrine-shot","profile":"vitrine-shot","url":"<app url>"}'`+ `adom-bridge-cli pup_open_window '{"sessionId":"showcase-shot","profile":"showcase-shot","url":"<app url>"}'` then size the viewport to the slot aspect and- `adom-bridge-cli pup_screenshot '{"sessionId":"vitrine-shot"}'`.+ `adom-bridge-cli pup_screenshot '{"sessionId":"showcase-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+2. Load it: `hero-studio showcase --file <shot.png>` (the open studio updates live within a second). The CSS mock is acceptable ONLY when the page genuinely has no UI.@@ -328,9 +328,9 @@ hero-studio set fontSize.leading=0.96 fontSize.gap=1.2 # title line-height + t 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 fit # solve the LARGEST title size with <=3 lines + positive headroom (#40); auto-runs on headline edits, and presets re-fit with their size as a ceiling. If no size makes 3 lines, it fits the hard constraints and tells you to shorten the copy: shorter copy reads bigger-hero-studio vitrine --from-url <url> [--selector <css>] # the studio drives the desktop bridge itself: slot math, DPR, sentinel check, aspect crop (#41); falls back with a clear message when no bridge answers+hero-studio showcase --from-url <url> [--selector <css>] # the studio drives the desktop bridge itself: slot math, DPR, sentinel check, aspect crop (#41); falls back with a clear message when no bridge answers 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 showcase --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)@@ -354,15 +354,15 @@ studio page, so it is empty until a browser surface has rendered the pose; once one is open, every gate finding (layout linter, full-import caveats, 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+The showcase'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|bare`, the "Draw the fake window-titlebar" checkbox, or `set '{"vitrine":{"chrome":"none"}}'`. `bare` goes+titlebar" checkbox, or `set '{"showcase":{"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+Layouts include the `showcase` value labeled Backdrop (the showcase image gets ~65% of the hero behind a gradient scrim, Adom-USB style). Themes add `aurora`, `mesh`, and `circuitry`, which is procedurally generated from a seed so every hero is gently different; the seed rides the provenance stamp and `reroll` re-rolls it.@@ -386,7 +386,7 @@ and stamped, with zero recompression drift (hero-studio#36). ## The hero doctrine: ONE message (hero-studio#43) 1. A hero image gets across exactly ONE message. Six messages = zero messages.-2. That one message IS the title, ideally approaching one word. The vitrine is+2. That one message IS the title, ideally approaching one word. The showcase is supporting evidence, not the message. 3. White space is not wasted space; negative space is what makes the one message land.@@ -395,7 +395,7 @@ and stamped, with zero recompression drift (hero-studio#36). reason the lower marquee band stays calm. The gate walkthrough teaches this per step, and the linter enforces the-measurable parts: vitrine coverage above ~72% of the hero warns, and a+measurable parts: showcase coverage above ~72% of the hero warns, and a full-import whose zones read as several competing text blocks warns (a 4x3 edge-density grid; heuristic and says so). @@ -420,12 +420,12 @@ every full-import thread rediscovered it the hard way (hero-studio#32): the stamp composes, so pixel-identical passthrough is deliberately off for that generate. - The family preset (`hero-studio preset bridge-family`) carries the measured- constants (12cqw title, bare right-side vitrine, overlays off).+ constants (12cqw title, bare right-side showcase, overlays off). ## What the human controls -- Layout (vitrine bleeds, contained, mirror), theme, gradient angle.-- Vitrine content: the CSS mock, a real screenshot, or a full-hero import.+- Layout (showcase bleeds, contained, mirror), theme, gradient angle.+- Showcase content: the CSS mock, a real screenshot, or a full-hero import. - Title placement: image-only is the default; the title lives in the IMAGE. - Owner brand: adom-org pages get the house wordmark and palette automatically. Every other owner gets no Adom mark, no placeholder, and Accent + Background@@ -457,7 +457,7 @@ curl -s localhost:8846/console # page console relay State fields: `page` (owner/slug), `layout`, `theme`, `gradAngle`, `titleMode`, `show.logo/title/subtitle/description`, `overlay.pill/scores/author/slug`, `brand.accent/brand.bg` (non-adom owner palette), `verify` (verification chrome),-`confirm.title/subtitle/vitrine/type` (the checklist), `vitrine.src/full/cover`+`confirm.title/subtitle/showcase/type` (the checklist), `showcase.src/full/cover` (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`
capture.mjs+3−3@@ -1,5 +1,5 @@ #!/usr/bin/env node-// Vitrine capture helper (hero-studio#41): the studio drives the desktop+// Showcase capture helper (hero-studio#41): the studio drives the desktop // browser itself instead of leaving slot math, DPR math, and cropping to the // driving AI. Opens a dedicated pup session at the right shape, screenshots, // verifies the shot came from OUR window (sentinel, the #8 wrong-window@@ -9,7 +9,7 @@ import fs from 'fs'; import { makeBridge } from './bridge.mjs'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms));-const SESSION = 'vitrine-shot';+const SESSION = 'showcase-shot'; /// captureUrl({ url, selector?, aspect, outFile, target?, thread? }) /// -> { ok, file?, width?, height?, error? }@@ -20,7 +20,7 @@ export async function captureUrl(opts) { const status = await bridge.call('status', {}, 10000); if (!status.ok || (status.out + status.errOut).includes('_adom_use_cli_instead')) {- return bad('the desktop bridge did not answer (' + bridge.generation + '); no host browser to capture with. Capture manually and load it with: hero-studio vitrine --file <shot.png>');+ return bad('the desktop bridge did not answer (' + bridge.generation + '); no host browser to capture with. Capture manually and load it with: hero-studio showcase --file <shot.png>'); } const hasSession = status.out.includes(SESSION); const opened = hasSession
docs/bulk-publish-reference.mjs+1−1@@ -74,7 +74,7 @@ for (const d of dirs) { const render = path.join(PARTS, d, 'render.png'); partsManifest.push({ ref: `${ORG}/${d}`, title: meta.title, kind: meta.kind || 'component',- vitrine: fs.existsSync(render) ? render : undefined, _dir: d,+ showcase: fs.existsSync(render) ? render : undefined, _dir: d, }); } if (partsManifest.length && !DRY) {
drive.mjs+15−15@@ -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|bare] load a real screenshot+// hero-studio showcase --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@@ -46,19 +46,19 @@ const jf = async (p, body) => { const die = (msg) => { console.error(msg); process.exit(1); }; try {- if (verb === 'vitrine') {+ if (verb === 'showcase') { const ui = args.indexOf('--from-url'); if (ui >= 0) { // #41: the studio drives the capture itself: slot math, DPR, sentinel // verification, and aspect cropping all happen here, not in the caller. const url2 = args[ui + 1];- if (!url2) die('usage: hero-studio vitrine --from-url <url> [--selector <css>] [--full] [--cover]');+ if (!url2) die('usage: hero-studio showcase --from-url <url> [--selector <css>] [--full] [--cover]'); const si = args.indexOf('--selector'); const st = await jf('state'); const slot = st.slotSize || { w: 1280, h: 960 }; const { captureUrl } = await import('./capture.mjs'); const os = await import('os');- const tmp = path.join(os.tmpdir(), 'hs-vitrine-' + Date.now() + '.png');+ const tmp = path.join(os.tmpdir(), 'hs-showcase-' + Date.now() + '.png'); console.error('capturing ' + url2 + ' at slot aspect ' + (slot.w / slot.h).toFixed(2) + ' (' + slot.w + 'x' + slot.h + ') via the desktop bridge...'); const cap = await captureUrl({ url: url2, selector: si >= 0 ? args[si + 1] : null,@@ -66,19 +66,19 @@ try { target: process.env.ADOM_TARGET || '', thread: AI_THREAD, }); if (!cap.ok) die('ERROR: ' + cap.error);- const out2 = await jf('vitrine-file', { path: tmp, full: args.includes('--full'), cover: args.includes('--cover'), chrome: '' });+ const out2 = await jf('showcase-file', { path: tmp, full: args.includes('--full'), cover: args.includes('--cover'), chrome: '' }); if (!out2.ok) die('ERROR: ' + out2.error); console.log(JSON.stringify({ ok: true, captured: cap.width + 'x' + cap.height, bytes: out2.bytes }));- console.error('vitrine captured and loaded; the open studio updates within a second');+ console.error('showcase captured and loaded; the open studio updates within a second'); process.exit(0); } const fi = args.indexOf('--file');- if (fi < 0 || !args[fi + 1]) die('usage: hero-studio vitrine --file <png> | --from-url <url> [--selector <css>] [--full] [--cover] [--chrome none|fake|bare]');+ if (fi < 0 || !args[fi + 1]) die('usage: hero-studio showcase --file <png> | --from-url <url> [--selector <css>] [--full] [--cover] [--chrome none|fake|bare]'); const file = path.resolve(args[fi + 1]); const ci = args.indexOf('--chrome'); const chrome = ci >= 0 ? args[ci + 1] : ''; 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', {+ const out = await jf('showcase-file', { path: file, full: args.includes('--full'), cover: args.includes('--cover'),@@ -87,7 +87,7 @@ try { if (!out.ok) die('ERROR: ' + out.error + (out.error && out.error.includes('cannot read') ? '\n hint: the studio server reads the path locally; pass an absolute path on this machine' : '')); const slot = out.slot ? ' (slot wants ' + out.slot.w + 'x' + out.slot.h + ')' : ''; console.log(JSON.stringify({ ok: true, bytes: out.bytes, slot: out.slot }));- console.error('vitrine loaded' + slot + '; the open studio updates within a second');+ console.error('showcase loaded' + slot + '; the open studio updates within a second'); } else if (verb === 'set') { // --list: print the valid enum tokens (hero-studio#24 ask 2) if (args.includes('--list')) {@@ -202,7 +202,7 @@ try { // limits here); --pace spaces the renders. const si = args.indexOf('--style'); const pi2 = args.indexOf('--parts');- if (si < 0 || pi2 < 0) die('usage: hero-studio batch --style <name> --parts <parts.json> [--pace SECS] [--human <who>] [--quote "their words"]\n parts.json: [{"ref":"adom/x","title":"X","vitrine":"render.png","kind":"component"}, ...]');+ if (si < 0 || pi2 < 0) die('usage: hero-studio batch --style <name> --parts <parts.json> [--pace SECS] [--human <who>] [--quote "their words"]\n parts.json: [{"ref":"adom/x","title":"X","showcase":"render.png","kind":"component"}, ...]'); const styleName = args[si + 1]; const fsm = await import('fs'); const parts = JSON.parse(fsm.readFileSync(args[pi2 + 1], 'utf8'));@@ -220,10 +220,10 @@ try { const part = parts[i]; try { await jf('api/pages', { owner: part.ref.split('/')[0], slug: part.ref.split('/')[1], title: part.title, type: part.type || 'hardware', kind: part.kind });- await jf('state', Object.assign({}, stylePatch, { page: part.ref, headline: part.headline || '', subtitle: part.subtitle || '', vitrine: Object.assign({ src: '', full: false }, stylePatch.vitrine || {}) }));- if (part.vitrine) {- const v = await jf('vitrine-file', { path: path.resolve(part.vitrine), cover: !!(stylePatch.vitrine || {}).cover, chrome: (stylePatch.vitrine || {}).chrome || '' });- if (!v.ok) throw new Error('vitrine: ' + v.error);+ await jf('state', Object.assign({}, stylePatch, { page: part.ref, headline: part.headline || '', subtitle: part.subtitle || '', showcase: Object.assign({ src: '', full: false }, stylePatch.showcase || {}) }));+ if (part.showcase) {+ const v = await jf('showcase-file', { path: path.resolve(part.showcase), cover: !!(stylePatch.showcase || {}).cover, chrome: (stylePatch.showcase || {}).chrome || '' });+ if (!v.ok) throw new Error('showcase: ' + v.error); } const g = await jf('generate', { attest: { style: styleName, human, quote } }); if (!g.ok) throw new Error(g.error);@@ -283,7 +283,7 @@ try { 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 | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | instances | stop');+ die('verbs: pose | showcase | set | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | 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+3−3@@ -2,8 +2,8 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.19.9",- "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.",+ "version": "0.20.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 showcase: 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, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase 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": [ "hero",@@ -40,7 +40,7 @@ "import a hero", "re-stamp a hero", "verify a hero",- "vitrine",+ "showcase", "hero studio doctor", "hero generate failed", "renderer not ready",
page.json+3−3@@ -2,8 +2,8 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.19.9",- "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.",+ "version": "0.20.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 showcase: 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, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase 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": [ "hero",@@ -40,7 +40,7 @@ "import a hero", "re-stamp a hero", "verify a hero",- "vitrine",+ "showcase", "hero studio doctor", "hero generate failed", "renderer not ready",
pose.mjs+23−23@@ -25,7 +25,7 @@ const flags = {}; const pos = []; // Valueless flags MUST be declared: the old parser consumed the next token as // a value, so `--steal --headline "X"` silently dropped the headline (#42).-const BOOL_FLAGS = new Set(['steal', 'fresh-vitrine', 'no-fallback', 'keep-alive']);+const BOOL_FLAGS = new Set(['steal', 'fresh-showcase', 'no-fallback', 'keep-alive']); for (let i = 0; i < args.length; i++) { if (!args[i].startsWith('--')) { pos.push(args[i]); continue; } const name = args[i].slice(2);@@ -181,16 +181,16 @@ await jf('api/pages', { }); // Every type poses into the app composition (title + subtitle + showcase // window): that is the surface the wizard drives and the only one that honors-// an imported vitrine. The studio's legacy 'component' mode is a sample-parts+// an imported showcase. The studio's legacy 'component' mode is a sample-parts // design lab (built-in photos of a JST connector, its own text zone) that-// ignores the posed page AND the vitrine, so posing --type component into it+// ignores the posed page AND the showcase, so posing --type component into it // produced heroes of a completely different part (Arav, basic-parts rollout). // It stays reachable from Classic for what it is. Hardware pages get a bare, // frameless showcase by default (app.js chrome auto). const poseMode = 'app';-const samePageRepose = !!(state && state.page === ref && state.vitrine && state.vitrine.src);-if (samePageRepose && !('fresh-vitrine' in flags)) {- console.error('note: re-posing ' + ref + ' with staged vitrine content preserved (' + Math.round(state.vitrine.src.length / 1024) + 'kB' + (state.vitrine.full ? ', full-hero import' : '') + '); pass --fresh-vitrine to start clean');+const samePageRepose = !!(state && state.page === ref && state.showcase && state.showcase.src);+if (samePageRepose && !('fresh-showcase' in flags)) {+ console.error('note: re-posing ' + ref + ' with staged showcase content preserved (' + Math.round(state.showcase.src.length / 1024) + 'kB' + (state.showcase.full ? ', full-hero import' : '') + '); pass --fresh-showcase to start clean'); } await jf('state', { method: 'POST', headers: { 'Content-Type': 'application/json' },@@ -198,20 +198,20 @@ await jf('state', { mode: poseMode, page: ref, // the gate presentation: verification chrome + a fresh checklist verify: true, tour: true, marqueePreview: true, guides: false,- confirm: { title: false, subtitle: false, vitrine: false, type: false },- flags: { title: false, subtitle: false, vitrine: false, type: false },+ confirm: { title: false, subtitle: false, showcase: false, type: false },+ flags: { title: false, subtitle: false, showcase: 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+ // Clear the previous package's showcase so a failed auto-pull can never // leave sibling A's screenshot in sibling B's hero -- but PRESERVE staged // work when re-posing the SAME page (#24 ask 4: a crashed or timed-out- // gate must not destroy a staged full-hero import). --fresh-vitrine+ // gate must not destroy a staged full-hero import). --fresh-showcase // forces the clear. Artwork state (theme, layout, angle, brand) always // carries: that IS the platform-twin flow.- ...(samePageRepose && !('fresh-vitrine' in flags) ? {} : { vitrine: { src: '', full: false } }),+ ...(samePageRepose && !('fresh-showcase' in flags) ? {} : { showcase: { src: '', full: false } }), }), }); @@ -233,10 +233,10 @@ try { } catch {} // 2c. best-effort: pre-load a real screenshot from the package's wiki page-// into the vitrine, so the mock only shows when the page has no images.+// into the showcase, so the mock only shows when the page has no images. // Skipped when a same-page re-pose preserved staged content (#24 ask 4).-if (!(samePageRepose && !('fresh-vitrine' in flags))) {- try { await jf('vitrine-from-page?page=' + ref, { method: 'POST' }); } catch {}+if (!(samePageRepose && !('fresh-showcase' in flags))) {+ try { await jf('showcase-from-page?page=' + ref, { method: 'POST' }); } catch {} } // 3. open the studio IN FRONT OF THE HUMAN on the surface they actually use.@@ -304,8 +304,8 @@ async function openWebview() { await sh('adom-cli', ['hydrogen', 'webview', 'set-proxy', 'false', '--name', tabName]); } -// 3b. THE VITRINE MANDATE. Wait for the UI to measure the slot, then tell the-// AI exactly what a real capture requires. A generic mock vitrine is a+// 3b. THE SHOWCASE MANDATE. Wait for the UI to measure the slot, then tell the+// AI exactly what a real capture requires. A generic mock showcase is a // reprimand, not a shrug. let slot = null; for (let i = 0; i < 8; i++) {@@ -317,19 +317,19 @@ for (let i = 0; i < 8; i++) { } catch {} } const slotTxt = slot ? slot.w + 'x' + slot.h + ' (aspect ' + (slot.w / slot.h).toFixed(2) + ')' : 'the size shown in the left panel';-if (!(state.vitrine && state.vitrine.src)) {+if (!(state.showcase && state.showcase.src)) { console.error('');- console.error('REPRIMAND [VITRINE_IS_GENERIC]: the vitrine is showing the GENERIC MOCK.');+ console.error('REPRIMAND [SHOWCASE_IS_GENERIC]: the showcase is showing the GENERIC MOCK.'); console.error(' You MUST capture a real screenshot of ' + ref + "'s actual UI and load it:"); console.error(' 1. open the app in a pup window and size the capture to ' + slotTxt + ' so nothing crops:');- console.error(' adom-desktop browser_open_window \'{"sessionId":"vitrine-shot","profile":"vitrine-shot","url":"<the app\'s URL>"}\'');- console.error(' adom-desktop browser_eval \'{"sessionId":"vitrine-shot","expr":"window.resizeTo(' + (slot ? slot.w / 2 : 1000) + ',' + (slot ? slot.h / 2 : 640) + ')"}\'');- console.error(' adom-desktop browser_screenshot \'{"sessionId":"vitrine-shot"}\'');- console.error(' 2. load it into the studio: hero-studio vitrine --file <that screenshot.png>');+ console.error(' adom-desktop browser_open_window \'{"sessionId":"showcase-shot","profile":"showcase-shot","url":"<the app\'s URL>"}\'');+ console.error(' adom-desktop browser_eval \'{"sessionId":"showcase-shot","expr":"window.resizeTo(' + (slot ? slot.w / 2 : 1000) + ',' + (slot ? slot.h / 2 : 640) + ')"}\'');+ console.error(' adom-desktop browser_screenshot \'{"sessionId":"showcase-shot"}\'');+ console.error(' 2. load it into the studio: hero-studio showcase --file <that screenshot.png>'); console.error(' Do NOT let the human generate over the generic mock unless the page truly has no UI.'); console.error(''); } else {- console.error('vitrine: loaded (' + (state.vitrine.src.length / 1024 | 0) + ' KB). For a sharper capture, shoot at ' + slotTxt + ' and run: hero-studio vitrine --file <shot.png>');+ console.error('showcase: loaded (' + (state.showcase.src.length / 1024 | 0) + ' KB). For a sharper capture, shoot at ' + slotTxt + ' and run: hero-studio showcase --file <shot.png>'); } // 4. block until a HUMAN clicks Generate (a new _gen seq)
public/hero.css+4−4@@ -222,12 +222,12 @@ body { .appbody { flex:1; display:flex; min-height:0; position:relative; } -.vitrine-img { display:none; position:absolute; inset:0; width:100%; height:100%;+.showcase-img { display:none; position:absolute; inset:0; width:100%; height:100%; object-fit:contain; object-position:top center; background:#0d1117; z-index:2; } -.hero.vitrine-cover .vitrine-img { object-fit:cover; }+.hero.showcase-cover .showcase-img { object-fit:cover; } -.hero.has-vitrine .vitrine-img { display:block; }+.hero.has-showcase .showcase-img { display:block; } .fullimport-img { display:none; position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; } @@ -309,7 +309,7 @@ body { /* 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+/* pill over showcase (#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); }
public/index.html+15−15@@ -77,8 +77,8 @@ <h3>Package (live wiki data)</h3> <select id="sel-page" data-tip="Real pages from adom-wiki page trending: title, type, score, stars, author"></select> - <h3>Vitrine placement</h3>- <div class="hint">The vitrine is the floating app window in the hero (the glass display case). One word, everywhere, per hero-studio#1.</div>+ <h3>Showcase placement</h3>+ <div class="hint">The showcase image is the app window shown in the hero. One word, everywhere, per hero-studio#1.</div> <div class="optgrid" id="grp-layout"> <button class="opt" data-v="bleed-bottom">Bleed bottom</button> <button class="opt" data-v="bleed-right">Bleed right</button>@@ -87,21 +87,21 @@ <button class="opt" data-v="bleed-corner-tr">Corner upper-right</button> <button class="opt" data-v="contained">Contained</button> <button class="opt" data-v="mirror">Mirror (text right)</button>- <button class="opt" data-v="showcase" data-tip="Adom-USB style: a large vitrine behind a gradient scrim. The title is STILL the message; the scrim exists so the text column keeps its white space">Showcase</button>+ <button class="opt" data-v="showcase" data-tip="Adom-USB style: a large showcase behind a gradient scrim. The title is STILL the message; the scrim exists so the text column keeps its white space">Showcase</button> </div> <div class="hint">The shot may run under the lower third; the overlay scrim covers it. That is the new rule.</div> - <div class="hint" id="slot-size" data-tip="Click to copy the pixel size">measuring vitrine slot...</div>+ <div class="hint" id="slot-size" data-tip="Click to copy the pixel size">measuring showcase image slot...</div> - <h3>Vitrine content</h3>- <input type="file" id="vitrine-file" accept="image/*">- <label class="chk"><input type="checkbox" id="vitrine-full"> Use the imported image as the FULL hero</label>+ <h3>Showcase content</h3>+ <input type="file" id="showcase-file" accept="image/*">+ <label class="chk"><input type="checkbox" id="showcase-full"> Use the imported image as the FULL hero</label> <label class="chk" id="stamp-logo-row" style="display:none" data-tip="Bakes the ONE standard wordmark (size and corner identical to every composed hero) over the import. Leave the logo OUT of your import and stamp it here; note this disables pixel-identical passthrough since something now composes on top."><input type="checkbox" id="chk-stamp-logo"> Stamp the standard Adom wordmark</label>- <label class="chk"><input type="checkbox" id="vitrine-cover"> Crop to fill (cover) instead of letterboxing a mismatched shot</label>- <label class="chk" data-tip="Real window screenshots already have a real titlebar; stacking the fake one above it reads as fake. Auto-off when an import is present; the CSS mock keeps it."><input type="checkbox" id="vitrine-chrome"> Draw the fake window titlebar</label>+ <label class="chk"><input type="checkbox" id="showcase-cover"> Crop to fill (cover) instead of letterboxing a mismatched shot</label>+ <label class="chk" data-tip="Real window screenshots already have a real titlebar; stacking the fake one above it reads as fake. Auto-off when an import is present; the CSS mock keeps it."><input type="checkbox" id="showcase-chrome"> Draw the fake window titlebar</label> <label class="chk"><input type="checkbox" id="chk-mqpreview"> Show the homepage-marquee overlay over the hero (judge composition and title redundancy; never exported)</label>- <button class="btn" id="vitrine-pull">Pull real screenshot from the page</button>- <button class="btn" id="vitrine-clear">Clear import</button>+ <button class="btn" id="showcase-pull">Pull real screenshot from the page</button>+ <button class="btn" id="showcase-clear">Clear import</button> <h3>Page fields</h3> <div class="hint">The human at the gate is best placed to catch page-level issues (hero-studio#20). Edits preview live; Apply writes them to the wiki (or stashes them for the first publish), and Copy Changelog Prompt hands the full session's changes back to the AI that owns the package working copy.</div>@@ -114,7 +114,7 @@ <button class="btn" id="pf-apply">Apply changes to wiki</button> <button class="btn" id="pf-prompt">Copy Changelog Prompt</button> <div class="hint" id="pf-status"></div>- <div class="hint">Import a real screenshot into the vitrine instead of the CSS mock (apps show their real UI). Full-hero mode is the re-stamp path: bring in an existing hero image, ratify it here as the human, and the export carries the studio provenance stamp. The type pill and slug still bake on top.</div>+ <div class="hint">Import a real screenshot into the showcase image instead of the CSS mock (apps show their real UI). Full-hero mode is the re-stamp path: bring in an existing hero image, ratify it here as the human, and the export carries the studio provenance stamp. The type pill and slug still bake on top.</div> </div> <h3>Background theme</h3>@@ -190,7 +190,7 @@ <div class="vhead"><b>Hero verification.</b> The registry requires a human to approve this hero; an AI cannot do it for you. Check each item below, adjust anything that reads wrong (left panel), then Generate. Only this studio's export carries the provenance stamp the gate accepts.</div> <label class="chk vchk"><input type="checkbox" id="v-title"> Title reads right</label> <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-showcase"> Showcase (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>@@ -226,7 +226,7 @@ <div class="c-spec" id="c-spec"></div> </div> - <div class="window" data-tip="Vitrine: the floating app window. Content comes from your page screenshots, an import, or the mock; placement from Vitrine placement">+ <div class="window" data-tip="Showcase image: the floating app window. Content comes from your page screenshots, an import, or the mock; placement from Showcase placement"> <div class="titlebar"> <span class="t" id="win-label"></span> <span class="winbtns">@@ -236,7 +236,7 @@ </span> </div> <div class="appbody">- <img class="vitrine-img" id="vitrine-img" alt="">+ <img class="showcase-img" id="showcase-img" alt=""> <div class="sidebar"> <div class="srow on"></div><div class="srow"></div><div class="srow"></div> <div class="srow"></div><div class="srow"></div><div class="srow"></div><div class="srow"></div>
public/js/app.js+51−51@@ -102,12 +102,12 @@ function render() { const flags = state.flags || {}; $('v-title').checked = !!confirm.title; $('v-sub').checked = !!confirm.subtitle;- $('v-vitrine').checked = !!confirm.vitrine;+ $('v-showcase').checked = !!confirm.showcase; $('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']]) {+ for (const [el, key] of [['v-title','title'],['v-sub','subtitle'],['v-showcase','showcase'],['v-type','type']]) { const label = $(el).parentElement; let mark = label.querySelector('.flagged'); if (flags[key] && !mark) {@@ -123,7 +123,7 @@ function render() { $('v-type-label').classList.toggle('warn', !modeMatchesType); 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;+ $('v-generate').disabled = !(confirm.title && confirm.subtitle && confirm.showcase && confirm.type) || anyFlag || lintFail; // author-adjustable image-zone type (hero-studio#4); cqw so preview == render const fs = state.fontSize || {};@@ -180,29 +180,29 @@ function render() { } $('chk-mqpreview').checked = !!state.marqueePreview; - // vitrine slot size (ask 1): measure the window body, scale to export px+ // showcase slot size (ask 1): measure the window body, scale to export px requestAnimationFrame(() => { const win = hero.querySelector('.appbody'); const el = $('slot-size'); if (!win || !el) return; const hb = hero.getBoundingClientRect(); const wb = win.getBoundingClientRect();- if (hb.width < 10 || wb.width < 10) { el.textContent = 'vitrine slot hidden in this layout'; return; }+ if (hb.width < 10 || wb.width < 10) { el.textContent = 'showcase slot hidden in this layout'; return; } const k = 2000 / hb.width; const w = Math.round(wb.width * k); const h = Math.round(wb.height * k); el.dataset.size = w + 'x' + h;- el.textContent = 'shoot the vitrine screenshot at ' + w + 'x' + h ++ el.textContent = 'shoot the showcase image at ' + w + 'x' + h + ' (aspect ' + (wb.width / wb.height).toFixed(2) + ') for this placement; click to copy'; const cur = state.slotSize || {}; if (cur.w !== w || cur.h !== h) send({ slotSize: { w: w, h: h } }); }); - // vitrine content: real screenshot in the window, or full-hero import- const vit = state.vitrine || {};- hero.classList.toggle('vitrine-cover', !!vit.cover);- $('vitrine-cover').checked = !!vit.cover;- hero.classList.toggle('has-vitrine', !!vit.src && !vit.full);+ // showcase content: real screenshot in the window, or full-hero import+ const vit = state.showcase || {};+ hero.classList.toggle('showcase-cover', !!vit.cover);+ $('showcase-cover').checked = !!vit.cover;+ hero.classList.toggle('has-showcase', !!vit.src && !vit.full); // fake titlebar: real window screenshots carry their own chrome, so a // second bar above it reads as an obvious fake (#11). Auto = off whenever // an import is present; the CSS mock keeps it. Explicit choice wins.@@ -213,20 +213,20 @@ function render() { const chromeMode = vit.chrome || ((vit.src && !vit.full) ? (isHardware ? 'bare' : 'none') : 'fake'); hero.classList.toggle('no-chrome', chromeMode === 'none'); hero.classList.toggle('bare-chrome', chromeMode === 'bare');- $('vitrine-chrome').checked = chromeMode === 'fake';+ $('showcase-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) || '';- $('vitrine-img').onerror = () => {- try { fetch('console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: 'error', text: 'vitrine image failed to load: ' + String(vit.src).slice(0, 80) }) }); } catch {}+ $('showcase-img').src = (!vit.full && vit.src) || '';+ $('showcase-img').onerror = () => {+ try { fetch('console', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: 'error', text: 'showcase image failed to load: ' + String(vit.src).slice(0, 80) }) }); } catch {} handleToast({ text: 'The showcase image failed to load (see console); re-import it or pick another source.', kind: 'warn', seq: Date.now() }); }; $('fullimport-img').src = (vit.full && vit.src) || '';- $('vitrine-full').checked = !!vit.full;+ $('showcase-full').checked = !!vit.full; // Adom branding only on adom-owned pages; everyone else: no mark, own palette. // Every preview wordmark (hero AND the in-context mock cards) follows.@@ -559,8 +559,8 @@ $('btn-reset').addEventListener('click', () => { }); })(); -/* verification checklist + vitrine controls */-[['v-title','title'],['v-sub','subtitle'],['v-vitrine','vitrine'],['v-type','type']].forEach(([el, key]) => {+/* verification checklist + showcase controls */+[['v-title','title'],['v-sub','subtitle'],['v-showcase','showcase'],['v-type','type']].forEach(([el, key]) => { // 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 } })); });@@ -583,28 +583,28 @@ $('bg-file').addEventListener('change', e => { r.readAsDataURL(f); }); $('bg-cover').addEventListener('change', e => send({ bgImage: { cover: e.target.checked } }));-$('vitrine-file').addEventListener('change', e => {+$('showcase-file').addEventListener('change', e => { const f = e.target.files && e.target.files[0]; if (!f) return; const r = new FileReader();- r.onload = () => send({ vitrine: { src: r.result } });+ r.onload = () => send({ showcase: { src: r.result } }); r.readAsDataURL(f); });-$('vitrine-full').addEventListener('change', e => send({ vitrine: { full: e.target.checked } }));+$('showcase-full').addEventListener('change', e => send({ showcase: { full: e.target.checked } })); $('chk-stamp-logo').addEventListener('change', e => send({ overlay: { logo: e.target.checked } }));-$('vitrine-cover').addEventListener('change', e => send({ vitrine: { cover: e.target.checked } }));-$('vitrine-chrome').addEventListener('change', e => send({ vitrine: { chrome: e.target.checked ? 'fake' : 'none' } }));+$('showcase-cover').addEventListener('change', e => send({ showcase: { cover: e.target.checked } }));+$('showcase-chrome').addEventListener('change', e => send({ showcase: { chrome: e.target.checked ? 'fake' : 'none' } })); $('chk-mqpreview').addEventListener('change', e => send({ marqueePreview: e.target.checked })); $('slot-size').addEventListener('click', () => { const sz = $('slot-size').dataset.size; if (sz && navigator.clipboard) navigator.clipboard.writeText(sz) .then(() => handleToast({ text: 'Copied ' + sz, kind: 'success', seq: Date.now() })).catch(() => {}); });-$('vitrine-clear').addEventListener('click', () => send({ vitrine: { src: '', full: false } }));-$('vitrine-pull').addEventListener('click', () => {- post('vitrine-from-page?page=' + encodeURIComponent(state.page))+$('showcase-clear').addEventListener('click', () => send({ showcase: { src: '', full: false } }));+$('showcase-pull').addEventListener('click', () => {+ post('showcase-from-page?page=' + encodeURIComponent(state.page)) .then(r => r.json())- .then(j => handleToast({ text: j.ok ? 'Vitrine loaded: ' + j.picked : 'No page screenshot: ' + j.error, kind: j.ok ? 'success' : 'warn', seq: Date.now() }))+ .then(j => handleToast({ text: j.ok ? 'Showcase loaded: ' + j.picked : 'No page screenshot: ' + j.error, kind: j.ok ? 'success' : 'warn', seq: Date.now() })) .catch(() => {}); }); @@ -614,7 +614,7 @@ const TOUR_STEPS = [ body: 'A hero gets across exactly ONE message, and this is it: the title, ideally approaching one word. Six messages equal zero messages. It bakes at this exact size; make sure it reads at dock size.' }, { key: 'subtitle', el: 'el-sub', title: 'This is your subtitle', body: 'Deliberately small BECAUSE it is assumed nobody reads it. If the design only works when someone reads this line, the hero has failed. Check the wording anyway.' },- { key: 'vitrine', el: 'vitrine-img', fallbackEl: 'circuitcanvas', winSel: '.window', title: 'This is your vitrine',+ { key: 'showcase', el: 'showcase-img', fallbackEl: 'circuitcanvas', winSel: '.window', title: 'This is your showcase image', body: 'Supporting evidence, NOT the message; the title is the message. It should show YOUR app, not a generic chart, and it should leave white space: negative space is what makes the one message land.' }, { key: 'type', el: 'pill-tr', title: 'This is your type pill', body: 'app / skill / hardware / bootstrap (hardware is the launch name; component is its legacy spelling and survives as a kind). AIs pick this wrong often; if it says the wrong thing, yell at yours now, before it bakes.' },@@ -714,17 +714,17 @@ function runLint() { 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 showcase = (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+ // 1. pill vs showcase / 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' });+ ptrEl.classList.toggle('scrim', !!(pill && showcase && overlapPx(pill, showcase) > 9));+ if (pill && showcase && overlapPx(pill, showcase) > 9) {+ items.push({ id: 'pill-showcase', level: 'warn', el: 'pill-tr', msg: 'type pill sits on the showcase image (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 });@@ -740,7 +740,7 @@ function runLint() { } // 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+ // near-edge text = warning. The showcase is exempt: bleeding off the edge is // a deliberate layout feature. const inset = H.width * 0.015; for (const t of texts) {@@ -753,7 +753,7 @@ function runLint() { // 4b. full-hero imports (#22): the composed checks cannot see imported // pixels; say so instead of reporting all clear, and run the band scan.- const vit2 = state.vitrine || {};+ const vit2 = state.showcase || {}; if (vit2.full && vit2.src) { scanImportBand(vit2.src); items.push({ id: 'import-unlinted', level: 'warn', el: 'fullimport-img', msg: 'full-hero import: composed-layout checks cannot inspect imported pixels; verify by eye before ratifying. Doctrine still applies (#32/#37): title set in Familjen Grotesk 700, body copy in Satoshi, optical left alignment on the ink edge, calm lower band; wordmark at the ONE standard size (156x35px at export scale, 90px inset top-left): if the import lacks it, use Stamp the standard Adom wordmark instead of drawing your own' });@@ -779,18 +779,18 @@ 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:+ // 5. text vs showcase (#25): the title owns its column. The fix is ALWAYS to+ // move or resize the SHOWCASE (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.+ // title four times before anyone questioned the showcase. 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' });+ if (showcase && overlapPx(t.box, showcase) > 9) {+ items.push({ id: 'showcase-' + t.id, level: 'warn', el: t.id, msg: 'the ' + t.name + ' collides with the showcase image: resize or move the showcase image (layout picker, 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' });+ 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 showcase image, resize the showcase image instead' }); } // 6. optical alignment (retrospective #35 fix 4): glyph side bearing shifts@@ -817,21 +817,21 @@ function runLint() { } catch {} } - // 6b. vitrine coverage (#43): the title is the message, the vitrine is- // supporting evidence. When the vitrine swallows the hero, the hierarchy+ // 6b. showcase coverage (#43): the title is the message, the showcase is+ // supporting evidence. When the showcase swallows the hero, the hierarchy // inverts and white space (what makes the one message land) is gone.- if (vitrine) {- const iw = Math.max(0, Math.min(vitrine.right, H.width) - Math.max(vitrine.left, 0));- const ih = Math.max(0, Math.min(vitrine.bottom, H.height) - Math.max(vitrine.top, 0));+ if (showcase) {+ const iw = Math.max(0, Math.min(showcase.right, H.width) - Math.max(showcase.left, 0));+ const ih = Math.max(0, Math.min(showcase.bottom, H.height) - Math.max(showcase.top, 0)); const cov = (iw * ih) / (H.width * H.height); if (cov > 0.72) {- items.push({ id: 'vitrine-coverage', level: 'warn', el: null, msg: 'the vitrine covers ' + Math.round(cov * 100) + '% of the hero. The title is the ONE message this image sends; the vitrine is supporting evidence. Pick a layout that gives the title its white space' });+ items.push({ id: 'showcase-coverage', level: 'warn', el: null, msg: 'the showcase image covers ' + Math.round(cov * 100) + '% of the hero. The title is the ONE message this image sends; the showcase image is supporting evidence. Pick a layout that gives the title its white space' }); } } // 6c. cover-crop accounting (#41): a cover fit silently discards source // pixels; say how much, and check the crop lines for beheaded content.- const vimg = $('vitrine-img');+ const vimg = $('showcase-img'); if (vit2.src && !vit2.full && vit2.cover && vimg && vimg.naturalWidth > 0) { const win2 = hero.querySelector('.appbody'); if (win2 && win2.offsetParent) {@@ -841,11 +841,11 @@ function runLint() { const discard = 1 - Math.min(arImg / arSlot, arSlot / arImg); if (discard > 0.25) { const axis = arImg > arSlot ? 'left and right edges' : 'top and bottom edges';- items.push({ id: 'cover-discard', level: 'warn', el: 'vitrine-img', msg: 'cover fit discards ' + Math.round(discard * 100) + '% of the source screenshot (' + axis + '). Check nothing that matters was cropped out; a screenshot captured AT the slot aspect (left panel size) loses nothing' });+ items.push({ id: 'cover-discard', level: 'warn', el: 'showcase-img', msg: 'cover fit discards ' + Math.round(discard * 100) + '% of the source screenshot (' + axis + '). Check nothing that matters was cropped out; a screenshot captured AT the slot aspect (left panel size) loses nothing' }); } scanCropEdges(vimg, arSlot); if (cropScan.status === 'done' && cropScan.cut) {- items.push({ id: 'crop-edge-cut', level: 'warn', el: 'vitrine-img', msg: 'content appears cut mid-element at the ' + cropScan.where + ' crop line (edge-density heuristic): logos or headings beheaded by a crop read as broken. Recapture at the slot aspect or move the crop' });+ items.push({ id: 'crop-edge-cut', level: 'warn', el: 'showcase-img', msg: 'content appears cut mid-element at the ' + cropScan.where + ' crop line (edge-density heuristic): logos or headings beheaded by a crop read as broken. Recapture at the slot aspect or move the crop' }); } } }@@ -988,7 +988,7 @@ function scanImportBand(src) { } /* crop-line scan (#41): sample thin strips just inside the visible window of- a cover-cropped vitrine; text straddling the crop line = beheaded content */+ a cover-cropped showcase; text straddling the crop line = beheaded content */ let cropScan = { key: '', status: 'idle', cut: false, where: '' }; function scanCropEdges(vimg, arSlot) { const key = vimg.src.length + ':' + arSlot.toFixed(3);
public/js/wizard.js+23−23@@ -129,7 +129,7 @@ const WIZ_STEPS = [ hint: () => (canonType(wp().type) === 'component' ? 'The picture of your part. Use its real render or photo; it floats bare, no app window.' : 'The screenshot shown in your hero. Use your real app, not a mock.'),- summary: () => (state.vitrine && state.vitrine.src ? (state.vitrine.full ? 'Full-hero import' : 'Screenshot') : 'Drawn mock'),+ summary: () => (state.showcase && state.showcase.src ? (state.showcase.full ? 'Full-hero import' : 'Screenshot') : 'Drawn mock'), options: () => [ { v: 'page', label: 'From the wiki page', sub: 'The screenshot already on the page' }, { v: 'upload', label: 'Upload images', sub: wizUploads.length ? 'Add more; keep several and switch' : 'Pick PNGs from this machine' },@@ -137,15 +137,15 @@ const WIZ_STEPS = [ { v: 'mock', label: 'Drawn mock', sub: 'The generic drawn window' }, ], get: () => {- if (!(state.vitrine && state.vitrine.src)) return 'mock';- const i = wizUploads.findIndex(u => u.data === state.vitrine.src);+ if (!(state.showcase && state.showcase.src)) return 'mock';+ const i = wizUploads.findIndex(u => u.data === state.showcase.src); return i >= 0 ? 'up:' + i : 'page'; }, set: (v) => {- if (v === 'mock') { wizSend({ vitrine: { src: '', full: false } }); return; }- if (v === 'upload') { $('vitrine-file').click(); return; }- if (v === 'page') { post('vitrine-from-page?page=' + encodeURIComponent(state.page)).catch(() => {}); return; }- if (v.startsWith('up:')) { wizSend({ vitrine: { src: wizUploads[Number(v.slice(3))].data } }); }+ if (v === 'mock') { wizSend({ showcase: { src: '', full: false } }); return; }+ if (v === 'upload') { $('showcase-file').click(); return; }+ if (v === 'page') { post('showcase-from-page?page=' + encodeURIComponent(state.page)).catch(() => {}); return; }+ if (v.startsWith('up:')) { wizSend({ showcase: { src: wizUploads[Number(v.slice(3))].data } }); } }, }, {@@ -234,7 +234,7 @@ const WIZ_STEPS = [ { id: 'finishing', title: 'Finishing', hint: 'The small marks in the corners.',- summary: () => ['pill', 'slug', 'logo'].filter(k => state.overlay[k] && (k !== 'logo' || (state.vitrine && state.vitrine.full))).join(' + ') || 'clean',+ summary: () => ['pill', 'slug', 'logo'].filter(k => state.overlay[k] && (k !== 'logo' || (state.showcase && state.showcase.full))).join(' + ') || 'clean', custom: (host) => { const wrap = document.createElement('div'); wrap.className = 'wiz-finish';@@ -246,7 +246,7 @@ const WIZ_STEPS = [ ['pill', 'Type pill (top right)', 'Bakes into the corner'], ['slug', 'Wiki address (bottom right)', 'Bakes into the corner; the homepage also shows a live one'], ];- if (state.vitrine && state.vitrine.full && wp().owner === 'adom') {+ if (state.showcase && state.showcase.full && wp().owner === 'adom') { items.push(['logo', 'Adom wordmark stamp', 'Bakes the standard mark over your import']); } items.forEach(([k, lab, subOn]) => {@@ -278,7 +278,7 @@ const wizStepIndex = (id) => WIZ_STEPS.findIndex(s => s.id === id); const wizActive = () => WIZ_STEPS[wizStepIndex(wizStep)] || WIZ_STEPS[0]; function wizAllConfirmed() { const c = state.confirm || {};- return c.title && c.subtitle && c.vitrine && c.type+ return c.title && c.subtitle && c.showcase && c.type && !Object.values(state.flags || {}).some(Boolean) && !(state.lint || []).some(l => l.level === 'fail'); }@@ -335,7 +335,7 @@ if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => wizF /* ---------- edit ring: the dashed box around what this step edits ---------- */ const WIZ_EDIT_TARGETS = {- showcase: () => (state.vitrine && state.vitrine.full ? $('hero') : hero.querySelector('.window')),+ showcase: () => (state.showcase && state.showcase.full ? $('hero') : hero.querySelector('.window')), placement: () => hero.querySelector('.window'), background: () => $('hero'), headline: () => hero.querySelector('.textzone'),@@ -361,7 +361,7 @@ function positionWizRing() { const WIZ_CHECKS = [ { key: 'title', q: 'Title reads right' }, { key: 'subtitle', q: 'The line under the title reads right' },- { key: 'vitrine', q: 'The screenshot shows the right thing' },+ { key: 'showcase', q: 'The screenshot shows the right thing' }, { key: 'type', q: () => 'Page type matches (this page says "' + wireLabel(wp().type) + '")' }, ]; function wizRenderReview(host) {@@ -411,22 +411,22 @@ function wizRenderReview(host) { ex.textContent = 'Check one by one'; ex.addEventListener('click', () => { wizExpanded = true;- wizSend({ confirm: { title: false, subtitle: false, vitrine: false, type: false } });+ wizSend({ confirm: { title: false, subtitle: false, showcase: false, type: false } }); }); row.append(q, ex); checks.appendChild(row); const c = state.confirm || {};- if (!(c.title && c.subtitle && c.vitrine && c.type) && !Object.values(state.flags || {}).some(Boolean)) {- wizMerge(state, { confirm: { title: true, subtitle: true, vitrine: true, type: true } });- send({ confirm: { title: true, subtitle: true, vitrine: true, type: true } });+ if (!(c.title && c.subtitle && c.showcase && c.type) && !Object.values(state.flags || {}).some(Boolean)) {+ wizMerge(state, { confirm: { title: true, subtitle: true, showcase: true, type: true } });+ send({ confirm: { title: true, subtitle: true, showcase: true, type: true } }); } } else { const allb = document.createElement('button'); allb.className = 'btn small'; allb.textContent = 'All of it looks good'; allb.addEventListener('click', () => wizSend({- confirm: { title: true, subtitle: true, vitrine: true, type: true },- flags: { title: false, subtitle: false, vitrine: false, type: false },+ confirm: { title: true, subtitle: true, showcase: true, type: true },+ flags: { title: false, subtitle: false, showcase: false, type: false }, })); checks.appendChild(allb); WIZ_CHECKS.forEach(c => {@@ -549,7 +549,7 @@ function wizCard(o, on, onPick) { let wizLastSig = ''; function wizSignature() { const l = state.lint || [];- const v = state.vitrine || {};+ const v = state.showcase || {}; return JSON.stringify([wizStep, wizExpanded, state.page, state.layout, state.theme, state.headline, state.subtitle, state.accentWords, state.overlay, state.confirm, state.flags, state.marqueePreview, state.gatesDone, wizUploads.length,@@ -628,16 +628,16 @@ document.addEventListener('keydown', (e) => { }); /* multi-upload rides the existing file input; keep a session gallery */-$('vitrine-file').setAttribute('multiple', '');-$('vitrine-file').addEventListener('change', () => {+$('showcase-file').setAttribute('multiple', '');+$('showcase-file').addEventListener('change', () => { if (!document.body.classList.contains('wizard')) return;- const files = [...$('vitrine-file').files];+ const files = [...$('showcase-file').files]; files.forEach((f) => { const r = new FileReader(); r.onload = () => { wizUploads.push({ name: f.name, data: r.result }); if (wizUploads.length && f === files[files.length - 1]) {- wizSend({ vitrine: { src: r.result } });+ wizSend({ showcase: { src: r.result } }); } }; r.readAsDataURL(f);
server.js+28−28@@ -47,13 +47,13 @@ let state = { overlay: { pill: true, scores: false, author: true, slug: false, logo: false }, // logo = the standard-wordmark stamp over full imports (#37, adom pages only); 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+ confirm: { title: false, subtitle: false, showcase: false, type: false },+ flags: { title: false, subtitle: false, showcase: 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: '' }, // 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+ showcase: { 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})+ tour: false, // verification walkthrough: spotlight title -> subtitle -> showcase -> type+ slotSize: null, // measured showcase 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, leading: 1.02, gap: 1 }, // cqw sizes + title line-height + text-block gap (em); author-adjustable, defaults tuned for dock legibility@@ -84,13 +84,13 @@ const POSE_TRACKED = [ 'show.logo', 'show.title', 'show.subtitle', 'show.description', 'overlay.pill', 'overlay.scores', 'overlay.author', 'overlay.slug', 'overlay.logo', 'brand.accent', 'brand.bg', 'fontSize.title', 'fontSize.sub', 'fontSize.desc', 'fontSize.leading', 'fontSize.gap', 'headline', 'subtitle', 'accentWords',- 'vitrine.chrome', 'vitrine.full', 'vitrine.cover',+ 'showcase.chrome', 'showcase.full', 'showcase.cover', ]; const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o); function snapshotPose() { const s = {}; for (const p of POSE_TRACKED) s[p] = getPath(state, p);- s['vitrine.src'] = (state.vitrine && state.vitrine.src) ? Math.round(state.vitrine.src.length / 1024) + 'kB screenshot' : '(mock)';+ s['showcase.src'] = (state.showcase && state.showcase.src) ? Math.round(state.showcase.src.length / 1024) + 'kB screenshot' : '(mock)'; return s; } function notePoseDiff(before) {@@ -106,7 +106,7 @@ function notePoseDiff(before) { // 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).+// bare right-side showcase, overlays off, calm band). const PRESETS = { // The true boot defaults, server-owned so the UI's reset can never drift // from them again (the old client reset restored overlay defaults that no@@ -119,13 +119,13 @@ const PRESETS = { brand: { accent: '', bg: '' }, fontSize: { title: 8, sub: 2.5, desc: 1.35, leading: 1.02, gap: 1 }, headline: '', gradAngle: 155,- vitrine: { chrome: '', cover: false },+ showcase: { chrome: '', cover: false }, }, '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, logo: false },- vitrine: { chrome: 'bare' },+ showcase: { chrome: 'bare' }, guides: false, }, };@@ -154,8 +154,8 @@ const stateBodyCache = {}; // Lean variant: staged imports are multi-MB base64 data URLs inside state. // The browser opts in with ?src=ref and gets a same-origin path instead; the-// bytes come from /vitrine.png and /bg.png. Plain GET /state stays-// full-fidelity because pose.mjs reads vitrine.src directly.+// bytes come from /showcase.png and /bg.png. Plain GET /state stays+// full-fidelity because pose.mjs reads showcase.src directly. const srcHashCache = new Map(); function srcHash(src) { let h = srcHashCache.get(src);@@ -168,8 +168,8 @@ function srcHash(src) { } function leanState() { const out = Object.assign({}, state);- if (state.vitrine && state.vitrine.src && state.vitrine.src.startsWith('data:')) {- out.vitrine = Object.assign({}, state.vitrine, { src: 'vitrine.png?v=' + srcHash(state.vitrine.src) });+ if (state.showcase && state.showcase.src && state.showcase.src.startsWith('data:')) {+ out.showcase = Object.assign({}, state.showcase, { src: 'showcase.png?v=' + srcHash(state.showcase.src) }); } if (state.bgImage && state.bgImage.src && state.bgImage.src.startsWith('data:')) { out.bgImage = Object.assign({}, state.bgImage, { src: 'bg.png?v=' + srcHash(state.bgImage.src) });@@ -417,8 +417,8 @@ function route(req, res, url, p) { res.writeHead(200, { 'Content-Type': 'application/json', ETag: etag, 'Cache-Control': 'no-cache' }); return res.end(body); }- if ((p === '/vitrine.png' || p === '/bg.png') && req.method === 'GET') {- const src = p === '/vitrine.png' ? (state.vitrine && state.vitrine.src) : (state.bgImage && state.bgImage.src);+ if ((p === '/showcase.png' || p === '/bg.png') && req.method === 'GET') {+ const src = p === '/showcase.png' ? (state.showcase && state.showcase.src) : (state.bgImage && state.bgImage.src); if (!src || !src.startsWith('data:')) { res.writeHead(404, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'no image staged' })); } const m = src.match(/^data:([^;,]+)?(;base64)?,/); const mime = (m && m[1]) || 'image/png';@@ -442,7 +442,7 @@ function route(req, res, url, p) { layout: state.layout, theme: state.theme, gradAngle: state.gradAngle, titleMode: state.titleMode, fontSize: state.fontSize, overlay: state.overlay, brand: state.brand, show: state.show, guides: false,- vitrine: { chrome: (state.vitrine || {}).chrome || '', cover: !!(state.vitrine || {}).cover },+ showcase: { chrome: (state.showcase || {}).chrome || '', cover: !!(state.showcase || {}).cover }, mode: state.mode, comp: state.mode === 'component' ? { imgSrc: state.comp.imgSrc, layout: state.comp.layout, mpn: state.comp.mpn } : undefined, circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined, };@@ -495,7 +495,7 @@ function route(req, res, url, p) { } } const pageChanged = typeof body.page === 'string' && body.page !== state.page;- const turningFullOn = body.vitrine && body.vitrine.full && !(state.vitrine && state.vitrine.full);+ const turningFullOn = body.showcase && body.showcase.full && !(state.showcase && state.showcase.full); if (turningFullOn && !body.overlay) { body.overlay = { pill: false, scores: false, author: false, slug: false, logo: false }; logLine('info', 'full import: composed overlays auto-disabled (#32); re-enable explicitly to simulate them (they render amber-fenced)');@@ -604,8 +604,8 @@ function route(req, res, url, p) { res.end(JSON.stringify({ ok: true })); }); }- if (p === '/vitrine-file' && req.method === 'POST') {- // The CLI's explicit vitrine command: hero-studio vitrine --file <png>.+ if (p === '/showcase-file' && req.method === 'POST') {+ // The CLI's explicit showcase command: hero-studio showcase --file <png>. // Local trusted tool, so a filesystem path is the contract. return readBody(req, (err, body) => { const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };@@ -615,8 +615,8 @@ function route(req, res, url, p) { if (buf.length > 12 * 1024 * 1024) return finish(200, { ok: false, error: 'image too large (12 MB cap)' }); const low = body.path.toLowerCase(); const mime = low.endsWith('.webp') ? 'image/webp' : low.match(/\.jpe?g$/) ? 'image/jpeg' : 'image/png';- state.vitrine = { src: 'data:' + mime + ';base64,' + buf.toString('base64'), full: !!body.full, cover: !!body.cover, chrome: body.chrome || (state.vitrine && state.vitrine.chrome) || '' };- logLine('info', 'vitrine <- ' + body.path + ' (' + Math.round(buf.length / 1024) + ' KB, cli)');+ state.showcase = { src: 'data:' + mime + ';base64,' + buf.toString('base64'), full: !!body.full, cover: !!body.cover, chrome: body.chrome || (state.showcase && state.showcase.chrome) || '' };+ logLine('info', 'showcase <- ' + body.path + ' (' + Math.round(buf.length / 1024) + ' KB, cli)'); broadcast('state', state); finish(200, { ok: true, bytes: buf.length, slot: state.slotSize }); });@@ -722,7 +722,7 @@ function route(req, res, url, p) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, seed: state.circuitSeed })); }- if (p === '/vitrine-from-page' && req.method === 'POST') {+ if (p === '/showcase-from-page' && req.method === 'POST') { const ref = url.searchParams.get('page') || state.page; const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc'; const headers = {};@@ -769,8 +769,8 @@ function route(req, res, url, p) { const buf = Buffer.from(await ir.arrayBuffer()); if (buf.length > 8 * 1024 * 1024) return finish(200, { ok: false, error: 'image too large' }); const mime = pick.endsWith('.webp') ? 'image/webp' : pick.match(/\.jpe?g$/) ? 'image/jpeg' : 'image/png';- state.vitrine = { src: 'data:' + mime + ';base64,' + buf.toString('base64'), full: false, chrome: (state.vitrine && state.vitrine.chrome) || '' };- logLine('info', 'vitrine <- ' + ref + '/' + pick + ' (' + Math.round(buf.length / 1024) + ' KB)');+ state.showcase = { src: 'data:' + mime + ';base64,' + buf.toString('base64'), full: false, chrome: (state.showcase && state.showcase.chrome) || '' };+ logLine('info', 'showcase <- ' + ref + '/' + pick + ' (' + Math.round(buf.length / 1024) + ' KB)'); broadcast('state', state); finish(200, { ok: true, picked: pick, bytes: buf.length }); })().catch((e) => finish(200, { ok: false, error: String(e).slice(0, 200) }));@@ -885,9 +885,9 @@ function route(req, res, url, p) { // 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;+ const fullImport = state.showcase && state.showcase.full && state.showcase.src; if (fullImport && !ov.pill && !ov.scores && !ov.author && !ov.slug && !ov.logo) {- const b64 = String(state.vitrine.src).match(/^data:image\/png;base64,(.+)$/);+ const b64 = String(state.showcase.src).match(/^data:image\/png;base64,(.+)$/); if (b64) { try { fs.writeFileSync(outFile, Buffer.from(b64[1], 'base64'));@@ -959,7 +959,7 @@ function route(req, res, url, p) { return finish({ ok: false, error: 'could not read the rendered PNG: ' + e.message, hint: "run 'hero-studio doctor'" }); } // downscale to the hero standard 2000x1250 and stamp provenance metadata- const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });+ const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined }); const py = 'import sys\n' + 'from PIL import Image\n' + 'from PIL.PngImagePlugin import PngInfo\n' +