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.18.0
11 files changed
+4519−1862
SKILL.md+33@@ -9,6 +9,39 @@ A local web app (plain Node, no dependencies) that renders a full 16:10 hero liv pixel-faithful wiki contexts, and generates the provenance-stamped 2000x1250 PNG the registry's hero gate requires. +## The wizard is the surface (0.18)++The studio opens as a bottom-dock WIZARD: seven steps (Package, Showcase+image, Placement, Background, Headline and text, Finishing, Review and+generate), one decision at a time, the hero always live above, a dashed ring+marking what the current step edits. The old full control panel is one click+away (header "Classic") and keeps the component-mode extras. Component pages+ride the same wizard for now. 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).++New wire fields for the words: `headline` (baked title override),+`subtitle` (the line under it; '' = the page brief), `accentWords` (word+indices that get the gradient; null = the legacy auto accent). The gate+count rides `gatesDone` in GET /state: at 4+ completed gates the Review+checklist renders pre-ticked (seasoned reviewer; expand to check one by+one). GET /state also carries `resolved` {title, headline, subtitle,+*Source}: the exact strings the surface is showing and where each came from+(live-wiki | fixture | override), so a driving AI never verifies via pixels.++## Automated mode: same-session repeats only++The FIRST Generate of a page each session is always clicked by the human in+the studio. After that, the driving AI may RE-generate that same page in+that same session:++ POST /generate {"aiThread": "...", "attest": {"human": "colby", "quote": "ship it"}}++The attestation is REQUIRED (who approved, their actual words), is baked+into the PNG provenance and the Approved record, and never counts toward+the seasoned gate tally. Everything else is refused with the exact retry.+Hard-fail lint findings lock every Generate path, human or attested.+ ## The ai-thread name is REQUIRED (hero-studio#39) Every server start and every state-changing verb needs the global flag
package.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.17.0",+ "version": "0.18.0", "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.", "license": "MIT", "tags": [
page.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.17.0",+ "version": "0.18.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": [
public/hero.cssadded+641@@ -0,0 +1,641 @@+/* hero.css: every rule that draws the hero, the in-context previews, and the+ ?render=1 export view. This file is the pixel firewall for the wizard+ redesign: the export must stay pixel-identical, so nothing here changes+ without a deliberate, parity-tested decision. Base tokens and fonts live+ at the top because everything else depends on them; hero.css loads after+ studio.css so original cascade order is preserved for the hero rules.+ Both stylesheets live at the public root, not a css/ subfolder: their+ url(...) references (fonts, adom-logo.svg, comp images) resolve relative+ to the stylesheet, and only the root keeps those identical to the old+ inline styles under the reverse proxy. */+++@font-face { font-family: 'Familjen Grotesk'; src: url('fonts/familjen-grotesk-700.woff2') format('woff2'); font-weight: 700; font-display: swap; }++@font-face { font-family: 'Familjen Grotesk'; src: url('fonts/familjen-grotesk-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }++@font-face { font-family: 'Satoshi'; src: url('fonts/satoshi-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }++@font-face { font-family: 'Satoshi'; src: url('fonts/satoshi-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }++@font-face { font-family: 'JetBrains Mono'; src: url('fonts/jetbrains-mono-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }+++:root {+ --bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d; --border:#30363d;+ --text:#e6edf3; --text-2:#8b949e; --text-3:#484f58;+ --accent:#00b8b0; --accent-bright:#00e6dc; --purple:#8c6bf7; --blue:#58a6ff;+ --green:#3fb950; --red:#f85149; --yellow:#d29922;+ --grad:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7);+}++* { box-sizing:border-box; margin:0; padding:0; }++html,body { height:100%; }++body {+ background:var(--bg); color:var(--text);+ font-family:'Satoshi',sans-serif; font-size:14px;+ display:flex; flex-direction:column; overflow:hidden;+}+++.hero[data-theme=image] .bg { background-color:#0a0e14; background-image:var(--bgimg, none); background-repeat:repeat; background-size:var(--bgtile, 22cqw); }++.hero[data-theme=image].bg-cover .bg { background-repeat:no-repeat; background-size:cover; background-position:center; }++.hero {+ /* equal safe-area margins on all four sides (John, hero-studio#1 ask 7);+ corner badges keep their own uniform tighter inset */+ --inset:4.5cqw; --badge-inset:2.6cqw;+ aspect-ratio:16/10; position:relative; overflow:hidden;+ border-radius:12px; border:1px solid var(--border); container-type:inline-size;+ --ga:155deg;+}++#hero { width:100%; max-width:1000px; flex:0 0 auto; box-shadow:0 24px 70px rgba(0,0,0,.5); }+++/* backgrounds: continue to the very bottom, per the new rule */+.bg { position:absolute; inset:0; }++/* mirror mode flips the whole background composition (glows, tiles, corner motifs) */+.hero[data-layout=mirror] .bg { transform:scaleX(-1); }++.hero[data-theme=midnight] .bg {+ background:+ radial-gradient(1200px 800px at 88% -10%, rgba(0,184,177,.22), transparent 60%),+ radial-gradient(900px 700px at -5% 110%, rgba(140,107,247,.18), transparent 55%),+ linear-gradient(var(--ga),#0a0e14 0%,#0d1117 45%,#071318 100%);+}++.hero[data-theme=midnight] .bg::after {+ content:''; position:absolute; inset:0;+ background-image:radial-gradient(rgba(255,255,255,.05) 1px, transparent 1.4px);+ background-size:2.2cqw 2.2cqw;+ -webkit-mask-image:radial-gradient(700px 560px at 30% 30%, #000, transparent 75%);+ mask-image:radial-gradient(700px 560px at 30% 30%, #000, transparent 75%);+}++.hero[data-theme=pcb] .bg {+ background:+ radial-gradient(1100px 760px at 85% -8%, rgba(0,230,220,.14), transparent 60%),+ linear-gradient(var(--ga),#031b19 0%,#04302c 55%,#02211e 100%);+}++.hero[data-theme=pcb] .bg::after {+ content:''; position:absolute; inset:0; opacity:.5;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><g stroke="%2300e6dc" stroke-opacity=".09" fill="none" stroke-width="1.4"><path d="M10 20 h56 l18 18 v50"/><path d="M84 150 v-36 l22 -22 h44"/><path d="M20 140 v-48 l14 -14"/><circle cx="10" cy="20" r="3.2"/><circle cx="84" cy="88" r="3.2"/><circle cx="150" cy="92" r="3.2"/><circle cx="20" cy="140" r="3.2"/><circle cx="34" cy="78" r="3.2"/></g></svg>');+ background-size:10cqw 10cqw;+}++.hero[data-theme=onion] .bg {+ background:+ radial-gradient(900px 700px at -5% 112%, rgba(140,107,247,.16), transparent 55%),+ linear-gradient(var(--ga),#0b1322 0%,#101a2c 55%,#0a111e 100%);+}++/* authentic Kickstand corner dome (brand deck onion motif), teal-to-purple stroke */+.hero[data-theme=onion] .bg::after {+ content:''; position:absolute; inset:0;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stop-color="%238c6bf7"/><stop offset=".55" stop-color="%2339b8ff"/><stop offset="1" stop-color="%2300e6dc"/></linearGradient></defs><g fill="none" stroke="url(%23g)" stroke-width="4" opacity=".5"><path d="M356 400 V234 Q356 60 200 60 Q44 60 44 234 V400"/><path d="M312 400 V250 Q312 110 200 110 Q88 110 88 250 V400"/><path d="M268 400 V266 Q268 160 200 160 Q132 160 132 266 V400"/><path d="M224 400 V282 Q224 210 200 210 Q176 210 176 282 V400"/></g></svg>');+ background-repeat:no-repeat;+ background-position:right -7cqw bottom -9cqw;+ background-size:50cqw auto;+}++.hero[data-theme=pill] .bg {+ background:+ radial-gradient(1000px 720px at 90% -8%, rgba(0,184,177,.12), transparent 60%),+ linear-gradient(var(--ga),#082a29 0%,#0c3a3c 55%,#062220 100%);+}++/* authentic pattern-pill: scattered capsule + circle outlines, mixed brand strokes */+.hero[data-theme=pill] .bg::after {+ content:''; position:absolute; inset:0;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="260" height="200"><g fill="none" stroke-width="2"><rect x="16" y="22" width="26" height="62" rx="13" stroke="%2364ABFF" stroke-opacity=".3"/><circle cx="112" cy="38" r="12" stroke="%2300e6dc" stroke-opacity=".28"/><rect x="196" y="14" width="28" height="72" rx="14" stroke="%2339b8ff" stroke-opacity=".3"/><rect x="84" y="128" width="62" height="26" rx="13" stroke="%2300b8b1" stroke-opacity=".28"/><circle cx="226" cy="150" r="10" stroke="%238c6bf7" stroke-opacity=".26"/><rect x="20" y="150" width="26" height="40" rx="13" stroke="%2300e6dc" stroke-opacity=".2"/></g></svg>');+ background-size:16cqw auto;+}++.hero[data-theme=grid] .bg {+ background:+ radial-gradient(1000px 700px at 85% -10%, rgba(0,230,220,.10), transparent 60%),+ linear-gradient(var(--ga),#07302e 0%,#0c3a3c 55%,#052220 100%);+}++/* authentic pattern-grid: fine outlined-square field */+.hero[data-theme=grid] .bg::after {+ content:''; position:absolute; inset:0; opacity:.55;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><rect x="4" y="4" width="18" height="18" fill="none" stroke="%23ffffff" stroke-opacity=".13" stroke-width="1.3"/></svg>');+ background-size:2.4cqw 2.4cqw;+}++.hero[data-theme=gradient] .bg {+ background:+ linear-gradient(155deg, rgba(5,10,18,.42), rgba(5,10,18,.14) 55%, rgba(5,10,18,.5)),+ linear-gradient(var(--ga),#00c8bf 0%,#2f9dfa 55%,#8c6bf7 100%);+}+++/* ---------- image-zone text column ---------- */+.textzone {+ position:absolute; left:var(--inset); top:var(--inset); width:34cqw; z-index:3;+ padding-right:1.5cqw;+}++.hero[data-layout=mirror] .textzone { left:auto; right:var(--inset); }++.wordmark { display:flex; align-items:flex-start; height:1.72cqw; margin-bottom:3.2cqw; }++/* the official Adom wordmark (adom-logo.svg), recolored white for dark heroes via mask */+.wordmark .wmark {+ display:block; width:7.8cqw; height:1.72cqw; background:#e6edf3;+ -webkit-mask:url('adom-logo.svg') no-repeat left top / contain;+ mask:url('adom-logo.svg') no-repeat left top / contain;+}++.h-title {+ font-family:'Familjen Grotesk'; font-weight:700; font-size:var(--fs-title, 8cqw); line-height:1.04;+ letter-spacing:-.14cqw; color:#e6edf3; margin-bottom:calc(1.8cqw * var(--fs-gap, 1));+ /* the ink never moves: leading is fixed (no control), and cap trimming+ removes the size-dependent gap above the capitals, so the first cap+ sits exactly at the anchor at any font size */+ text-box-trim: trim-start;+ text-box-edge: cap alphabetic;+}++.h-title { max-width:30cqw; }++.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; }++.h-sub b { color:#e6edf3; font-weight:500; }++.h-desc { font-size:var(--fs-desc, 1.35cqw); line-height:1.5; color:#8b949e; max-width:30cqw; }+++/* ---------- the app-window mock (the "screenshot") ---------- */+.window {+ position:absolute; z-index:2; border-radius:.9cqw .9cqw 0 0; overflow:hidden;+ background:#10151c; border:1px solid rgba(255,255,255,.09);+ box-shadow:0 1.7cqw 5cqw rgba(0,0,0,.62), 0 0 8cqw rgba(0,184,177,.07);+ display:flex; flex-direction:column;+}++.hero[data-layout=bleed-bottom] .window { right:4.5cqw; top:6cqw; width:56cqw; height:64cqw; }++.hero[data-layout=bleed-right] .window { right:-8cqw; top:6cqw; width:56cqw; height:44cqw; border-radius:.9cqw 0 0 .9cqw; }++.hero[data-layout=bleed-top] .window { right:4.5cqw; top:-6cqw; width:52cqw; height:42cqw; border-radius:0 0 .9cqw .9cqw; }++.hero[data-layout=bleed-corner-br] .window { right:-7cqw; top:14cqw; width:56cqw; height:52cqw; border-radius:.9cqw 0 0 0; }++.hero[data-layout=bleed-corner-tr] .window { right:-7cqw; top:-6cqw; width:56cqw; height:44cqw; border-radius:0 0 0 .9cqw; }++.hero[data-layout=contained] .window { right:4.5cqw; top:6cqw; width:50cqw; height:33cqw; border-radius:.9cqw; }++.hero[data-layout=mirror] .window { left:4.5cqw; top:6cqw; width:56cqw; height:64cqw; }++.hero[data-layout=showcase] .window { right:-4cqw; top:4cqw; width:66cqw; height:58cqw; border-radius:.9cqw 0 0 .9cqw; }++.hero[data-layout=showcase]::after {+ content:''; position:absolute; inset:0; z-index:2; pointer-events:none;+ background:linear-gradient(100deg, rgba(5,9,14,.88) 0%, rgba(5,9,14,.55) 30%, rgba(5,9,14,0) 55%);+}++.hero[data-layout=showcase] .textzone { z-index:3; width:30cqw; }++.titlebar {+ flex:0 0 2.1cqw; background:#181f28; border-bottom:1px solid rgba(255,255,255,.06);+ display:flex; align-items:center; padding:0 .9cqw; gap:.6cqw;+}++.titlebar .t { font-family:'Satoshi'; font-size:.82cqw; color:#8b949e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }++.winbtns { margin-left:auto; display:flex; gap:.9cqw; align-items:center; }++.winbtns svg { width:.72cqw; height:.72cqw; stroke:#8b949e; stroke-width:2; fill:none; }++.appbody { flex:1; display:flex; min-height:0; position:relative; }++.vitrine-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.has-vitrine .vitrine-img { display:block; }++.fullimport-img { display:none; position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }++.hero.fullimport .fullimport-img { display:block; }++.hero.fullimport .textzone, .hero.fullimport .window { display:none; }++.sidebar { flex:0 0 11cqw; background:#131920; border-right:1px solid rgba(255,255,255,.05); padding:.8cqw .7cqw; }++.srow { height:1.5cqw; border-radius:.35cqw; background:rgba(255,255,255,.05); margin-bottom:.55cqw; }++.srow.on { background:rgba(0,184,176,.18); border:1px solid rgba(0,184,176,.35); }++.content { flex:1; padding:1cqw 1.1cqw; display:flex; flex-direction:column; gap:.8cqw; }++.crow { display:flex; gap:.6cqw; align-items:center; }++.chipk { height:1.3cqw; border-radius:999px; background:rgba(255,255,255,.07); }++.bars { flex:1; display:flex; align-items:flex-end; gap:.5cqw; min-height:0; }++.bar { flex:1; border-radius:.3cqw .3cqw 0 0; background:linear-gradient(180deg, rgba(0,230,220,.55), rgba(0,184,176,.12)); }++.tbl { display:flex; flex-direction:column; gap:.45cqw; }++.trow { display:flex; gap:.5cqw; }++.tcell { height:1.05cqw; border-radius:.25cqw; background:rgba(255,255,255,.06); }+++/* ---------- the lower-third textual overlay ---------- */+#mqoverlay { display:none; position:absolute; left:0; right:0; bottom:0; z-index:5;+ padding:3cqw 3cqw 2.2cqw;+ background:linear-gradient(180deg, rgba(5,9,14,0) 0%, rgba(5,9,14,.55) 35%, rgba(5,9,14,.9) 100%); }++.hero.show-mq #mqoverlay { display:block; }++#mqoverlay .mqo-badges { display:flex; gap:1cqw; align-items:center; margin-bottom:.9cqw; }++#mqoverlay .mqo-title { font-family:'Familjen Grotesk'; font-weight:700; font-size:3.2cqw; color:#fff; margin:0 0 .5cqw; }++#mqoverlay .mqo-brief { font-size:1.5cqw; color:#c9d1d9; margin:0 0 .7cqw; max-width:60cqw; }++#mqoverlay .mqo-owner { font-family:'JetBrains Mono',monospace; font-size:1.1cqw; color:#00b8b1; }++.pill-tr { position:absolute; top:var(--badge-inset); right:var(--badge-inset); z-index:6; }++.slug-br { position:absolute; right:var(--badge-inset); bottom:var(--badge-inset); z-index:6; }++.lower {+ position:absolute; left:0; right:0; bottom:0; height:33.4%; z-index:4;+ background:linear-gradient(180deg, rgba(10,14,20,0) 0%, rgba(10,14,20,.62) 22%, rgba(10,14,20,.94) 62%, rgba(10,14,20,.98) 100%);+ display:flex; align-items:flex-end;+}++.lowerin {+ width:100%; padding:0 5.75cqw 3.1cqw; display:flex; align-items:flex-end; gap:2cqw;+}++.lmain { min-width:0; flex:1; }++.badges { display:flex; align-items:center; gap:.9cqw; margin-bottom:1cqw; }++.pill {+ font-family:'Familjen Grotesk'; font-weight:700; font-size:.85cqw; letter-spacing:.14em;+ padding:.3cqw .85cqw; border-radius:999px; border:1px solid; text-transform:uppercase;+}++.pill.app { color:#00e6dc; border-color:rgba(0,230,220,.45); background:rgba(0,184,176,.10); }++.pill.skill { color:#b39dfb; border-color:rgba(140,107,247,.5); background:rgba(140,107,247,.12); }++.pill.bootstrap { color:#79b8ff; border-color:rgba(88,166,255,.5); background:rgba(88,166,255,.12); }++.pill.component { color:#e3b341; border-color:rgba(210,153,34,.5); background:rgba(210,153,34,.12); }++.pill.service { color:#ff9d76; border-color:rgba(248,110,73,.5); background:rgba(248,110,73,.12); }++/* pill group (#19): multi-type pages render one clustered badge box */+.pill-tr { display:flex; gap:.5cqw; }++/* pill over vitrine (#17): the scrim makes it read as a deliberate badge, not+ debris on the app's own corner UI. Placement stays top-right per contract. */+.pill-tr.scrim .pill { background:rgba(8,12,18,.72); backdrop-filter:blur(5px); box-shadow:0 .2cqw 1cqw rgba(0,0,0,.5); }++/* dynamic-vs-baked (#16): the overlay breathes, tags itself, and hold-hides */+.hero.show-mq #mqoverlay { animation:mqbreathe 3.4s ease-in-out infinite; cursor:pointer; }++@keyframes mqbreathe { 0%,100% { opacity:1; } 50% { opacity:.5; } }++.hero.mq-hold #mqoverlay { display:none !important; }++#mqoverlay .mqo-prompt { display:flex; gap:.8cqw; align-items:center; margin-top:.6cqw; font-family:'JetBrains Mono',monospace; font-size:1.05cqw; color:#c9d1d9; }++#mqoverlay .mqo-prompt button { font-size:.95cqw; padding:.2cqw .8cqw; border-radius:.5cqw; border:1px solid rgba(232,161,60,.6); background:rgba(232,161,60,.12); color:#e8a13c; cursor:pointer; }++.hero.show-mq #mqoverlay { outline:2px dashed rgba(232,161,60,.85); outline-offset:-2px; background:rgba(232,161,60,.045); }++.obadge {+ display:flex; align-items:center; gap:.42cqw; font-family:'JetBrains Mono',monospace;+ font-size:.95cqw; color:#aeb8c4;+}++.obadge svg { width:1cqw; height:1cqw; stroke:#8b949e; stroke-width:1.8; fill:none; }++.obadge b { color:#e6edf3; font-weight:500; }++.l-title { display:block; font-family:'Familjen Grotesk'; font-weight:700; font-size:2.3cqw; line-height:1.1; color:#e6edf3; text-decoration:none; margin-bottom:.55cqw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }++.l-title:hover { color:#00e6dc; }++.l-desc { font-size:1.2cqw; color:#c3ccd6; line-height:1.4; max-width:58cqw; margin-bottom:.35cqw; }++.l-author { font-size:1.05cqw; color:#8b949e; }++.l-author a { color:#aeb8c4; text-decoration:none; }++.l-author a:hover { color:#00e6dc; }++.l-right { flex:0 0 auto; display:flex; flex-direction:column; align-items:flex-end; gap:.4cqw; padding-bottom:.15cqw; }++.l-mpn { font-family:'JetBrains Mono',monospace; font-size:.95cqw; color:#aeb8c4; display:none; }++.l-slug { font-family:'JetBrains Mono',monospace; font-size:.95cqw; color:#00b8b1; text-decoration:none; }++.l-slug:hover { color:#00e6dc; }++/* long refs shrink a step instead of eating the overlay (the studio-side answer to wiki#59) */+.l-mpn.fit, .l-slug.fit { font-size:.78cqw; }+++/* ---------- component mode ---------- */+.hero[data-mode=component] .textzone, .hero[data-mode=component] .window { display:none; }++.hero[data-mode=app] .comp-side, .hero[data-mode=app] .ctextzone { display:none; }++body[data-mode=app] .mode-component { display:none; }++body[data-mode=component] .mode-app { display:none; }++.comp-side { position:absolute; top:0; bottom:0; right:0; width:56%; z-index:1; }++.comp-img {+ position:absolute; inset:0; background-size:cover; background-position:center;+ -webkit-mask-image:linear-gradient(90deg, transparent 0, #000 26%);+ mask-image:linear-gradient(90deg, transparent 0, #000 26%);+}++.hero[data-imgsrc=photo] .comp-img { background-image:url('comp/photo.png'); }++.hero[data-imgsrc=raytrace] .comp-img { background-image:url('comp/raytrace.png'); }++.hero[data-imgsrc=phong] .comp-img { background-image:url('comp/phong.png'); background-size:contain; background-repeat:no-repeat; background-position:center 40%; }++.hero[data-clayout=photo-left] .comp-side { left:0; right:auto; }++.hero[data-clayout=photo-left] .comp-img {+ -webkit-mask-image:linear-gradient(270deg, transparent 0, #000 26%);+ mask-image:linear-gradient(270deg, transparent 0, #000 26%);+}++.hero[data-clayout=photo-left] .ctextzone { left:auto; right:var(--inset); }++.hero[data-clayout=photo-full] .comp-side { left:0; width:100%; }++.hero[data-clayout=photo-full] .comp-img { -webkit-mask-image:none; mask-image:none; }++.hero[data-clayout=photo-full] .comp-img::after {+ content:''; position:absolute; inset:0;+ background:linear-gradient(90deg, rgba(10,14,20,.88), rgba(10,14,20,.3) 55%, rgba(10,14,20,.12));+}++.ctextzone { position:absolute; left:var(--inset); top:var(--inset); width:37cqw; z-index:3; }++.c-chips { display:flex; gap:.8cqw; margin-bottom:2cqw; flex-wrap:wrap; }++.c-family {+ display:inline-block; font-family:'JetBrains Mono',monospace; font-size:.9cqw; letter-spacing:.12em;+ color:#00e6dc; background:rgba(5,9,14,.55); border:1px solid rgba(0,230,220,.3);+ border-radius:999px; padding:.35cqw 1cqw; text-transform:uppercase;+}++.c-mpnchip { color:#aeb8c4; border-color:rgba(255,255,255,.16); display:none; }++.c-name { font-family:'Familjen Grotesk'; font-weight:700; font-size:5.5cqw; line-height:1.02; letter-spacing:-.08cqw; color:#e6edf3; margin-bottom:.9cqw; text-shadow:0 2px 12px rgba(0,0,0,.5); }++.c-mpn { font-family:'JetBrains Mono',monospace; font-size:1.05cqw; color:#8b949e; margin-bottom:1cqw; display:none; }++.c-spec { font-size:2cqw; color:#aeb8c4; line-height:1.45; max-width:30cqw; text-shadow:0 1px 8px rgba(0,0,0,.5); }++.hero[data-mpn=eyebrow] .c-mpnchip { display:inline-block; }++.hero[data-mpn=under-title] .c-mpn { display:block; }++.hero[data-mpn=overlay] .l-mpn { display:block; }+++/* guides */+.guide { position:absolute; left:0; right:0; top:66.6%; z-index:6; border-top:1.5px dashed rgba(0,230,220,.55); pointer-events:none; display:none; }++.guide span {+ position:absolute; right:1cqw; top:.4cqw; font-family:'JetBrains Mono',monospace; font-size:.8cqw;+ color:#00e6dc; background:rgba(10,14,20,.7); padding:.2cqw .6cqw; border-radius:.35cqw;+}++.hero.guides .guide { display:block; }+++/* ---------- in-context previews (chrome copied 1:1 from git-wiki style.css) ---------- */+#contexts { display:flex; flex-wrap:wrap; gap:16px; width:100%; max-width:1000px; align-items:flex-start; }++.ctx { min-width:0; }++.ctxlabel { font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--text-2); margin-bottom:6px; }++.ctxframe { background:#0d1117; border:1px solid var(--border); border-radius:10px; padding:14px; }++.clone-slot { position:absolute; inset:0; }++.clone-slot .hero { width:100%; border-radius:0; border:none; }++.heroslot .hero { width:100%; border-radius:8px; }++.hero.preview .guide { display:none !important; }++.hero.preview a { pointer-events:none; }+++/* wiki type badges (style.css .badge) */+#contexts .badge { display:inline-block; padding:2px 10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.5px; border-radius:12px; line-height:1.6; text-align:center; }++#contexts .badge-app { background:rgba(56,139,253,.15); color:#58a6ff; border:1px solid rgba(56,139,253,.3); }++#contexts .badge-skill { background:rgba(188,140,255,.15); color:#bc8cff; border:1px solid rgba(188,140,255,.3); }++#contexts .badge-component { background:rgba(210,153,34,.15); color:#d29922; border:1px solid rgba(210,153,34,.3); }++#contexts .badge-bootstrap { background:rgba(0,184,176,.15); color:#00b8b0; border:1px solid rgba(0,184,176,.3); }+++/* homepage marquee (style.css .popular-marquee / .mq-* / .ts-slide-*) */+.mqwrap { height:430px; overflow:hidden; position:relative; }++.mqwrap::before, .mqwrap::after { content:""; position:absolute; left:0; right:0; height:64px; z-index:3; pointer-events:none; }++.mqwrap::before { top:0; background:linear-gradient(to bottom,#0d1117,transparent); }++.mqwrap::after { bottom:0; background:linear-gradient(to top,#0d1117,transparent); }++.mqcol { transform:perspective(900px) rotateY(15deg); }++.mq-track { display:flex; flex-direction:column; gap:20px; will-change:transform; animation:mq-roll-up 130s linear infinite; }++.mqwrap:hover .mq-track { animation-play-state:paused; }++@keyframes mq-roll-up { from { transform:translateY(0); } to { transform:translateY(-50%); } }++.ts-slide { position:relative; aspect-ratio:16/10; display:block; overflow:hidden; background:#161b22; color:#fff; border-radius:14px; flex:none; text-decoration:none; }++.ts-hero-fallback { width:100%; height:100%; display:flex; align-items:center; justify-content:center; padding:32px; text-align:center; background:linear-gradient(135deg,#0f766e,#1d4ed8 60%,#6d28d9); font-size:26px; font-weight:700; color:#fff; font-family:'Familjen Grotesk'; }++.ts-slide-shade { position:absolute; inset:0; background:linear-gradient(to top, rgba(5,9,14,.95) 0%, rgba(5,9,14,.82) 28%, rgba(5,9,14,.5) 55%, rgba(5,9,14,0) 82%); }++.ts-slide-info { position:absolute; left:0; right:0; bottom:0; padding:20px 26px; }++.ts-slide-badges { position:absolute; top:12px; right:14px; display:flex; align-items:center; gap:10px; }++#contexts .ts-slide-badges .badge { background:rgba(5,9,14,.72); box-shadow:0 1px 4px rgba(0,0,0,.4); }++.ts-slide-score { display:inline-flex; align-items:center; gap:4px; font-size:12px; font-weight:600; color:#fff; background:rgba(5,9,14,.72); padding:2px 9px; border-radius:10px; box-shadow:0 1px 4px rgba(0,0,0,.4); }++.ts-slide-stars { display:inline-flex; align-items:center; gap:3px; font-size:12px; color:rgba(255,255,255,.85); background:rgba(5,9,14,.72); padding:2px 9px; border-radius:10px; box-shadow:0 1px 4px rgba(0,0,0,.4); }++.ts-slide-title { font-size:23px; font-weight:700; margin:6px 0 3px; color:#fff; font-family:'Familjen Grotesk'; text-shadow:0 1px 2px rgba(0,0,0,.85), 0 2px 14px rgba(0,0,0,.55); }++.ts-slide-brief { font-size:15px; line-height:1.5; color:rgba(255,255,255,.88); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; margin:0 0 4px; text-shadow:0 1px 2px rgba(0,0,0,.85), 0 2px 10px rgba(0,0,0,.6); }++.ts-slide-owner { font-size:12px; color:rgba(255,255,255,.72); text-shadow:0 1px 2px rgba(0,0,0,.85); }+++/* index page card grid (style.css .page-grid / .page-card-*) */+.page-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:16px; }++.page-card { position:relative; display:flex; flex-direction:column; text-decoration:none; color:#e6edf3; padding:0; overflow:hidden; background:#161b22; border:1px solid #30363d; border-radius:6px; transition:border-color .2s, box-shadow .2s; }++.page-card:hover { border-color:rgba(0,184,176,.3); box-shadow:0 4px 12px rgba(0,0,0,.2); }++.page-card-hero { aspect-ratio:16/10; background:#1c2128; display:flex; align-items:center; justify-content:center; overflow:hidden; border-bottom:1px solid #30363d; position:relative; }++.page-card-hero-placeholder { display:flex; align-items:center; justify-content:center; width:100%; height:100%; }++.page-card-body { padding:16px; display:flex; flex-direction:column; gap:6px; flex:1; }++.page-card-top { position:absolute; top:8px; right:10px; z-index:2; display:flex; align-items:center; gap:8px; }++.page-card-title { font-size:15px; font-weight:600; line-height:1.3; margin:0; }++.page-card-brief { color:#8b949e; font-size:13px; margin:0; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }++.page-card-meta { font-size:12px; color:#484f58; display:flex; align-items:center; gap:4px; margin-top:auto; }++.page-card-stars { display:inline-flex; align-items:center; gap:3px; }++.page-card-tags { display:flex; gap:4px; flex-wrap:wrap; margin-top:4px; }++.chip-sm { display:inline-block; padding:1px 8px; font-size:11px; background:rgba(0,184,176,.15); color:#00b8b0; border-radius:12px; border:1px solid rgba(0,184,176,.3); line-height:1.5; }++.page-card-install { margin-top:8px; padding:6px 8px; background:#0d1117; border:1px solid #30363d; border-radius:6px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#8b949e; display:flex; align-items:center; gap:8px; }++.page-card-install code { background:transparent; color:#8b949e; flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }++.dot-sep { color:#484f58; }+++/* adom-desktop right bar (proposed v2 overlay style) */+.deskmock { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:10px; }++.deskhead { font-family:'Familjen Grotesk'; font-weight:500; font-size:11px; letter-spacing:.07em; text-transform:uppercase; color:var(--text-2); }+++/* Other owners never get the Adom mark, and there is NO placeholder: the+ logo slot renders nothing in preview and export alike. Non-adom owners+ bring their own palette via the Owner brand controls; the placement grid+ is the consistency we keep, not the colors. */+.wordmark.noadom { visibility:hidden; } /* the slot is RESERVED: the title anchor holds with or without a logo */++/* full-import wordmark stamp (#37): the ONE standard logo (same size and+ position as every composed hero: 7.8cqw at the --inset corner) baked over+ an import, so builders stop drawing their own at improvised sizes */+.fi-logo {+ position:absolute; left:var(--inset); top:var(--inset); z-index:4; display:none;+ width:7.8cqw; height:1.72cqw; background:#e6edf3;+ -webkit-mask:url('adom-logo.svg') no-repeat left center / contain;+ mask:url('adom-logo.svg') no-repeat left center / contain;+}++.hero.fullimport.stamp-logo .fi-logo { display:block; }+++/* owner brand: non-adom accent + background */+#hero.branded { background: var(--brand-bg, #0d1117) !important; }++#hero.branded .bar { background: linear-gradient(180deg,+ color-mix(in srgb, var(--brand-accent, #00e6dc) 55%, transparent),+ color-mix(in srgb, var(--brand-accent, #00b8b0) 12%, transparent)); }++#hero.branded .h-sub .accent { background: linear-gradient(90deg,+ var(--brand-accent, #00e6dc),+ color-mix(in srgb, var(--brand-accent, #00e6dc) 45%, #fff));+ -webkit-background-clip:text; background-clip:text; }++/* new background families so heroes stop looking identical */+.hero[data-theme=aurora] {+ background:+ radial-gradient(90cqw 60cqw at 12% -10%, rgba(0,184,177,.28), transparent 60%),+ radial-gradient(70cqw 55cqw at 95% 20%, rgba(88,101,242,.22), transparent 65%),+ radial-gradient(80cqw 70cqw at 55% 115%, rgba(140,107,247,.20), transparent 60%),+ #0a0f16;+}++.hero[data-theme=mesh] {+ background:+ conic-gradient(from var(--ga) at 28% 30%, rgba(0,184,177,.16), transparent 32%),+ conic-gradient(from calc(var(--ga) + 120deg) at 75% 65%, rgba(88,166,255,.13), transparent 36%),+ conic-gradient(from calc(var(--ga) + 240deg) at 45% 90%, rgba(140,107,247,.12), transparent 30%),+ #0b1016;+}++.hero[data-theme=circuitry] { background:#080d13; }++#circuitcanvas { display:none; position:absolute; inset:0; width:100%; height:100%; z-index:0; }++.hero[data-theme=circuitry] #circuitcanvas { display:block; }++.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; }+++/* render mode: ?render=1 strips all chrome for the server-side PNG shot */+body[data-render] header, body[data-render] #panel, body[data-render] #contexts,+body[data-render] #toasts, body[data-render] .guide, body[data-render] #verifybar,+body[data-render] #approvedbar,+body[data-render] #tourpop { display:none !important; }++/* Per Colby (2026-07-23): the image carries the title, the type pill (top+ right), and the full slug (bottom right); the pill and slug BAKE into the+ PNG. The page shows the image bare. Only the homepage marquee overlays+ dynamic info (stats, author), and it brings its own, so the lower band+ sim never exports. */+body[data-render] .hero .lower { display:none !important; }++body[data-render] #mqoverlay { display:none !important; }++/* The dashed YOUR LOGO box is a posing placeholder, not content. No logo+ posed = nothing exported, not a placeholder. */+body[data-render] .wordmark.noadom { display:none !important; }++body[data-render] main, body[data-render] #stage { display:block; padding:0; overflow:hidden; }++body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; height:1000px; border-radius:0; border:none; box-shadow:none; }
public/index.html+24−1843@@ -15,578 +15,8 @@ <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Hero Studio</title> <link rel="icon" href='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="%23e6edf3" stroke-width="1.8"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="14.5" x2="21" y2="14.5"/><line x1="6" y1="17.5" x2="13" y2="17.5"/></svg>'>-<style>-@font-face { font-family: 'Familjen Grotesk'; src: url('fonts/familjen-grotesk-700.woff2') format('woff2'); font-weight: 700; font-display: swap; }-@font-face { font-family: 'Familjen Grotesk'; src: url('fonts/familjen-grotesk-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }-@font-face { font-family: 'Satoshi'; src: url('fonts/satoshi-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }-@font-face { font-family: 'Satoshi'; src: url('fonts/satoshi-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }-@font-face { font-family: 'JetBrains Mono'; src: url('fonts/jetbrains-mono-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }--:root {- --bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d; --border:#30363d;- --text:#e6edf3; --text-2:#8b949e; --text-3:#484f58;- --accent:#00b8b0; --accent-bright:#00e6dc; --purple:#8c6bf7; --blue:#58a6ff;- --green:#3fb950; --red:#f85149; --yellow:#d29922;- --grad:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7);-}-* { box-sizing:border-box; margin:0; padding:0; }-html,body { height:100%; }-body {- background:var(--bg); color:var(--text);- font-family:'Satoshi',sans-serif; font-size:14px;- display:flex; flex-direction:column; overflow:hidden;-}--/* ---------- 44px header, three zones ---------- */-header {- height:44px; flex:0 0 44px; display:flex; align-items:center; gap:12px;- padding:0 12px; background:var(--surface); border-bottom:1px solid var(--border);-}-.brand { display:flex; align-items:center; gap:8px; white-space:nowrap; }-.brand svg { width:16px; height:16px; stroke:var(--text); }-.brand .name { font-family:'Familjen Grotesk'; font-weight:500; font-size:14px; color:var(--accent); }-.brand .sub { color:var(--text-2); font-size:13px; }-.vsep { width:1px; height:22px; background:var(--border); flex:0 0 1px; }-.subject { display:flex; align-items:baseline; gap:10px; min-width:0; }-.subject b { font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }-.subject .meta { font-family:'JetBrains Mono',monospace; font-size:11.5px; color:var(--text-2); white-space:nowrap; }-.actions { margin-left:auto; display:flex; gap:6px; }-.iconbtn {- width:30px; height:30px; border:1px solid var(--border); border-radius:8px; background:transparent;- display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all .15s ease;-}-.iconbtn svg { width:16px; height:16px; stroke:var(--text); fill:none; stroke-width:1.8; }-.iconbtn:hover { background:var(--elevated); border-color:var(--text-3); }-.iconbtn.on { border-color:var(--accent); background:rgba(0,184,176,.08); }-@media (max-width:720px){ .subject .meta { display:none; } }--/* ---------- body ---------- */-main { flex:1; display:flex; min-height:0; }-#panel {- flex:0 0 264px; overflow-y:auto; background:var(--surface); border-right:1px solid var(--border);- padding:14px 14px 40px;-}-#panel h3 {- font-family:'Familjen Grotesk'; font-weight:500; font-size:11px; letter-spacing:.08em;- text-transform:uppercase; color:var(--text-2); margin:18px 0 8px;-}-#panel h3:first-child { margin-top:2px; }-select {- width:100%; background:var(--elevated); color:var(--text); border:1px solid var(--border);- border-radius:8px; padding:7px 8px; font-family:'Satoshi'; font-size:13px; cursor:pointer;-}-.optgrid { display:flex; flex-wrap:wrap; gap:6px; }-.opt {- border:1px solid var(--border); background:var(--elevated); color:var(--text-2); border-radius:8px;- padding:6px 9px; font-size:12.5px; cursor:pointer; transition:all .15s ease; font-family:'Satoshi';-}-.opt:hover { color:var(--text); border-color:var(--text-3); }-.opt.on { color:var(--text); border-color:var(--accent); background:rgba(0,184,176,.10); }-.swatches { display:flex; gap:6px; }-.swatch {- width:38px; height:26px; border-radius:6px; border:1px solid var(--border); cursor:pointer;- transition:all .15s ease; position:relative;-}-.swatch.on { outline:2px solid var(--accent); outline-offset:1px; }-.sw-midnight { background:linear-gradient(155deg,#0a0e14,#0d1117 45%,#071318); }-.sw-image { background:repeating-conic-gradient(#1c2530 0 25%, #0d1117 0 50%) 0 0/8px 8px; }-.hero[data-theme=image] .bg { background-color:#0a0e14; background-image:var(--bgimg, none); background-repeat:repeat; background-size:var(--bgtile, 22cqw); }-.hero[data-theme=image].bg-cover .bg { background-repeat:no-repeat; background-size:cover; background-position:center; }-.sw-pcb { background:linear-gradient(155deg,#03211f,#04302c); }-.sw-onion { background:repeating-radial-gradient(circle at 90% 10%, #101a2c 0 4px, #16223a 4px 6px); }-.sw-pill { background:#0c3a3c; }-.sw-grid { background:#0c3a3c url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><rect x="2" y="2" width="8" height="8" fill="none" stroke="%23ffffff" stroke-opacity=".35" stroke-width="1"/></svg>'); }-.sw-grad { background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7); }-input[type=range] { width:100%; accent-color:var(--accent); cursor:pointer; }-label.chk { display:flex; align-items:center; gap:8px; padding:5px 0; cursor:pointer; color:var(--text); font-size:13px; }-label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:pointer; }-.hint { font-size:11.5px; color:var(--text-2); line-height:1.45; margin-top:6px; }-.btn {- width:100%; margin-top:8px; padding:8px 10px; border-radius:8px; border:1px solid var(--border);- background:var(--elevated); color:var(--text); font-family:'Satoshi'; font-size:13px; cursor:pointer;- transition:all .15s ease;-}-.btn:hover { border-color:var(--accent); background:rgba(0,184,176,.08); }--/* ---------- stage + hero canvas ---------- */-#stage { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:18px; padding:22px; min-width:0; overflow:auto; }-.hero {- /* equal safe-area margins on all four sides (John, hero-studio#1 ask 7);- corner badges keep their own uniform tighter inset */- --inset:4.5cqw; --badge-inset:2.6cqw;- aspect-ratio:16/10; position:relative; overflow:hidden;- border-radius:12px; border:1px solid var(--border); container-type:inline-size;- --ga:155deg;-}-#hero { width:100%; max-width:1000px; flex:0 0 auto; box-shadow:0 24px 70px rgba(0,0,0,.5); }--/* backgrounds: continue to the very bottom, per the new rule */-.bg { position:absolute; inset:0; }-/* mirror mode flips the whole background composition (glows, tiles, corner motifs) */-.hero[data-layout=mirror] .bg { transform:scaleX(-1); }-.hero[data-theme=midnight] .bg {- background:- radial-gradient(1200px 800px at 88% -10%, rgba(0,184,177,.22), transparent 60%),- radial-gradient(900px 700px at -5% 110%, rgba(140,107,247,.18), transparent 55%),- linear-gradient(var(--ga),#0a0e14 0%,#0d1117 45%,#071318 100%);-}-.hero[data-theme=midnight] .bg::after {- content:''; position:absolute; inset:0;- background-image:radial-gradient(rgba(255,255,255,.05) 1px, transparent 1.4px);- background-size:2.2cqw 2.2cqw;- -webkit-mask-image:radial-gradient(700px 560px at 30% 30%, #000, transparent 75%);- mask-image:radial-gradient(700px 560px at 30% 30%, #000, transparent 75%);-}-.hero[data-theme=pcb] .bg {- background:- radial-gradient(1100px 760px at 85% -8%, rgba(0,230,220,.14), transparent 60%),- linear-gradient(var(--ga),#031b19 0%,#04302c 55%,#02211e 100%);-}-.hero[data-theme=pcb] .bg::after {- content:''; position:absolute; inset:0; opacity:.5;- background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><g stroke="%2300e6dc" stroke-opacity=".09" fill="none" stroke-width="1.4"><path d="M10 20 h56 l18 18 v50"/><path d="M84 150 v-36 l22 -22 h44"/><path d="M20 140 v-48 l14 -14"/><circle cx="10" cy="20" r="3.2"/><circle cx="84" cy="88" r="3.2"/><circle cx="150" cy="92" r="3.2"/><circle cx="20" cy="140" r="3.2"/><circle cx="34" cy="78" r="3.2"/></g></svg>');- background-size:10cqw 10cqw;-}-.hero[data-theme=onion] .bg {- background:- radial-gradient(900px 700px at -5% 112%, rgba(140,107,247,.16), transparent 55%),- linear-gradient(var(--ga),#0b1322 0%,#101a2c 55%,#0a111e 100%);-}-/* authentic Kickstand corner dome (brand deck onion motif), teal-to-purple stroke */-.hero[data-theme=onion] .bg::after {- content:''; position:absolute; inset:0;- background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stop-color="%238c6bf7"/><stop offset=".55" stop-color="%2339b8ff"/><stop offset="1" stop-color="%2300e6dc"/></linearGradient></defs><g fill="none" stroke="url(%23g)" stroke-width="4" opacity=".5"><path d="M356 400 V234 Q356 60 200 60 Q44 60 44 234 V400"/><path d="M312 400 V250 Q312 110 200 110 Q88 110 88 250 V400"/><path d="M268 400 V266 Q268 160 200 160 Q132 160 132 266 V400"/><path d="M224 400 V282 Q224 210 200 210 Q176 210 176 282 V400"/></g></svg>');- background-repeat:no-repeat;- background-position:right -7cqw bottom -9cqw;- background-size:50cqw auto;-}-.hero[data-theme=pill] .bg {- background:- radial-gradient(1000px 720px at 90% -8%, rgba(0,184,177,.12), transparent 60%),- linear-gradient(var(--ga),#082a29 0%,#0c3a3c 55%,#062220 100%);-}-/* authentic pattern-pill: scattered capsule + circle outlines, mixed brand strokes */-.hero[data-theme=pill] .bg::after {- content:''; position:absolute; inset:0;- background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="260" height="200"><g fill="none" stroke-width="2"><rect x="16" y="22" width="26" height="62" rx="13" stroke="%2364ABFF" stroke-opacity=".3"/><circle cx="112" cy="38" r="12" stroke="%2300e6dc" stroke-opacity=".28"/><rect x="196" y="14" width="28" height="72" rx="14" stroke="%2339b8ff" stroke-opacity=".3"/><rect x="84" y="128" width="62" height="26" rx="13" stroke="%2300b8b1" stroke-opacity=".28"/><circle cx="226" cy="150" r="10" stroke="%238c6bf7" stroke-opacity=".26"/><rect x="20" y="150" width="26" height="40" rx="13" stroke="%2300e6dc" stroke-opacity=".2"/></g></svg>');- background-size:16cqw auto;-}-.hero[data-theme=grid] .bg {- background:- radial-gradient(1000px 700px at 85% -10%, rgba(0,230,220,.10), transparent 60%),- linear-gradient(var(--ga),#07302e 0%,#0c3a3c 55%,#052220 100%);-}-/* authentic pattern-grid: fine outlined-square field */-.hero[data-theme=grid] .bg::after {- content:''; position:absolute; inset:0; opacity:.55;- background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><rect x="4" y="4" width="18" height="18" fill="none" stroke="%23ffffff" stroke-opacity=".13" stroke-width="1.3"/></svg>');- background-size:2.4cqw 2.4cqw;-}-.hero[data-theme=gradient] .bg {- background:- linear-gradient(155deg, rgba(5,10,18,.42), rgba(5,10,18,.14) 55%, rgba(5,10,18,.5)),- linear-gradient(var(--ga),#00c8bf 0%,#2f9dfa 55%,#8c6bf7 100%);-}--/* ---------- image-zone text column ---------- */-.textzone {- position:absolute; left:var(--inset); top:var(--inset); width:34cqw; z-index:3;- padding-right:1.5cqw;-}-.hero[data-layout=mirror] .textzone { left:auto; right:var(--inset); }-.wordmark { display:flex; align-items:center; margin-bottom:3.2cqw; }-/* the official Adom wordmark (adom-logo.svg), recolored white for dark heroes via mask */-.wordmark .wmark {- display:block; width:7.8cqw; height:1.72cqw; background:#e6edf3;- -webkit-mask:url('adom-logo.svg') no-repeat left center / contain;- 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: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: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; }-.h-sub b { color:#e6edf3; font-weight:500; }-.h-desc { font-size:var(--fs-desc, 1.35cqw); line-height:1.5; color:#8b949e; max-width:30cqw; }--/* ---------- the app-window mock (the "screenshot") ---------- */-.window {- position:absolute; z-index:2; border-radius:.9cqw .9cqw 0 0; overflow:hidden;- background:#10151c; border:1px solid rgba(255,255,255,.09);- box-shadow:0 1.7cqw 5cqw rgba(0,0,0,.62), 0 0 8cqw rgba(0,184,177,.07);- display:flex; flex-direction:column;-}-.hero[data-layout=bleed-bottom] .window { right:4.5cqw; top:6cqw; width:56cqw; height:64cqw; }-.hero[data-layout=bleed-right] .window { right:-8cqw; top:6cqw; width:56cqw; height:44cqw; border-radius:.9cqw 0 0 .9cqw; }-.hero[data-layout=bleed-top] .window { right:4.5cqw; top:-6cqw; width:52cqw; height:42cqw; border-radius:0 0 .9cqw .9cqw; }-.hero[data-layout=bleed-corner-br] .window { right:-7cqw; top:14cqw; width:56cqw; height:52cqw; border-radius:.9cqw 0 0 0; }-.hero[data-layout=bleed-corner-tr] .window { right:-7cqw; top:-6cqw; width:56cqw; height:44cqw; border-radius:0 0 0 .9cqw; }-.hero[data-layout=contained] .window { right:4.5cqw; top:6cqw; width:50cqw; height:33cqw; border-radius:.9cqw; }-.hero[data-layout=mirror] .window { left:4.5cqw; top:6cqw; width:56cqw; height:64cqw; }-.hero[data-layout=showcase] .window { right:-4cqw; top:4cqw; width:66cqw; height:58cqw; border-radius:.9cqw 0 0 .9cqw; }-.hero[data-layout=showcase]::after {- content:''; position:absolute; inset:0; z-index:2; pointer-events:none;- background:linear-gradient(100deg, rgba(5,9,14,.88) 0%, rgba(5,9,14,.55) 30%, rgba(5,9,14,0) 55%);-}-.hero[data-layout=showcase] .textzone { z-index:3; width:30cqw; }-.titlebar {- flex:0 0 2.1cqw; background:#181f28; border-bottom:1px solid rgba(255,255,255,.06);- display:flex; align-items:center; padding:0 .9cqw; gap:.6cqw;-}-.titlebar .t { font-family:'Satoshi'; font-size:.82cqw; color:#8b949e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }-.winbtns { margin-left:auto; display:flex; gap:.9cqw; align-items:center; }-.winbtns svg { width:.72cqw; height:.72cqw; stroke:#8b949e; stroke-width:2; fill:none; }-.appbody { flex:1; display:flex; min-height:0; position:relative; }-.vitrine-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.has-vitrine .vitrine-img { display:block; }-.fullimport-img { display:none; position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }-.hero.fullimport .fullimport-img { display:block; }-.hero.fullimport .textzone, .hero.fullimport .window { display:none; }-.sidebar { flex:0 0 11cqw; background:#131920; border-right:1px solid rgba(255,255,255,.05); padding:.8cqw .7cqw; }-.srow { height:1.5cqw; border-radius:.35cqw; background:rgba(255,255,255,.05); margin-bottom:.55cqw; }-.srow.on { background:rgba(0,184,176,.18); border:1px solid rgba(0,184,176,.35); }-.content { flex:1; padding:1cqw 1.1cqw; display:flex; flex-direction:column; gap:.8cqw; }-.crow { display:flex; gap:.6cqw; align-items:center; }-.chipk { height:1.3cqw; border-radius:999px; background:rgba(255,255,255,.07); }-.bars { flex:1; display:flex; align-items:flex-end; gap:.5cqw; min-height:0; }-.bar { flex:1; border-radius:.3cqw .3cqw 0 0; background:linear-gradient(180deg, rgba(0,230,220,.55), rgba(0,184,176,.12)); }-.tbl { display:flex; flex-direction:column; gap:.45cqw; }-.trow { display:flex; gap:.5cqw; }-.tcell { height:1.05cqw; border-radius:.25cqw; background:rgba(255,255,255,.06); }--/* ---------- the lower-third textual overlay ---------- */-#mqoverlay { display:none; position:absolute; left:0; right:0; bottom:0; z-index:5;- padding:3cqw 3cqw 2.2cqw;- background:linear-gradient(180deg, rgba(5,9,14,0) 0%, rgba(5,9,14,.55) 35%, rgba(5,9,14,.9) 100%); }-.hero.show-mq #mqoverlay { display:block; }-#mqoverlay .mqo-badges { display:flex; gap:1cqw; align-items:center; margin-bottom:.9cqw; }-#mqoverlay .mqo-title { font-family:'Familjen Grotesk'; font-weight:700; font-size:3.2cqw; color:#fff; margin:0 0 .5cqw; }-#mqoverlay .mqo-brief { font-size:1.5cqw; color:#c9d1d9; margin:0 0 .7cqw; max-width:60cqw; }-#mqoverlay .mqo-owner { font-family:'JetBrains Mono',monospace; font-size:1.1cqw; color:#00b8b1; }-.pill-tr { position:absolute; top:var(--badge-inset); right:var(--badge-inset); z-index:6; }-.slug-br { position:absolute; right:var(--badge-inset); bottom:var(--badge-inset); z-index:6; }-.lower {- position:absolute; left:0; right:0; bottom:0; height:33.4%; z-index:4;- background:linear-gradient(180deg, rgba(10,14,20,0) 0%, rgba(10,14,20,.62) 22%, rgba(10,14,20,.94) 62%, rgba(10,14,20,.98) 100%);- display:flex; align-items:flex-end;-}-.lowerin {- width:100%; padding:0 5.75cqw 3.1cqw; display:flex; align-items:flex-end; gap:2cqw;-}-.lmain { min-width:0; flex:1; }-.badges { display:flex; align-items:center; gap:.9cqw; margin-bottom:1cqw; }-.pill {- font-family:'Familjen Grotesk'; font-weight:700; font-size:.85cqw; letter-spacing:.14em;- padding:.3cqw .85cqw; border-radius:999px; border:1px solid; text-transform:uppercase;-}-.pill.app { color:#00e6dc; border-color:rgba(0,230,220,.45); background:rgba(0,184,176,.10); }-.pill.skill { color:#b39dfb; border-color:rgba(140,107,247,.5); background:rgba(140,107,247,.12); }-.pill.bootstrap { color:#79b8ff; border-color:rgba(88,166,255,.5); background:rgba(88,166,255,.12); }-.pill.component { color:#e3b341; border-color:rgba(210,153,34,.5); background:rgba(210,153,34,.12); }-.pill.service { color:#ff9d76; border-color:rgba(248,110,73,.5); background:rgba(248,110,73,.12); }-/* pill group (#19): multi-type pages render one clustered badge box */-.pill-tr { display:flex; gap:.5cqw; }-/* pill over vitrine (#17): the scrim makes it read as a deliberate badge, not- debris on the app's own corner UI. Placement stays top-right per contract. */-.pill-tr.scrim .pill { background:rgba(8,12,18,.72); backdrop-filter:blur(5px); box-shadow:0 .2cqw 1cqw rgba(0,0,0,.5); }-/* dynamic-vs-baked (#16): the overlay breathes, tags itself, and hold-hides */-.hero.show-mq #mqoverlay { animation:mqbreathe 3.4s ease-in-out infinite; cursor:pointer; }-@keyframes mqbreathe { 0%,100% { opacity:1; } 50% { opacity:.5; } }-.hero.mq-hold #mqoverlay { display:none !important; }-#mqoverlay .mqo-prompt { display:flex; gap:.8cqw; align-items:center; margin-top:.6cqw; font-family:'JetBrains Mono',monospace; font-size:1.05cqw; color:#c9d1d9; }-#mqoverlay .mqo-prompt button { font-size:.95cqw; padding:.2cqw .8cqw; border-radius:.5cqw; border:1px solid rgba(232,161,60,.6); background:rgba(232,161,60,.12); color:#e8a13c; cursor:pointer; }-.hero.show-mq #mqoverlay { outline:2px dashed rgba(232,161,60,.85); outline-offset:-2px; background:rgba(232,161,60,.045); }-/* the stagebar (#21/#23): stage chrome lives ABOVE the stage, never on it */-#stagebar { display:none; align-items:center; gap:10px; margin:10px 0 8px; }-#stagebar.on { display:flex; }-#stagebar .seg { display:flex; border:1px solid #30363d; border-radius:8px; overflow:hidden; }-#stagebar .seg button { background:#0d1117; color:var(--text); border:0; padding:6px 12px; font-size:12.5px; cursor:pointer; }-#stagebar .seg button.on { background:rgba(0,184,176,.18); color:var(--accent-bright); }-#stagebar .legend { font-size:12px; color:#8b949e; line-height:1.35; }-#stagebar .legend b { color:#e8a13c; font-weight:600; }-.obadge {- display:flex; align-items:center; gap:.42cqw; font-family:'JetBrains Mono',monospace;- font-size:.95cqw; color:#aeb8c4;-}-.obadge svg { width:1cqw; height:1cqw; stroke:#8b949e; stroke-width:1.8; fill:none; }-.obadge b { color:#e6edf3; font-weight:500; }-.l-title { display:block; font-family:'Familjen Grotesk'; font-weight:700; font-size:2.3cqw; line-height:1.1; color:#e6edf3; text-decoration:none; margin-bottom:.55cqw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }-.l-title:hover { color:#00e6dc; }-.l-desc { font-size:1.2cqw; color:#c3ccd6; line-height:1.4; max-width:58cqw; margin-bottom:.35cqw; }-.l-author { font-size:1.05cqw; color:#8b949e; }-.l-author a { color:#aeb8c4; text-decoration:none; }-.l-author a:hover { color:#00e6dc; }-.l-right { flex:0 0 auto; display:flex; flex-direction:column; align-items:flex-end; gap:.4cqw; padding-bottom:.15cqw; }-.l-mpn { font-family:'JetBrains Mono',monospace; font-size:.95cqw; color:#aeb8c4; display:none; }-.l-slug { font-family:'JetBrains Mono',monospace; font-size:.95cqw; color:#00b8b1; text-decoration:none; }-.l-slug:hover { color:#00e6dc; }-/* long refs shrink a step instead of eating the overlay (the studio-side answer to wiki#59) */-.l-mpn.fit, .l-slug.fit { font-size:.78cqw; }--/* ---------- component mode ---------- */-.hero[data-mode=component] .textzone, .hero[data-mode=component] .window { display:none; }-.hero[data-mode=app] .comp-side, .hero[data-mode=app] .ctextzone { display:none; }-body[data-mode=app] .mode-component { display:none; }-body[data-mode=component] .mode-app { display:none; }-.comp-side { position:absolute; top:0; bottom:0; right:0; width:56%; z-index:1; }-.comp-img {- position:absolute; inset:0; background-size:cover; background-position:center;- -webkit-mask-image:linear-gradient(90deg, transparent 0, #000 26%);- mask-image:linear-gradient(90deg, transparent 0, #000 26%);-}-.hero[data-imgsrc=photo] .comp-img { background-image:url('comp/photo.png'); }-.hero[data-imgsrc=raytrace] .comp-img { background-image:url('comp/raytrace.png'); }-.hero[data-imgsrc=phong] .comp-img { background-image:url('comp/phong.png'); background-size:contain; background-repeat:no-repeat; background-position:center 40%; }-.hero[data-clayout=photo-left] .comp-side { left:0; right:auto; }-.hero[data-clayout=photo-left] .comp-img {- -webkit-mask-image:linear-gradient(270deg, transparent 0, #000 26%);- mask-image:linear-gradient(270deg, transparent 0, #000 26%);-}-.hero[data-clayout=photo-left] .ctextzone { left:auto; right:var(--inset); }-.hero[data-clayout=photo-full] .comp-side { left:0; width:100%; }-.hero[data-clayout=photo-full] .comp-img { -webkit-mask-image:none; mask-image:none; }-.hero[data-clayout=photo-full] .comp-img::after {- content:''; position:absolute; inset:0;- background:linear-gradient(90deg, rgba(10,14,20,.88), rgba(10,14,20,.3) 55%, rgba(10,14,20,.12));-}-.ctextzone { position:absolute; left:var(--inset); top:var(--inset); width:37cqw; z-index:3; }-.c-chips { display:flex; gap:.8cqw; margin-bottom:2cqw; flex-wrap:wrap; }-.c-family {- display:inline-block; font-family:'JetBrains Mono',monospace; font-size:.9cqw; letter-spacing:.12em;- color:#00e6dc; background:rgba(5,9,14,.55); border:1px solid rgba(0,230,220,.3);- border-radius:999px; padding:.35cqw 1cqw; text-transform:uppercase;-}-.c-mpnchip { color:#aeb8c4; border-color:rgba(255,255,255,.16); display:none; }-.c-name { font-family:'Familjen Grotesk'; font-weight:700; font-size:5.5cqw; line-height:1.02; letter-spacing:-.08cqw; color:#e6edf3; margin-bottom:.9cqw; text-shadow:0 2px 12px rgba(0,0,0,.5); }-.c-mpn { font-family:'JetBrains Mono',monospace; font-size:1.05cqw; color:#8b949e; margin-bottom:1cqw; display:none; }-.c-spec { font-size:2cqw; color:#aeb8c4; line-height:1.45; max-width:30cqw; text-shadow:0 1px 8px rgba(0,0,0,.5); }-.hero[data-mpn=eyebrow] .c-mpnchip { display:inline-block; }-.hero[data-mpn=under-title] .c-mpn { display:block; }-.hero[data-mpn=overlay] .l-mpn { display:block; }--/* guides */-.guide { position:absolute; left:0; right:0; top:66.6%; z-index:6; border-top:1.5px dashed rgba(0,230,220,.55); pointer-events:none; display:none; }-.guide span {- position:absolute; right:1cqw; top:.4cqw; font-family:'JetBrains Mono',monospace; font-size:.8cqw;- color:#00e6dc; background:rgba(10,14,20,.7); padding:.2cqw .6cqw; border-radius:.35cqw;-}-.hero.guides .guide { display:block; }--/* ---------- in-context previews (chrome copied 1:1 from git-wiki style.css) ---------- */-#contexts { display:flex; flex-wrap:wrap; gap:16px; width:100%; max-width:1000px; align-items:flex-start; }-.ctx { min-width:0; }-.ctxlabel { font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--text-2); margin-bottom:6px; }-.ctxframe { background:#0d1117; border:1px solid var(--border); border-radius:10px; padding:14px; }-.clone-slot { position:absolute; inset:0; }-.clone-slot .hero { width:100%; border-radius:0; border:none; }-.heroslot .hero { width:100%; border-radius:8px; }-.hero.preview .guide { display:none !important; }-.hero.preview a { pointer-events:none; }--/* wiki type badges (style.css .badge) */-#contexts .badge { display:inline-block; padding:2px 10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.5px; border-radius:12px; line-height:1.6; text-align:center; }-#contexts .badge-app { background:rgba(56,139,253,.15); color:#58a6ff; border:1px solid rgba(56,139,253,.3); }-#contexts .badge-skill { background:rgba(188,140,255,.15); color:#bc8cff; border:1px solid rgba(188,140,255,.3); }-#contexts .badge-component { background:rgba(210,153,34,.15); color:#d29922; border:1px solid rgba(210,153,34,.3); }-#contexts .badge-bootstrap { background:rgba(0,184,176,.15); color:#00b8b0; border:1px solid rgba(0,184,176,.3); }--/* homepage marquee (style.css .popular-marquee / .mq-* / .ts-slide-*) */-.mqwrap { height:430px; overflow:hidden; position:relative; }-.mqwrap::before, .mqwrap::after { content:""; position:absolute; left:0; right:0; height:64px; z-index:3; pointer-events:none; }-.mqwrap::before { top:0; background:linear-gradient(to bottom,#0d1117,transparent); }-.mqwrap::after { bottom:0; background:linear-gradient(to top,#0d1117,transparent); }-.mqcol { transform:perspective(900px) rotateY(15deg); }-.mq-track { display:flex; flex-direction:column; gap:20px; will-change:transform; animation:mq-roll-up 130s linear infinite; }-.mqwrap:hover .mq-track { animation-play-state:paused; }-@keyframes mq-roll-up { from { transform:translateY(0); } to { transform:translateY(-50%); } }-.ts-slide { position:relative; aspect-ratio:16/10; display:block; overflow:hidden; background:#161b22; color:#fff; border-radius:14px; flex:none; text-decoration:none; }-.ts-hero-fallback { width:100%; height:100%; display:flex; align-items:center; justify-content:center; padding:32px; text-align:center; background:linear-gradient(135deg,#0f766e,#1d4ed8 60%,#6d28d9); font-size:26px; font-weight:700; color:#fff; font-family:'Familjen Grotesk'; }-.ts-slide-shade { position:absolute; inset:0; background:linear-gradient(to top, rgba(5,9,14,.95) 0%, rgba(5,9,14,.82) 28%, rgba(5,9,14,.5) 55%, rgba(5,9,14,0) 82%); }-.ts-slide-info { position:absolute; left:0; right:0; bottom:0; padding:20px 26px; }-.ts-slide-badges { position:absolute; top:12px; right:14px; display:flex; align-items:center; gap:10px; }-#contexts .ts-slide-badges .badge { background:rgba(5,9,14,.72); box-shadow:0 1px 4px rgba(0,0,0,.4); }-.ts-slide-score { display:inline-flex; align-items:center; gap:4px; font-size:12px; font-weight:600; color:#fff; background:rgba(5,9,14,.72); padding:2px 9px; border-radius:10px; box-shadow:0 1px 4px rgba(0,0,0,.4); }-.ts-slide-stars { display:inline-flex; align-items:center; gap:3px; font-size:12px; color:rgba(255,255,255,.85); background:rgba(5,9,14,.72); padding:2px 9px; border-radius:10px; box-shadow:0 1px 4px rgba(0,0,0,.4); }-.ts-slide-title { font-size:23px; font-weight:700; margin:6px 0 3px; color:#fff; font-family:'Familjen Grotesk'; text-shadow:0 1px 2px rgba(0,0,0,.85), 0 2px 14px rgba(0,0,0,.55); }-.ts-slide-brief { font-size:15px; line-height:1.5; color:rgba(255,255,255,.88); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; margin:0 0 4px; text-shadow:0 1px 2px rgba(0,0,0,.85), 0 2px 10px rgba(0,0,0,.6); }-.ts-slide-owner { font-size:12px; color:rgba(255,255,255,.72); text-shadow:0 1px 2px rgba(0,0,0,.85); }--/* index page card grid (style.css .page-grid / .page-card-*) */-.page-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:16px; }-.page-card { position:relative; display:flex; flex-direction:column; text-decoration:none; color:#e6edf3; padding:0; overflow:hidden; background:#161b22; border:1px solid #30363d; border-radius:6px; transition:border-color .2s, box-shadow .2s; }-.page-card:hover { border-color:rgba(0,184,176,.3); box-shadow:0 4px 12px rgba(0,0,0,.2); }-.page-card-hero { aspect-ratio:16/10; background:#1c2128; display:flex; align-items:center; justify-content:center; overflow:hidden; border-bottom:1px solid #30363d; position:relative; }-.page-card-hero-placeholder { display:flex; align-items:center; justify-content:center; width:100%; height:100%; }-.page-card-body { padding:16px; display:flex; flex-direction:column; gap:6px; flex:1; }-.page-card-top { position:absolute; top:8px; right:10px; z-index:2; display:flex; align-items:center; gap:8px; }-.page-card-title { font-size:15px; font-weight:600; line-height:1.3; margin:0; }-.page-card-brief { color:#8b949e; font-size:13px; margin:0; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }-.page-card-meta { font-size:12px; color:#484f58; display:flex; align-items:center; gap:4px; margin-top:auto; }-.page-card-stars { display:inline-flex; align-items:center; gap:3px; }-.page-card-tags { display:flex; gap:4px; flex-wrap:wrap; margin-top:4px; }-.chip-sm { display:inline-block; padding:1px 8px; font-size:11px; background:rgba(0,184,176,.15); color:#00b8b0; border-radius:12px; border:1px solid rgba(0,184,176,.3); line-height:1.5; }-.page-card-install { margin-top:8px; padding:6px 8px; background:#0d1117; border:1px solid #30363d; border-radius:6px; font-family:'JetBrains Mono',monospace; font-size:11px; color:#8b949e; display:flex; align-items:center; gap:8px; }-.page-card-install code { background:transparent; color:#8b949e; flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }-.dot-sep { color:#484f58; }--/* adom-desktop right bar (proposed v2 overlay style) */-.deskmock { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:10px; }-.deskhead { font-family:'Familjen Grotesk'; font-weight:500; font-size:11px; letter-spacing:.07em; text-transform:uppercase; color:var(--text-2); }--/* Other owners never get the Adom mark, and there is NO placeholder: the- logo slot renders nothing in preview and export alike. Non-adom owners- bring their own palette via the Owner brand controls; the placement grid- is the consistency we keep, not the colors. */-.wordmark.noadom { display:none; }-/* full-import wordmark stamp (#37): the ONE standard logo (same size and- position as every composed hero: 7.8cqw at the --inset corner) baked over- an import, so builders stop drawing their own at improvised sizes */-.fi-logo {- position:absolute; left:var(--inset); top:var(--inset); z-index:4; display:none;- width:7.8cqw; height:1.72cqw; background:#e6edf3;- -webkit-mask:url('adom-logo.svg') no-repeat left center / contain;- mask:url('adom-logo.svg') no-repeat left center / contain;-}-.hero.fullimport.stamp-logo .fi-logo { display:block; }--/* owner brand: non-adom accent + background */-#hero.branded { background: var(--brand-bg, #0d1117) !important; }-#hero.branded .bar { background: linear-gradient(180deg,- color-mix(in srgb, var(--brand-accent, #00e6dc) 55%, transparent),- color-mix(in srgb, var(--brand-accent, #00b8b0) 12%, transparent)); }-#hero.branded .h-sub .accent { background: linear-gradient(90deg,- var(--brand-accent, #00e6dc),- color-mix(in srgb, var(--brand-accent, #00e6dc) 45%, #fff));- -webkit-background-clip:text; background-clip:text; }-.brandrow { display:flex; align-items:center; gap:8px; margin:6px 0; }-.brandrow label { font-size:12px; color:var(--text-2); width:84px; }-.brandrow input[type=color] { width:44px; height:26px; border:1px solid var(--border); border-radius:6px; background:var(--surface); padding:1px; cursor:pointer; }--#approvedbar { display:none; margin:0 0 14px; border:1px solid #2ea06b; background:rgba(46,160,107,.09);- border-radius:10px; padding:14px 16px; }-#approvedbar .ab-head { font-family:'Familjen Grotesk'; font-weight:700; color:#4ade80; margin-bottom:6px; }-#approvedbar .ab-body { font-size:13px; color:var(--text); line-height:1.5; margin-bottom:10px; }-#genmodal .gen-approved { font-family:'Familjen Grotesk'; font-weight:700; color:#4ade80; margin-bottom:6px; }-#verifybar { display:none; margin:0 0 14px; border:1px solid var(--accent); background:rgba(0,184,176,.07); border-radius:10px; padding:14px 16px; }-#verifybar .vhead { font-size:13px; color:var(--text); margin-bottom:10px; line-height:1.5; }-#verifybar .vchk { display:block; margin:6px 0; }-#verifybar .vchk .warn { color:#e8a13c; }-#verifybar .vchk .flagged { color:#f85149; font-weight:600; margin-left:6px; }-#v-generate { margin-top:10px; font-size:14px; padding:10px 18px; }-#v-generate:disabled { opacity:.45; cursor:not-allowed; }-/* the layout linter (#18): the studio notices, the human decides */-#lintbox { margin-top:8px; }-#lintbox .lint-item { font-size:12.5px; margin:4px 0; cursor:pointer; line-height:1.4; }-#lintbox .lint-warn { color:#e8a13c; }-#lintbox .lint-fail { color:#f85149; font-weight:600; }-#lintbox .lint-ok { color:#3fb950; font-size:12px; }-#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; }-/* page-field editors (#20) */-.pf-in { width:100%; box-sizing:border-box; background:#0d1117; color:var(--text); border:1px solid #30363d; border-radius:6px; padding:6px 8px; font:inherit; font-size:12.5px; margin:2px 0 8px; }-.pf-in:focus { border-color:var(--accent); outline:none; }-/* zen focus (#12): the hero gets the whole surface */-body.zen #panel { display:none; }-body.zen main { max-width:none; }-/* new background families so heroes stop looking identical */-.hero[data-theme=aurora] {- background:- radial-gradient(90cqw 60cqw at 12% -10%, rgba(0,184,177,.28), transparent 60%),- radial-gradient(70cqw 55cqw at 95% 20%, rgba(88,101,242,.22), transparent 65%),- radial-gradient(80cqw 70cqw at 55% 115%, rgba(140,107,247,.20), transparent 60%),- #0a0f16;-}-.hero[data-theme=mesh] {- background:- conic-gradient(from var(--ga) at 28% 30%, rgba(0,184,177,.16), transparent 32%),- conic-gradient(from calc(var(--ga) + 120deg) at 75% 65%, rgba(88,166,255,.13), transparent 36%),- conic-gradient(from calc(var(--ga) + 240deg) at 45% 90%, rgba(140,107,247,.12), transparent 30%),- #0b1016;-}-.hero[data-theme=circuitry] { background:#080d13; }-#circuitcanvas { display:none; position:absolute; inset:0; width:100%; height:100%; z-index:0; }-.hero[data-theme=circuitry] #circuitcanvas { display:block; }-.sw-aurora { background:radial-gradient(8px 6px at 30% 20%, rgba(0,230,220,.8), transparent), radial-gradient(10px 8px at 70% 80%, rgba(140,107,247,.7), transparent), #0a0f16; }-.sw-mesh { background:conic-gradient(from 155deg at 40% 40%, rgba(0,184,177,.5), transparent 40%), #0b1016; }-.sw-circuitry { background:repeating-linear-gradient(45deg, rgba(0,184,177,.5) 0 1px, transparent 1px 5px), #080d13; }--/* verification walkthrough popout */-#tourpop { display:none; position:fixed; z-index:900; max-width:300px;- background:var(--elevated); border:1px solid var(--accent); border-radius:10px;- padding:12px 14px; box-shadow:0 12px 40px rgba(0,0,0,.6); font-size:13px; }-#tourpop .tp-title { font-family:'Familjen Grotesk'; font-weight:700; margin-bottom:6px; color:var(--accent-bright); }-#tourpop .tp-body { color:var(--text); line-height:1.45; margin-bottom:10px; }-#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; }--/* render mode: ?render=1 strips all chrome for the server-side PNG shot */-body[data-render] header, body[data-render] #panel, body[data-render] #contexts,-body[data-render] #toasts, body[data-render] .guide, body[data-render] #verifybar,-body[data-render] #approvedbar,-body[data-render] #tourpop { display:none !important; }-/* Per Colby (2026-07-23): the image carries the title, the type pill (top- right), and the full slug (bottom right); the pill and slug BAKE into the- PNG. The page shows the image bare. Only the homepage marquee overlays- dynamic info (stats, author), and it brings its own, so the lower band- sim never exports. */-body[data-render] .hero .lower { display:none !important; }-body[data-render] #mqoverlay { display:none !important; }-/* The dashed YOUR LOGO box is a posing placeholder, not content. No logo- posed = nothing exported, not a placeholder. */-body[data-render] .wordmark.noadom { display:none !important; }-body[data-render] main, body[data-render] #stage { display:block; padding:0; overflow:hidden; }-body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; height:1000px; border-radius:0; border:none; box-shadow:none; }--/* generated-hero preview modal */-#genmodal { position:fixed; inset:0; z-index:99997; background:rgba(5,9,14,.8); display:none; align-items:center; justify-content:center; padding:32px; }-#genmodal.show { display:flex; }-.genbox { max-width:min(1000px, 92vw); background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:14px; box-shadow:0 24px 80px rgba(0,0,0,.6); }-.genbox img { display:block; width:100%; border-radius:8px; border:1px solid var(--border); }-.genrow { display:flex; align-items:center; gap:10px; margin-top:10px; }-.genrow .hint { margin:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }-.genrow a { color:var(--accent); font-size:13px; text-decoration:none; }-.genrow a:hover { color:var(--accent-bright); }-.btn-primary { border-color:var(--accent); background:rgba(0,184,176,.12); font-weight:500; }-.btn-primary:hover { background:rgba(0,184,176,.2); }-.btn-primary:disabled { opacity:.55; cursor:wait; }--/* tooltip + toast */-#tip {- position:fixed; z-index:99999; background:var(--overlay); border:1px solid var(--border);- color:var(--text); font-size:12px; line-height:1.4; padding:6px 9px; border-radius:8px;- max-width:240px; pointer-events:none; opacity:0; transform:translateY(3px);- transition:opacity .18s ease, transform .18s ease; text-transform:none;-}-#tip.show { opacity:1; transform:none; }-#toasts { position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:99998; display:flex; flex-direction:column-reverse; gap:8px; }-.toast {- display:flex; align-items:center; gap:8px; background:var(--overlay); border:1px solid var(--border);- border-left:3px solid var(--accent); border-radius:8px; padding:9px 13px; font-size:13px;- box-shadow:0 8px 30px rgba(0,0,0,.45); animation:tin .2s ease;-}-.toast.success { border-left-color:var(--green); }-.toast.error { border-left-color:var(--red); }-@keyframes tin { from { opacity:0; transform:translateY(6px);} }-</style>+<link rel="stylesheet" href="studio.css">+<link rel="stylesheet" href="hero.css"> </head> <body> @@ -605,6 +35,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh <button class="iconbtn" id="btn-guides" data-tip="Toggle the lower-third guide line on the canvas"> <svg viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.6"/></svg> </button>+ <button class="btn small" id="wiz-classic" data-tip="Flip between the step-by-step wizard and the classic full control panel">Classic</button> <button class="iconbtn" id="btn-rand" data-tip="Randomize package, layout and theme"> <svg viewBox="0 0 24 24"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg> </button>@@ -728,7 +159,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh <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" style="display:none"><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>@@ -871,6 +302,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh </div> <div class="guide"></div>+ <div id="weditring"></div> </div> <div id="contexts">@@ -918,1276 +350,25 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh </div> </div> -<script>-if (new URLSearchParams(location.search).has('render')) document.body.dataset.render = '1';--/* console relay: the AI reads GET /console instead of DevTools */-(function () {- const post = (level, args) => fetch('console', { method:'POST', headers:{'Content-Type':'application/json'},- body: JSON.stringify({ level, text: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch { return String(a); } }).join(' ') }) }).catch(()=>{});- ['log','warn','error'].forEach(k => { const o = console[k].bind(console); console[k] = (...a) => { o(...a); post(k, a); }; });- window.onerror = (m, s, l) => post('error', [m + ' @' + s + ':' + l]);- window.addEventListener('unhandledrejection', e => post('error', ['unhandledrejection: ' + e.reason]));-})();--let pages = [];-let comps = [];-let state = null;--const $ = (id) => document.getElementById(id);-// Every state-changing POST from this page is attributed to the server's-// owning ai-thread (#39): the page IS that instance's surface, and the human-// acting through it acts within that thread's session.-const withThread = (o) => Object.assign({ aiThread: (state && state.aiThread) || 'hero-studio ui' }, o || {});-const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) });-const send = (patch) => post('state', patch).catch(() => {});--function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); }-// the LAUNCH spelling for display + bake: the wiki's public name is hardware-function wireLabel(t) { return canonType(t) === 'component' ? 'hardware' : (t || 'app'); }-function pageByKey(key) { return pages.find(p => p.owner + '/' + p.slug === key) || pages[0]; }-function compById(id) { return comps.find(c => c.id === id) || comps[0]; }-/* the object the overlay, header, and context cards render: page or component */-function displayEntry() {- if (state.mode === 'component' && comps.length) {- const c = compById(state.comp.id);- return { owner: c.owner, slug: c.slug, type: 'component', title: c.name, brief: c.spec,- author: c.author, score: c.score, stars: c.stars, installs30d: null,- tags: [c.mfr.toLowerCase()], updated_at: null, mpn: c.mpn };- }- return pageByKey(state.page);-}--/* long mono strings in the overlay: shrink a step past 40 chars; past 54,- middle-truncate so the owner and the tail stay readable (full string in title=) */-function fitMono(s) {- if (s.length <= 40) return { text: s, small: false };- if (s.length <= 54) return { text: s, small: true };- return { text: s.slice(0, 33) + '...' + s.slice(-18), small: true };-}--function firstSentence(s, max) {- let t = (s || '').split(/(?<=\.)\s/)[0] || s || '';- if (t.length > max) t = t.slice(0, max).replace(/\s+\S*$/, '') + '...';- return t;-}--function accentize(text) {- const words = text.split(' ');- if (words.length < 3) return '<span class="accent">' + text + '</span>';- return '<span class="accent">' + words.slice(0, 2).join(' ') + '</span> ' + words.slice(2).join(' ');-}--function render() {- if (!state || !pages.length) return;- const p = pageByKey(state.page);- const d = displayEntry();- const mode = state.mode || 'app';- const hero = $('hero');- document.body.dataset.mode = mode;- hero.dataset.mode = mode;- hero.dataset.theme = state.theme;- hero.dataset.layout = state.layout;- hero.dataset.clayout = state.comp.layout;- hero.dataset.imgsrc = state.comp.imgSrc;- hero.dataset.mpn = state.comp.mpn;- hero.classList.toggle('guides', !!state.guides);- // imported background art (theme 'image', hero-studio#24 ask 7)- const bg = state.bgImage || {};- hero.style.setProperty('--bgimg', bg.src ? 'url(' + bg.src + ')' : 'none');- hero.classList.toggle('bg-cover', !!bg.cover);- $('bg-cover').checked = !!bg.cover;- // ?? not ||: an angle of 0 is a real choice, not an unset value- hero.style.setProperty('--ga', (state.gradAngle ?? 155) + 'deg');- $('rng-angle').value = state.gradAngle ?? 155;- $('angle-val').textContent = state.gradAngle ?? 155;-- // header subject- $('subj-title').textContent = d.title;- $('subj-meta').textContent = mode === 'component'- ? d.type + ' · score ' + d.score + ' · MPN ' + d.mpn- : d.type + ' · score ' + d.score + ' · ' + d.stars + ' stars · ' + d.installs30d + ' installs/30d';- $('btn-guides').classList.toggle('on', !!state.guides);-- // verification chrome (the pose gate sets state.verify); per-item confirms- const confirm = state.confirm || {};- const flags = state.flags || {};- $('v-title').checked = !!confirm.title;- $('v-sub').checked = !!confirm.subtitle;- $('v-vitrine').checked = !!confirm.vitrine;- $('v-type').checked = !!confirm.type;- // "Needs fixing" verdicts (#14): flags hold Generate and are visible to the- // driving AI via state, so it can adjust and re-present instead of the- // human laundering a problem through an approve-only checklist.- for (const [el, key] of [['v-title','title'],['v-sub','subtitle'],['v-vitrine','vitrine'],['v-type','type']]) {- const label = $(el).parentElement;- let mark = label.querySelector('.flagged');- if (flags[key] && !mark) {- mark = document.createElement('span');- mark.className = 'flagged';- mark.textContent = 'NEEDS FIXING (flagged; the AI sees this in state.flags)';- label.appendChild(mark);- } else if (!flags[key] && mark) mark.remove();- }- const types = (Array.isArray(d.types) && d.types.length ? d.types : [d.type]);- const modeMatchesType = (canonType(d.type) === 'component') === (mode === 'component');- $('v-type-label').textContent = 'Page type matches: page is "' + types.map(wireLabel).join(' + ') + '", studio mode is "' + mode + '"' + (modeMatchesType ? '' : ' (MISMATCH: switch mode at the top of the panel)');- $('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;-- // author-adjustable image-zone type (hero-studio#4); cqw so preview == render- const fs = state.fontSize || {};- 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');- 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);- }-- // the gate visibly CLOSES (hero-studio#5): approval is persistent state- const ap = state.approved;- const approvedHere = !!(ap && ap.page === state.page);- $('approvedbar').style.display = approvedHere ? 'block' : 'none';- if (approvedHere) {- $('ab-body').textContent = ap.file + ', generated ' + new Date(ap.ts).toLocaleString() +- '. The gate is satisfied; the PNG carries the provenance stamp. Apply it with: adom-wiki page hero ' + state.page + ' --image <repo path>';- }- $('verifybar').style.display = state.verify && !approvedHere ? 'block' : 'none';-- // stagebar (#21/#23): stage chrome above the stage, never on it- $('stagebar').classList.toggle('on', !!state.verify || !!state.marqueePreview);- $('sb-png').classList.toggle('on', !state.marqueePreview);- $('sb-mq').classList.toggle('on', !!state.marqueePreview);-- // walkthrough + procedural background + measured layout lint + page fields- showTourStep();- scheduleLint();- renderPageFields();- if (state.theme === 'circuitry') requestAnimationFrame(drawCircuitry);- $('btn-reroll').style.display = state.theme === 'circuitry' ? '' : 'none';-- // marquee overlay preview (ask 5): today's REAL homepage treatment,- // composited over the hero so redundancy and balance are judgeable- hero.classList.toggle('show-mq', !!state.marqueePreview && mode === 'app');- if (state.marqueePreview) {- $('mqo-badges').innerHTML = '<span class="badge badge-' + canonType(d.type) + '">' + wireLabel(d.type) + '</span>' +- (d.score > 0 ? '<span class="ts-slide-score">' + TREND13 + ' ' + d.score + '</span>' : '') +- (d.stars > 0 ? '<span class="ts-slide-stars">' + STAR12 + ' ' + d.stars + '</span>' : '');- // TODAY-REALITY on purpose (Colby): the homepage still overlays its own- // title, so show the doubling instead of hiding it; the tag names the- // planned fix so nobody thinks it is an accident.- $('mqo-title').textContent = d.title;- $('mqo-brief').textContent = firstSentence(d.brief || p.brief || '', 120);- $('mqo-owner').textContent = 'wiki.adom.inc/' + d.owner + '/' + d.slug;- // sample prompt: DYNAMIC by design (#22): metadata the page renders with a- // copy button; if it were pixels nobody could copy it- const sp = (Array.isArray(d.sample_prompts) && d.sample_prompts[0] && d.sample_prompts[0].prompt) || state.samplePrompt || '';- $('mqo-prompt').style.display = sp ? 'flex' : 'none';- $('mqo-prompt-text').textContent = sp ? '"' + sp + '"' : '';- }- $('chk-mqpreview').checked = !!state.marqueePreview;-- // vitrine 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; }- 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 +- ' (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);- // 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.- 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;-- // Adom branding only on adom-owned pages; everyone else: no mark, own palette.- // Every preview wordmark (hero AND the in-context mock cards) follows.- const foreign = d.owner !== 'adom';- document.querySelectorAll('.wordmark').forEach(w => w.classList.toggle('noadom', foreign));- // #37: the standard-wordmark stamp for imports; adom-owned pages only (the- // no-Adom-mark-for-foreign-owners policy applies to the stamp too)- hero.classList.toggle('stamp-logo', !!(state.overlay || {}).logo && !foreign);- $('stamp-logo-row').style.display = (simOverImport && !foreign) ? '' : 'none';- $('chk-stamp-logo').checked = !!(state.overlay || {}).logo;- $('fi-logo').classList.toggle('sim-fenced', simOverImport && !!(state.overlay || {}).logo);- const brand = state.brand || {};- hero.classList.toggle('branded', foreign && !!(brand.accent || brand.bg));- if (foreign && brand.accent) hero.style.setProperty('--brand-accent', brand.accent);- else hero.style.removeProperty('--brand-accent');- if (foreign && brand.bg) hero.style.setProperty('--brand-bg', brand.bg);- else hero.style.removeProperty('--brand-bg');- const secBrand = document.getElementById('sec-brand');- if (secBrand) {- secBrand.style.display = (mode === 'app' && foreign) ? '' : 'none';- if (brand.accent) $('brand-accent').value = brand.accent;- if (brand.bg) $('brand-bg').value = brand.bg;- }-- // component image zone- if (mode === 'component' && comps.length) {- const c = compById(state.comp.id);- $('c-family').textContent = c.family;- $('c-mpn-chip').textContent = c.mpn;- $('c-name').textContent = c.name;- $('c-mpn-under').textContent = c.mpn;- $('c-spec').textContent = c.spec;- }-- // image zone- 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 = 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';- $('el-desc').textContent = firstSentence(p.brief.slice(80), 110);- $('win-label').textContent = p.title + ' · Adom webview';-- // lower band: gone for app pages (the page shows the image bare); kept in- // component mode for the MPN-in-overlay placement option- hero.querySelector('.lower').style.display = mode === 'app' ? 'none' : '';- const showTitleInOverlay = mode === 'component' || state.titleMode !== 'image-only';- // Pill GROUP (#19): a page carrying several types (app + service) renders- // them as one clustered badge box; the linter treats the cluster as one box.- const ptr = $('pill-tr');- ptr.style.display = state.overlay.pill ? '' : 'none';- ptr.innerHTML = types.map(t => '<span class="pill ' + canonType(t) + '">' + wireLabel(t).toUpperCase() + '</span>').join('');- $('l-trend').style.display = state.overlay.scores ? '' : 'none';- $('l-trend').querySelector('b').textContent = d.score.toLocaleString('en-US');- $('l-stars').style.display = state.overlay.scores ? '' : 'none';- $('l-stars').querySelector('b').textContent = d.stars;- $('l-installs').style.display = (state.overlay.scores && d.installs30d != null) ? '' : 'none';- $('l-installs').querySelector('b').textContent = d.installs30d;- const url = 'https://wiki.adom.inc/' + d.owner + '/' + d.slug;- $('l-title').style.display = showTitleInOverlay ? 'block' : 'none';- $('l-title').textContent = d.title;- $('l-title').href = url;- $('l-desc').textContent = firstSentence(d.brief || p.brief || '', 140);- const subtitleInImage = mode === 'app' && state.show.subtitle;- $('l-desc').style.display = (!subtitleInImage && (d.brief || p.brief)) ? 'block' : 'none';- $('l-author').style.display = state.overlay.author ? '' : 'none';- $('l-author').innerHTML = 'by <a href="' + url + '" target="_blank">' + d.author + '</a>';- const mpnFit = fitMono(d.mpn || '');- $('l-mpn').textContent = mpnFit.text;- $('l-mpn').title = d.mpn || '';- $('l-mpn').classList.toggle('fit', mpnFit.small);- const ref = 'wiki.adom.inc/' + d.owner + '/' + d.slug;- const refFit = fitMono(ref);- $('l-slug').style.display = state.overlay.slug ? '' : 'none';- $('l-slug').textContent = refFit.text;- $('l-slug').title = ref;- $('l-slug').classList.toggle('fit', refFit.small);- $('l-slug').href = url;-- // controls reflect state- document.querySelectorAll('#grp-mode .opt').forEach(b => b.classList.toggle('on', b.dataset.v === mode));- if (comps.length) $('sel-comp').value = compById(state.comp.id).id;- document.querySelectorAll('#grp-imgsrc .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.imgSrc));- document.querySelectorAll('#grp-clayout .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.layout));- document.querySelectorAll('#grp-mpn .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.mpn));- $('sel-page').value = state.page;- document.querySelectorAll('#grp-layout .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.layout));- document.querySelectorAll('#grp-theme .swatch').forEach(b => b.classList.toggle('on', b.dataset.v === state.theme));- document.querySelectorAll('#grp-titlemode .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.titleMode));- $('chk-logo').checked = state.show.logo;- $('chk-title').checked = state.show.title;- $('chk-subtitle').checked = state.show.subtitle;- $('chk-description').checked = state.show.description;- $('chk-pill').checked = state.overlay.pill;- $('chk-scores').checked = state.overlay.scores;- $('chk-author').checked = state.overlay.author;- $('chk-slug').checked = state.overlay.slug;-- syncPreviews();-}--/* in-context previews: markup mirrors git-wiki lib/templates.js (popularMarquee + categoryListPage) */-const STAR12 = '<svg width="12" height="12" viewBox="0 0 24 24" fill="#d29922" stroke="#d29922" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';-const TREND13 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>';-const IMGPH = '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#484f58" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';--function relTime(iso) {- if (!iso) return '';- const s = (Date.now() - new Date(iso).getTime()) / 1000;- if (s < 3600) return Math.max(1, Math.floor(s / 60)) + 'm ago';- if (s < 86400) return Math.floor(s / 3600) + 'h ago';- return Math.floor(s / 86400) + 'd ago';-}--function marqueeTile(p, isLive) {- const heroPart = isLive ? '<div class="clone-slot"></div>'- : '<div class="ts-hero-fallback"><span>' + p.title + '</span></div>';- // DECIDED (Colby, 2026-07-26, adom/wiki#80): hero-bearing tiles get badges- // and owner only; the baked title is the single title. Heroless fallback- // tiles keep the text overlay, which is their only title.- const textPart = isLive- ? ''- : '<h3 class="ts-slide-title">' + p.title + '</h3>' +- (p.brief ? '<p class="ts-slide-brief">' + p.brief + '</p>' : '');- return '<a class="ts-slide mq-slide">' + heroPart +- '<div class="ts-slide-shade"></div><div class="ts-slide-info">' +- '<div class="ts-slide-badges"><span class="badge badge-' + p.type + '">' + p.type + '</span>' +- (p.score > 0 ? '<span class="ts-slide-score">' + TREND13 + ' ' + p.score + '</span>' : '') +- (p.stars > 0 ? '<span class="ts-slide-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') +- '</div>' + textPart +- '<span class="ts-slide-owner">' + p.owner + '/' + p.slug + '</span></div></a>';-}--function indexCard(p, isLive) {- const hero = isLive ? '<div class="clone-slot"></div>'- : '<div class="page-card-hero-placeholder">' + IMGPH + '</div>';- return '<a class="page-card">' +- '<div class="page-card-hero">' + hero + '</div>' +- '<div class="page-card-body">' +- '<div class="page-card-top"><span class="badge badge-' + p.type + '">' + p.type + '</span></div>' +- '<h3 class="page-card-title">' + p.title + '</h3>' +- (p.brief ? '<p class="page-card-brief">' + p.brief + '</p>' : '') +- '<div class="page-card-meta"><span>' + p.owner + '/' + p.slug + '</span>' +- (p.updated_at ? '<span class="dot-sep">·</span><span>' + relTime(p.updated_at) + '</span>' : '') +- (p.stars > 0 ? '<span class="dot-sep">·</span><span class="page-card-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') +- '</div>' +- (p.tags && p.tags.length ? '<div class="page-card-tags">' + p.tags.map(t => '<span class="chip-sm">' + t + '</span>').join('') + '</div>' : '') +- '<div class="page-card-install"><code>adom-wiki pkg install ' + p.owner + '/' + p.slug + '</code></div>' +- '</div></a>';-}--function cloneHero(imageOnly) {- const c = $('hero').cloneNode(true);- c.removeAttribute('id');- c.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));- c.classList.add('preview');- c.classList.remove('guides');- if (imageOnly) { const l = c.querySelector('.lower'); if (l) l.remove(); }- return c;-}--function syncPreviews() {- const p = displayEntry();- const nb = pages.filter(x => x.owner + '/' + x.slug !== p.owner + '/' + p.slug).slice(0, 3);- // marquee: live tile among real neighbors, content doubled for the -50% loop- const seq = marqueeTile(p, true) + nb.map(x => marqueeTile(x)).join('');- $('mq-track').innerHTML = seq + seq;- // index grid: live card first, then real neighbor cards (no-hero placeholder icon)- $('wikigrid').innerHTML = indexCard(p, true) + nb.map(x => indexCard(x)).join('');- // wiki surfaces get the IMAGE PART only (the wiki overlays its own text);- // the desk bar gets the full v2 hero with its baked-in text overlay- document.querySelectorAll('#contexts .clone-slot').forEach(s => s.appendChild(cloneHero(true)));- document.querySelectorAll('#contexts .heroslot').forEach(s => { s.innerHTML = ''; s.appendChild(cloneHero(false)); });-}--let seenToastSeq = 0;-function handleToast(t) {- if (t && t.seq > seenToastSeq) { seenToastSeq = t.seq; toast(t.text, t.kind); }-}--function toast(text, kind) {- const el = document.createElement('div');- el.className = 'toast ' + (kind || 'info');- el.textContent = text;- $('toasts').appendChild(el);- setTimeout(() => el.remove(), 4000);-}--/* wire controls: server owns the truth, SSE echoes back */-document.querySelectorAll('#grp-mode .opt').forEach(b => b.addEventListener('click', () => send({ mode: b.dataset.v })));-$('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 => {- 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.- post('refresh-page?page=' + encodeURIComponent(v))- .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 })));-$('chk-logo').addEventListener('change', e => send({ show: { logo: e.target.checked } }));-$('chk-title').addEventListener('change', e => send({ show: { title: e.target.checked } }));-$('chk-subtitle').addEventListener('change', e => send({ show: { subtitle: e.target.checked } }));-$('chk-description').addEventListener('change', e => send({ show: { description: e.target.checked } }));-$('chk-pill').addEventListener('change', e => send({ overlay: { pill: e.target.checked } }));-$('chk-scores').addEventListener('change', e => send({ overlay: { scores: e.target.checked } }));-$('chk-author').addEventListener('change', e => send({ overlay: { author: e.target.checked } }));-$('chk-slug').addEventListener('change', e => send({ overlay: { slug: e.target.checked } }));-$('btn-guides').addEventListener('click', () => send({ guides: !state.guides }));-let angleTimer = null;-$('rng-angle').addEventListener('input', e => {- $('angle-val').textContent = e.target.value;- $('hero').style.setProperty('--ga', e.target.value + 'deg'); // instant local feedback- clearTimeout(angleTimer);- angleTimer = setTimeout(() => send({ gradAngle: +e.target.value }), 120);-});-$('btn-rand').addEventListener('click', () => {- const themes = ['midnight','pcb','onion','pill','grid','gradient'];- const angles = [65, 100, 155, 210, 245, 290, 335];- const theme = themes[Math.floor(Math.random()*themes.length)];- const gradAngle = angles[Math.floor(Math.random()*angles.length)];- if (state.mode === 'component') {- const clayouts = ['photo-right','photo-left','photo-full'];- const srcs = ['photo','raytrace','phong'];- const mpns = ['eyebrow','under-title','overlay'];- const c = comps[Math.floor(Math.random() * comps.length)];- send({ theme, gradAngle, comp: { id: c.id, layout: clayouts[Math.floor(Math.random()*clayouts.length)],- imgSrc: srcs[Math.floor(Math.random()*srcs.length)], mpn: mpns[Math.floor(Math.random()*mpns.length)] } });- toast('Randomized: ' + c.name, 'info');- } else {- const layouts = ['bleed-bottom','bleed-right','bleed-top','bleed-corner-br','bleed-corner-tr','contained','mirror'];- const p = pages[Math.floor(Math.random() * pages.length)];- send({ page: p.owner + '/' + p.slug, layout: layouts[Math.floor(Math.random()*layouts.length)], theme, gradAngle });- toast('Randomized: ' + p.title, 'info');- }-});-let seenGenSeq = 0;-function showGenerated(g) {- $('gen-img').src = g.url + '?t=' + g.ts;- $('gen-path').textContent = g.file;- $('gen-open').href = g.url;- $('genmodal').classList.add('show');-}-/* fires for local clicks AND for API-triggered generates (SSE or poll), deduped by seq */-function handleGen(g) {- if (g && g.ok && g.seq > seenGenSeq) { seenGenSeq = g.seq; showGenerated(g); }-}-$('gen-close').addEventListener('click', e => { e.preventDefault(); $('genmodal').classList.remove('show'); });-$('genmodal').addEventListener('click', e => { if (e.target === $('genmodal')) $('genmodal').classList.remove('show'); });-function runGenerate() {- const b = $('btn-generate');- b.disabled = true; b.textContent = 'Generating...';- toast('Rendering the hero on the server', 'info');- post('generate').then(r => r.json()).then(g => {- b.disabled = false; b.textContent = 'Generate hero PNG';- if (g.ok) handleGen(g); // success toast arrives via the live channel- else toast('Generate failed: ' + (g.error || 'unknown') + (g.hint ? '. ' + g.hint : ''), 'error');- }).catch(() => { b.disabled = false; b.textContent = 'Generate hero PNG'; toast('Generate failed: network error', 'error'); });-}-$('btn-generate').addEventListener('click', runGenerate);-$('v-generate').addEventListener('click', runGenerate);--$('btn-reset').addEventListener('click', () => {- send({ mode:'app', layout:'bleed-bottom', theme:'midnight', titleMode:'image-only', gradAngle:155,- comp:{ id:'sm02b-srss-tb', imgSrc:'photo', layout:'photo-right', mpn:'under-title' },- show:{ logo:true, title:true, subtitle:true, description:false },- fontSize:{ title:8, sub:2.5, desc:1.35 },- overlay:{ pill:true, scores:true, author:true, slug:true }, guides:true });- toast('Reset to the proposed default: overlay title, image keeps value-prop', 'success');-});--/* tooltips: fixed div, 600ms delay, clamped to viewport on both axes */-(function () {- const tip = $('tip');- let timer = null;- document.addEventListener('mouseover', e => {- const t = e.target.closest('[data-tip]');- if (!t) return;- clearTimeout(timer);- timer = setTimeout(() => {- tip.textContent = t.getAttribute('data-tip');- tip.classList.add('show');- const r = t.getBoundingClientRect();- let x = r.left + 14, y = r.bottom + 10;- const w = tip.offsetWidth, h = tip.offsetHeight, M = 8;- if (y + h > innerHeight - M) y = r.top - h - 10;- x = Math.max(M, Math.min(x, innerWidth - w - M));- y = Math.max(M, Math.min(y, innerHeight - h - M));- tip.style.left = x + 'px'; tip.style.top = y + 'px';- }, 600);- });- document.addEventListener('mouseout', e => {- if (e.target.closest && e.target.closest('[data-tip]')) { clearTimeout(timer); tip.classList.remove('show'); }- });-})();--/* verification checklist + vitrine controls */-[['v-title','title'],['v-sub','subtitle'],['v-vitrine','vitrine'],['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 } }));-});-$('verifybar').addEventListener('click', e => {- if (e.target.id !== 'v-generate' && !$('v-generate').disabled) return;- if ($('v-generate').disabled && (e.target.id === 'v-generate' || e.target.closest('#v-generate'))) {- const flags = Object.entries(state.flags || {}).filter(([, v]) => v).map(([k]) => k);- const fails = (state.lint || []).filter(l => l.level === 'fail');- let why = 'Confirm all four checks first; Generate unlocks when every item is ratified';- if (flags.length) why = 'Generate is locked: ' + flags.join(', ') + ' flagged as needs-fixing. Fix and re-tick, or the AI adjusts and re-presents';- else if (fails.length) why = 'Generate is locked by the layout linter: ' + fails.map(f => f.msg).join(' | ');- handleToast({ text: why, kind: 'warn', seq: Date.now() });- }-});-$('bg-file').addEventListener('change', e => {- const f = e.target.files && e.target.files[0];- if (!f) return;- const r = new FileReader();- r.onload = () => send({ bgImage: { src: r.result }, theme: 'image' });- r.readAsDataURL(f);-});-$('bg-cover').addEventListener('change', e => send({ bgImage: { cover: e.target.checked } }));-$('vitrine-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.readAsDataURL(f);-});-$('vitrine-full').addEventListener('change', e => send({ vitrine: { 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' } }));-$('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))- .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() }))- .catch(() => {});-});--/* verification walkthrough: spotlight each checklist item in place */-const TOUR_STEPS = [- { key: 'title', el: 'el-title', title: 'This is your title',- 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',- 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.' },-];-let tourIdx = -1;-function tourTarget(step) {- if (step.winSel) { const w = hero.querySelector(step.winSel); if (w && w.offsetParent) return w; }- const el = $(step.el);- if (el && el.offsetParent) return el;- return step.fallbackEl ? $(step.fallbackEl) : null;-}-function clearTourGlow() {- document.querySelectorAll('.tour-glow').forEach(n => n.classList.remove('tour-glow'));-}-function showTourStep() {- clearTourGlow();- const confirm = state.confirm || {};- const flags = state.flags || {};- // a flagged step is reviewed (verdict: needs fixing), so the tour moves on;- // the flag itself keeps Generate locked until it is cleared- const remaining = TOUR_STEPS.filter(st => !confirm[st.key] && !flags[st.key]);- if (!state.tour || !state.verify || remaining.length === 0) {- $('tourpop').style.display = 'none';- if (state.tour && remaining.length === 0) send({ tour: false });- return;- }- const step = remaining[0];- tourIdx = TOUR_STEPS.indexOf(step);- const target = tourTarget(step);- if (!target) { $('tourpop').style.display = 'none'; return; }- target.classList.add('tour-glow');- const r = target.getBoundingClientRect();- const pop = $('tourpop');- $('tp-title').textContent = step.title;- $('tp-body').textContent = step.body;- pop.style.display = 'block';- placeTourPop(pop, target, r);-}-/* The card must NEVER cover the element it asks the human to verify: a- coach-mark over its own subject collects blind confirmations (#10).- Try below / above / right / left; take the first spot that fits the- viewport and does not intersect the target. If no spot exists (target- nearly fills the window), lift the target above the card instead. */-function placeTourPop(pop, target, r) {- const W = window.innerWidth, H = window.innerHeight, M = 12, GAP = 14;- const pw = pop.offsetWidth, ph = pop.offsetHeight;- const candidates = [- { top: r.bottom + GAP, left: r.left },- { top: r.top - ph - GAP, left: r.left },- { top: r.top, left: r.right + GAP },- { top: r.top, left: r.left - pw - GAP },- ];- const fits = c => c.top >= M && c.left >= M && c.top + ph <= H - M && c.left + pw <= W - M;- const hides = c => !(c.left + pw <= r.left || c.left >= r.right || c.top + ph <= r.top || c.top >= r.bottom);- let spot = candidates.find(c => fits(c) && !hides(c));- if (!spot) {- // clamp into the viewport on the roomier vertical side of the target- const left = Math.max(M, Math.min(W - pw - M, r.left));- spot = (H - r.bottom >= r.top)- ? { top: Math.max(M, Math.min(H - ph - M, r.bottom + GAP)), left }- : { top: Math.max(M, r.top - ph - GAP), left };- }- document.querySelectorAll('.tour-above').forEach(n => n.classList.remove('tour-above'));- if (hides(spot)) target.classList.add('tour-above');- // apply the human's drag displacement (#13): anchored spawn gives context,- // the remembered offset keeps later steps out of the way they chose- pop._anchor = { top: spot.top, left: spot.left };- const top = Math.max(4, Math.min(window.innerHeight - 60, spot.top + tpDrag.dy));- const left = Math.max(4, Math.min(window.innerWidth - 80, spot.left + tpDrag.dx));- pop.style.top = top + 'px';- pop.style.left = left + 'px';-}-window.addEventListener('resize', () => { if (state.tour && state.verify) showTourStep(); });--/* ---- layout linter (#18): the studio notices, the human decides ----- Most engineers are not graphic designers; the studio is the only party that- reliably knows the geometry, so it encodes the judgment. Runs after every- render (debounced), posts results to state.lint (visible to the driving AI),- and hard failures (clipped text) lock Generate. Warnings advise. */-let lintTimer = null;-let lastLintKey = '';-function scheduleLint() { clearTimeout(lintTimer); lintTimer = setTimeout(runLint, 450); }-function runLint() {- if (!state) return;- const hero = $('hero');- const H = hero.getBoundingClientRect();- if (H.width < 60) return;- const items = [];- const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height });- const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2;- const boxOf = (el) => rel(el.getBoundingClientRect());- const overlapPx = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));-- const ptrEl = $('pill-tr');- const pill = vis(ptrEl) ? boxOf(ptrEl) : null;- const winEl = hero.querySelector('.window');- const vitrine = (winEl && winEl.offsetParent && state.mode !== 'component') ? boxOf(winEl) : null;- const texts = [['title', 'el-title'], ['subtitle', 'el-sub'], ['detail line', 'el-desc']]- .filter(([, id]) => vis($(id)))- .map(([name, id]) => ({ name, id, box: boxOf($(id)) }));-- // 1. pill vs vitrine / pill vs baked text (#17). Pill placement is- // contract-fixed top right, so the in-contract fix is a backdrop scrim- // (reads as a deliberate badge) plus this warning.- ptrEl.classList.toggle('scrim', !!(pill && vitrine && overlapPx(pill, vitrine) > 9));- if (pill && vitrine && overlapPx(pill, vitrine) > 9) {- items.push({ id: 'pill-vitrine', level: 'warn', el: 'pill-tr', msg: 'type pill sits on the vitrine (scrim applied so it reads as a badge). If the app has its own corner UI there, pick a layout that leaves the top-right clear' });- }- for (const t of texts) {- if (pill && overlapPx(pill, t.box) > 9) items.push({ id: 'pill-' + t.id, level: 'warn', el: 'pill-tr', msg: 'type pill overlaps the ' + t.name });- }-- // 2. image-zone text intruding into the dynamic marquee band (#14): the- // homepage overlays its own info over the lower band; keep it calm.- const bandTop = H.height * 0.76;- for (const t of texts) {- if (t.box.bottom > bandTop + 2) {- items.push({ id: 'band-' + t.id, level: 'warn', el: t.id, msg: t.name + ' extends ' + Math.round(t.box.bottom - bandTop) + 'px into the dynamic marquee band; shrink the title font or shorten the copy so the lower band stays calm' });- }- }-- // 3. clipped text = hard fail (the registry would ship a broken hero);- // near-edge text = warning. The vitrine is exempt: bleeding off the edge is- // a deliberate layout feature.- const inset = H.width * 0.015;- for (const t of texts) {- if (t.box.left < -1 || t.box.top < -1 || t.box.right > H.width + 1 || t.box.bottom > H.height + 1) {- items.push({ id: 'clip-' + t.id, level: 'fail', el: t.id, msg: t.name + ' is CLIPPED by the hero edge; Generate stays locked until it fits' });- } else if (t.box.left < inset || t.box.top < inset || H.width - t.box.right < inset) {- items.push({ id: 'inset-' + t.id, level: 'warn', el: t.id, msg: t.name + ' is closer than 1.5% to the hero edge' });- }- }-- // 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 || {};- 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' });- if (bandScan.status === 'done' && bandScan.texty) {- items.push({ id: 'import-band-text', level: 'warn', el: 'fullimport-img', msg: 'the imported image appears to carry text or graphics in the lower dynamic band (heuristic edge scan): the homepage overlays its own info there, keep the band calm' });- }- }-- // 4c. prompt-looking text baked into pixels (#22): sample prompts belong in- // page metadata (sample_prompts), rendered dynamically with a copy button.- for (const t of texts) {- const txt = ($(t.id).textContent || '');- if (/["\u201c\u201d'][^"\u201c\u201d']{12,}["\u201c\u201d']/.test(txt)) {- items.push({ id: 'prompt-' + t.id, level: 'warn', el: t.id, msg: t.name + ' contains quoted prompt-looking text; a baked prompt cannot be copied. Put it in sample_prompts (page metadata) and the page renders it dynamically with a copy button' });- }- }-- // 4. title wrap count: a 4-line title is almost never intended.- const tEl = $('el-title');- if (vis(tEl)) {- const lh = parseFloat(getComputedStyle(tEl).lineHeight);- const lines = lh ? Math.round(tEl.getBoundingClientRect().height / lh) : 1;- if (lines >= 4) items.push({ id: 'title-wrap', level: 'warn', el: 'el-title', msg: 'title wraps to ' + lines + ' lines; shorten it or lower the title size (left panel)' });- }-- // 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 {}- }-- // 6b. vitrine coverage (#43): the title is the message, the vitrine is- // supporting evidence. When the vitrine 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));- 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' });- }- }-- // 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');- if (vit2.src && !vit2.full && vit2.cover && vimg && vimg.naturalWidth > 0) {- const win2 = hero.querySelector('.appbody');- if (win2 && win2.offsetParent) {- const wb = win2.getBoundingClientRect();- const arImg = vimg.naturalWidth / vimg.naturalHeight;- const arSlot = wb.width / wb.height;- 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' });- }- 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' });- }- }- }-- // 6d. multi-message import (#43): one hero, one message.- if (vit2.full && vit2.src && bandScan.status === 'done' && bandScan.busyCells >= 6) {- items.push({ id: 'import-multi-message', level: 'warn', el: 'fullimport-img', msg: 'the imported image reads as ' + bandScan.busyCells + ' of 12 zones text/graphic-dense: that is several competing messages, and a hero sends ONE. The title should be the message; white space is what makes it land' });- }-- // 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));- if (key !== lastLintKey) {- lastLintKey = key;- send({ lint: items.map(({ id, level, msg }) => ({ id, level, msg })) });- }-}-function renderLintBox(items) {- const box = $('lintbox');- if (!state.verify) { box.innerHTML = ''; return; }- if (!items.length) { box.innerHTML = '<div class="lint-ok">layout linter: no findings (pill, margins, band, clipping, wrap all clear)</div>'; return; }- box.innerHTML = items.map((i, n) =>- '<div class="lint-item lint-' + i.level + '" data-el="' + (i.el || '') + '" data-n="' + n + '">' +- (i.level === 'fail' ? 'BLOCKS GENERATE: ' : '') + i.msg + '</div>').join('');-}-$('lintbox').addEventListener('click', (e) => {- const item = e.target.closest('.lint-item');- if (!item || !item.dataset.el) return;- const el = $(item.dataset.el);- if (!el) return;- clearTourGlow();- el.classList.add('tour-glow');- setTimeout(() => el.classList.remove('tour-glow'), 2200);-});--/* draggable tour dialogs (#13): pointer-capture drag on the header, clamped;- the offset persists across steps for this tour session */-let tpDrag = { dx: 0, dy: 0 };-(() => {- const pop = $('tourpop');- const head = pop.querySelector('.tp-head');- let grab = null;- head.addEventListener('pointerdown', (e) => {- grab = { x: e.clientX - pop.offsetLeft, y: e.clientY - pop.offsetTop };- pop.classList.add('dragging');- head.setPointerCapture(e.pointerId);- });- head.addEventListener('pointermove', (e) => {- if (!grab) return;- const left = Math.max(4, Math.min(window.innerWidth - 80, e.clientX - grab.x));- const top = Math.max(4, Math.min(window.innerHeight - 60, e.clientY - grab.y));- pop.style.left = left + 'px';- pop.style.top = top + 'px';- if (pop._anchor) tpDrag = { dx: left - pop._anchor.left, dy: top - pop._anchor.top };- });- const drop = () => { grab = null; pop.classList.remove('dragging'); };- head.addEventListener('pointerup', drop);- head.addEventListener('pointercancel', drop);-})();-$('tp-ok').addEventListener('click', () => {- const step = TOUR_STEPS[tourIdx];- if (step) send({ confirm: { [step.key]: true }, flags: { [step.key]: false } });-});-$('tp-flag').addEventListener('click', () => {- const step = TOUR_STEPS[tourIdx];- if (!step) return;- send({ flags: { [step.key]: true }, confirm: { [step.key]: false } });- handleToast({ text: step.key + ' flagged as needs-fixing: Generate stays locked and the driving AI can read the flag from state and adjust', kind: 'warn', seq: Date.now() });-});-$('tp-skip').addEventListener('click', () => { clearTourGlow(); $('tourpop').style.display = 'none'; send({ tour: false }); });--$('mqo-prompt-copy').addEventListener('click', async (e) => {- e.stopPropagation();- const txt = $('mqo-prompt-text').textContent.replace(/^"|"$/g, '');- try { await navigator.clipboard.writeText(txt); } catch {}- handleToast({ text: 'sample prompt copied', kind: 'success', seq: Date.now() });-});--/* full-hero import band scan (#22.1): composed-layout lint cannot see imported- pixels, so the class of hero most likely to violate the band skipped every- check. A cheap edge-density scan of the bottom 24% flags text-like structure- in the band; it is a heuristic and says so. */-let bandScan = { key: '', status: 'idle', texty: false, busyCells: 0, cells: 12 };-function edgeDensity(g, sx, sy, sw, sh, w, h) {- // draw a region to a small canvas and return horizontal edge density- g.canvas.width = w; g.canvas.height = h;- g.drawImage(g._img, sx, sy, sw, sh, 0, 0, w, h);- const d = g.getImageData(0, 0, w, h).data;- let edges = 0, n = 0;- for (let y = 0; y < h; y++) {- for (let x = 1; x < w; x++) {- const i = (y * w + x) * 4, j = i - 4;- const a = d[i] * .3 + d[i + 1] * .59 + d[i + 2] * .11;- const b = d[j] * .3 + d[j + 1] * .59 + d[j + 2] * .11;- if (Math.abs(a - b) > 60) edges++;- n++;- }- }- return n ? edges / n : 0;-}-function scanImportBand(src) {- const key = src.length + ':' + src.slice(40, 72);- if (bandScan.key === key) return;- bandScan = { key, status: 'pending', texty: false, busyCells: 0, cells: 12 };- const img = new Image();- img.onload = () => {- try {- const g = document.createElement('canvas').getContext('2d');- g._img = img;- // 1. the calm-band check (bottom 24%), unchanged- const w = Math.min(800, img.width);- const bandH = Math.max(10, Math.round(img.height * 0.24 * (w / img.width)));- bandScan.texty = edgeDensity(g, 0, img.height * 0.76, img.width, img.height * 0.24, w, bandH) > 0.0015;- // 2. message-block grid (#43): a hero sends ONE message; count how many- // zones of the upper 76% read as text/graphic-dense. 4x3 grid, cheap.- let busy = 0;- for (let gy = 0; gy < 3; gy++) {- for (let gx = 0; gx < 4; gx++) {- const dens = edgeDensity(g,- img.width * gx / 4, img.height * 0.76 * gy / 3,- img.width / 4, img.height * 0.76 / 3, 200, 100);- if (dens > 0.003) busy++;- }- }- bandScan.busyCells = busy;- bandScan.status = 'done';- } catch { bandScan.status = 'failed'; }- scheduleLint();- };- img.onerror = () => { bandScan.status = 'failed'; scheduleLint(); };- img.src = 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 */-let cropScan = { key: '', status: 'idle', cut: false, where: '' };-function scanCropEdges(vimg, arSlot) {- const key = vimg.src.length + ':' + arSlot.toFixed(3);- if (cropScan.key === key) return;- cropScan = { key, status: 'pending', cut: false, where: '' };- try {- const g = document.createElement('canvas').getContext('2d');- g._img = vimg;- const iw = vimg.naturalWidth, ih = vimg.naturalHeight;- const arImg = iw / ih;- let cut = false, where = '';- if (arImg > arSlot) {- const visW = ih * arSlot;- const cutL = (iw - visW) / 2;- const strip = Math.max(8, iw * 0.02);- if (edgeDensity(g, cutL, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = 'left'; }- if (edgeDensity(g, cutL + visW - strip, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = where ? 'left and right' : 'right'; }- } else if (arImg < arSlot) {- const visH = iw / arSlot;- const cutT = (ih - visH) / 2;- const strip = Math.max(8, ih * 0.02);- if (edgeDensity(g, 0, cutT, iw, strip, 300, 24) > 0.004) { cut = true; where = 'top'; }- if (edgeDensity(g, 0, cutT + visH - strip, iw, strip, 300, 24) > 0.004) { cut = true; where = where ? 'top and bottom' : 'bottom'; }- }- cropScan.cut = cut; cropScan.where = where; cropScan.status = 'done';- } catch { cropScan.status = 'failed'; }-}--/* auto-fit (#40): the largest title size that keeps <=3 lines, positive- headroom, and no clipping, measured with the REAL layout: sweep the CSS- var coarse-to-fine, read the boxes, restore. Runs on the fit SSE event- (drive verb + preset re-fit) and on headline edits; never in render mode. */-function solveTitleFit(maxCqw) {- const tEl = $('el-title');- if (!tEl || !tEl.offsetParent || document.body.dataset.render !== undefined) return null;- const H = hero.getBoundingClientRect();- if (H.width < 60) return null;- const bandTop = H.top + H.height * 0.76;- const measure = (v) => {- hero.style.setProperty('--fs-title', v + 'cqw');- void tEl.offsetHeight;- const b = tEl.getBoundingClientRect();- const lh = parseFloat(getComputedStyle(tEl).lineHeight) || 1;- return { b, lines: Math.max(1, Math.round(b.height / lh)) };- };- const inHero = (b) => b.bottom < bandTop && b.right <= H.right + 1 && b.left >= H.left - 1 && b.top >= H.top - 1;- const fits = (v) => { const m = measure(v); return m.lines <= 3 && inHero(m.b); };- const fitsLoose = (v) => inHero(measure(v).b);- const hi = Math.min(maxCqw || 13, 13);- const sweep = (pred) => {- let best = null;- for (let v = hi; v >= 5 && best == null; v = Math.round((v - 0.5) * 10) / 10) {- if (pred(v)) best = v;- }- if (best != null && best < hi) {- for (let v = Math.min(hi, best + 0.4); v > best; v = Math.round((v - 0.1) * 10) / 10) {- if (pred(v)) { best = v; break; }- }- }- return best;- };- let best = sweep(fits);- let loose = false;- if (best == null) {- // Nothing satisfies <=3 lines at any legible size: the headline itself is- // the problem. Relax to the HARD constraints only (inside the hero, above- // the band) so the pose is at least never broken, and say so.- loose = true;- best = sweep(fitsLoose);- }- const cur = (state.fontSize || {}).title || 8;- hero.style.setProperty('--fs-title', cur + 'cqw');- if (best == null) return null;- hero.style.setProperty('--fs-title', best + 'cqw');- void tEl.offsetHeight;- const lines = Math.max(1, Math.round(tEl.getBoundingClientRect().height / (parseFloat(getComputedStyle(tEl).lineHeight) || 1)));- hero.style.setProperty('--fs-title', cur + 'cqw');- return { size: best, loose, lines };-}-function runFit(maxCqw) {- const r = solveTitleFit(maxCqw);- if (r == null) {- handleToast({ text: 'fit: no title size in 5-13cqw keeps this headline inside the hero for this layout; shorten the headline or change layout', kind: 'warn', seq: Date.now() });- return;- }- const cur = (state.fontSize || {}).title || 8;- if (Math.abs(r.size - cur) >= 0.05) send({ fontSize: { title: r.size } });- if (r.loose) {- handleToast({ text: 'fit: this headline cannot make 3 lines at any legible size; set ' + r.size + 'cqw (' + r.lines + ' lines, still clear of the band). SHORTER COPY READS BIGGER: the title is the one message, aim for a few words', kind: 'warn', seq: Date.now() });- } else if (Math.abs(r.size - cur) >= 0.05) {- handleToast({ text: 'fit: title ' + r.size + 'cqw (largest size with <=3 lines and positive headroom for this headline + layout)', kind: 'info', seq: Date.now() });- }-}--/* the stagebar (#21/#23): explicit dynamic on/off, keyboard D, legend off-stage */-$('sb-png').addEventListener('click', () => send({ marqueePreview: false }));-$('sb-mq').addEventListener('click', () => send({ marqueePreview: true }));-document.addEventListener('keydown', (e) => {- if (e.key.toLowerCase() === 'd' && !e.target.closest('input,textarea')) {- send({ marqueePreview: !state.marqueePreview });- }-});--/* hold-to-hide the dynamic overlay (#16): press the band to see the pure- export, release to bring the overlay back */-(() => {- const mq = $('mqoverlay');- const hero = $('hero');- mq.addEventListener('pointerdown', (e) => { e.preventDefault(); hero.classList.add('mq-hold'); });- document.addEventListener('pointerup', () => hero.classList.remove('mq-hold'));- document.addEventListener('pointercancel', () => hero.classList.remove('mq-hold'));-})();--/* ---- page fields + Apply + Copy Changelog Prompt (#20) ----- The human edits page-level fields at the gate; the change ledger on the- server records everything (page fields AND pose deltas), Apply writes to- the wiki or stashes for the first publish, and the prompt hands the whole- session back to the AI that owns the package working copy. */-let pfEditing = false;-function renderPageFields() {- if (pfEditing || !state) return;- const d = pageByKey(state.page);- if (!d) return;- $('pf-title').value = d.title || '';- $('pf-brief').value = d.brief || '';- $('pf-tags').value = (d.tags || []).join(', ');-}-async function refreshChangesBadge() {- try {- const c = await (await fetch('changes')).json();- const n = Object.keys(c.page || {}).length;- $('pf-apply').textContent = 'Apply changes to wiki' + (n && !c.applied ? ' (' + n + ' unapplied)' : '');- $('pf-apply').disabled = !n || !!c.applied;- if (c.applied) $('pf-status').textContent = c.applied === 'wiki'- ? 'Applied to the wiki. Still run Copy Changelog Prompt: the local package checkout needs syncing too.'- : 'Stashed for the next publish (the page or edit route is not on the wiki yet); the changelog prompt carries the edits.';- } catch {}-}-function pushPageField() {- const [owner, slug] = (state.page || '/').split('/');- const body = { owner, slug, title: $('pf-title').value.trim(), brief: $('pf-brief').value.trim(),- tags: $('pf-tags').value.split(',').map(t => t.trim()).filter(Boolean) };- post('api/pages', body)- .then(() => fetch('api/pages')).then(r => r.json()).then(rows => { pages = rows; render(); refreshChangesBadge(); })- .catch(() => {});-}-for (const id of ['pf-title', 'pf-brief', 'pf-tags']) {- $(id).addEventListener('focus', () => { pfEditing = true; });- $(id).addEventListener('blur', () => { pfEditing = false; pushPageField(); });-}-$('pf-apply').addEventListener('click', async () => {- const r = await (await post('apply-page')).json().catch(() => ({}));- if (!r.ok) { handleToast({ text: r.error || 'nothing to apply', kind: 'warn', seq: Date.now() }); return; }- handleToast({- text: r.applied === 'wiki' ? 'Page edits applied to the wiki (' + r.fields.join(', ') + ')'- : 'Edits stashed for the next publish' + (r.http ? ' (wiki said HTTP ' + r.http + ')' : ''),- kind: r.applied === 'wiki' ? 'success' : 'info', seq: Date.now(),- });- refreshChangesBadge();-});-$('pf-prompt').addEventListener('click', async () => {- const c = await (await fetch('changes')).json().catch(() => null);- if (!c) return;- const lines = ['While reviewing the hero in Hero Studio I made these changes to ' + (c.ref || state.page) + ':'];- const fmt = (v) => typeof v === 'string' ? '"' + v + '"' : JSON.stringify(v);- for (const [f, ch] of Object.entries(c.page || {})) lines.push('- ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));- for (const [f, ch] of Object.entries(c.pose || {})) lines.push('- pose ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));- if (lines.length === 1) lines.push('- (no changes recorded this session)');- lines.push('- Applied to wiki: ' + (c.applied === 'wiki' ? 'yes' : c.applied === 'stashed' ? 'no, stashed: please include in the next publish' : 'no: please include in the next publish'));- lines.push('Please update the local package (page.json / package.json / README) to match.');- const text = lines.join('\n');- try {- await navigator.clipboard.writeText(text);- handleToast({ text: 'Changelog prompt copied: paste it into the AI session that owns the package', kind: 'success', seq: Date.now() });- } catch {- const ta = document.createElement('textarea');- ta.value = text; document.body.appendChild(ta); ta.select();- document.execCommand('copy'); ta.remove();- handleToast({ text: 'Changelog prompt copied (fallback path)', kind: 'success', seq: Date.now() });- }-});-setInterval(refreshChangesBadge, 3000);--/* zen focus (#12): the hero gets the whole surface */-$('btn-zen').addEventListener('click', () => {- const on = document.body.classList.toggle('zen');- $('btn-zen').classList.toggle('on', on);- try { on ? document.documentElement.requestFullscreen() : document.exitFullscreen(); } catch {}-});-document.addEventListener('keydown', (e) => {- if (e.key === 'Escape' && document.body.classList.contains('zen')) {- document.body.classList.remove('zen');- $('btn-zen').classList.remove('on');- }-});--/* procedural circuitry background (seeded, gently different every roll) */-function mulberry32(a) {- return function () {- a |= 0; a = (a + 0x6D2B79F5) | 0;- let t = Math.imul(a ^ (a >>> 15), 1 | a);- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;- };-}-let lastCircuitKey = '';-function drawCircuitry() {- const cv = $('circuitcanvas');- const rect = hero.getBoundingClientRect();- if (rect.width < 10) return;- const key = state.circuitSeed + 'x' + Math.round(rect.width);- if (key === lastCircuitKey) return;- lastCircuitKey = key;- const W = (cv.width = Math.round(rect.width * 2));- const H = (cv.height = Math.round(rect.height * 2));- const g = cv.getContext('2d');- const rnd = mulberry32(state.circuitSeed || 1);- g.clearRect(0, 0, W, H);- const grid = Math.round(W / 46);- const traces = 34;- for (let i = 0; i < traces; i++) {- const hue = rnd();- g.strokeStyle = hue < 0.72 ? 'rgba(0,184,177,' + (0.05 + rnd() * 0.10).toFixed(3) + ')'- : hue < 0.9 ? 'rgba(88,166,255,' + (0.05 + rnd() * 0.08).toFixed(3) + ')'- : 'rgba(140,107,247,' + (0.05 + rnd() * 0.08).toFixed(3) + ')';- g.lineWidth = rnd() < 0.85 ? 2 : 4;- g.lineCap = 'round';- let x = Math.round(rnd() * (W / grid)) * grid;- let y = Math.round(rnd() * (H / grid)) * grid;- g.beginPath();- g.arc(x, y, g.lineWidth * 1.6, 0, Math.PI * 2);- g.stroke();- g.beginPath();- g.moveTo(x, y);- let dir = Math.floor(rnd() * 4); // 0 r, 1 d, 2 l, 3 u- const segs = 3 + Math.floor(rnd() * 5);- for (let sVar = 0; sVar < segs; sVar++) {- const len = grid * (1 + Math.floor(rnd() * 5));- // 45-degree bend half the time, PCB style- if (rnd() < 0.5) dir = (dir + (rnd() < 0.5 ? 1 : 3)) % 4;- const diag = rnd() < 0.4;- const dx = dir === 0 ? len : dir === 2 ? -len : diag ? len * 0.7 : 0;- const dy = dir === 1 ? len : dir === 3 ? -len : diag ? len * 0.7 : 0;- x += dx; y += dy;- g.lineTo(x, y);- }- g.stroke();- g.beginPath();- g.arc(x, y, g.lineWidth * 1.6, 0, Math.PI * 2);- g.stroke();- }-}-$('btn-reroll').addEventListener('click', () => { post('reroll').catch(() => {}); });--/* image-zone type sizes: instant local feedback, debounced send */-let fsTimer = null;-let hlTimer = null;-$('headline-input').addEventListener('input', e => {- clearTimeout(hlTimer);- const v = e.target.value;- hlTimer = setTimeout(() => { send({ headline: v }); setTimeout(() => runFit(null), 250); }, 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;- hero.style.setProperty('--' + id.replace('fs-', 'fs-'), v + 'cqw');- hero.style.setProperty('--fs-' + key.replace('sub', 'sub').replace('desc', 'desc'), v + 'cqw');- clearTimeout(fsTimer);- fsTimer = setTimeout(() => send({ fontSize: { [key]: v } }), 150);- });-}-$('ab-again').addEventListener('click', runGenerate);--/* owner brand controls */-$('brand-accent').addEventListener('input', e => send({ brand: { accent: e.target.value } }));-$('brand-bg').addEventListener('input', e => send({ brand: { bg: e.target.value } }));-$('brand-clear').addEventListener('click', () => send({ brand: { accent: '', bg: '' } }));+<div id="wdock">+ <div id="wdockintro">You're designing the <b>hero image</b> for <span class="ref" id="wintro-ref"></span>, the picture at the top of its wiki page. Everything updates live above. Nothing is saved until you hit Generate.</div>+ <div id="wrailwrap"><div id="wsteprail"></div></div>+ <div id="wdockhead">+ <span class="wdtitle" id="wd-title"></span>+ <span class="wdcount" id="wd-count"></span>+ <span class="wdhint" id="wd-hint"></span>+ <button class="btn small" id="wbtn-finetune" style="display:none">Fine tune</button>+ <button class="btn btn-primary" id="wbtn-generate" style="display:none">Generate hero PNG</button>+ <button class="btn small btn-primary" id="wbtn-next">Next</button>+ </div>+ <div id="wcardsrow">+ <button class="warrow" id="warr-l">‹</button>+ <div id="wcards"></div>+ <button class="warrow" id="warr-r">›</button>+ </div>+</div> -/* boot: load real pages, subscribe to the live channel */-Promise.all([- fetch('api/pages').then(r => r.json()),- fetch('api/components').then(r => r.json()),- fetch('state').then(r => r.json()),-]).then(([pg, cp, st]) => {- pages = pg;- comps = cp;- state = st;- seenToastSeq = (st._toast && st._toast.seq) || 0; // don't replay a stale toast on load- seenGenSeq = (st._gen && st._gen.seq) || 0; // nor a stale generated modal- const sel = $('sel-page');- sel.innerHTML = pages.map(p =>- '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');- $('sel-comp').innerHTML = comps.map(c =>- '<option value="' + c.id + '">' + c.name + (c.sample ? ' (sample)' : '') + '</option>').join('');- render();- console.log('hero-studio ready: ' + pages.length + ' live pages');- if (location.hash) {- const el = document.querySelector(location.hash);- if (el) setTimeout(() => el.scrollIntoView({ block: 'start' }), 300);- }- const es = new EventSource('events');- es.addEventListener('state', e => { state = JSON.parse(e.data); render(); });- es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });- es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); });- es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); });- // poll fallback in case the proxy buffers the event stream- setInterval(() => {- fetch('state').then(r => r.json()).then(st => {- handleToast(st._toast);- handleGen(st._gen);- if (JSON.stringify(st) !== JSON.stringify(state)) { state = st; render(); }- }).catch(()=>{});- }, 1000);-});-</script>+<script src="js/app.js"></script>+<script src="js/wizard.js"></script> </body> </html>
public/js/app.jsadded+1295@@ -0,0 +1,1295 @@+/* app.js: the entire studio front-end, moved verbatim from index.html in the+ M3 split (wizard redesign). Decomposition into hero/lint/fit/wizard modules+ lands with M4, when the panel code entangled with render() is deleted. */++if (new URLSearchParams(location.search).has('render')) document.body.dataset.render = '1';++/* console relay: the AI reads GET /console instead of DevTools */+(function () {+ const post = (level, args) => fetch('console', { method:'POST', headers:{'Content-Type':'application/json'},+ body: JSON.stringify({ level, text: args.map(a => { try { return typeof a === 'string' ? a : JSON.stringify(a); } catch { return String(a); } }).join(' ') }) }).catch(()=>{});+ ['log','warn','error'].forEach(k => { const o = console[k].bind(console); console[k] = (...a) => { o(...a); post(k, a); }; });+ window.onerror = (m, s, l) => post('error', [m + ' @' + s + ':' + l]);+ window.addEventListener('unhandledrejection', e => post('error', ['unhandledrejection: ' + e.reason]));+})();++let pages = [];+let comps = [];+let state = null;++const $ = (id) => document.getElementById(id);+// Every state-changing POST from this page is attributed to the server's+// owning ai-thread (#39): the page IS that instance's surface, and the human+// acting through it acts within that thread's session.+const withThread = (o) => Object.assign({ aiThread: (state && state.aiThread) || 'hero-studio ui' }, o || {});+const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) });+const send = (patch) => post('state', patch).catch(() => {});++function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); }+// the LAUNCH spelling for display + bake: the wiki's public name is hardware+function wireLabel(t) { return canonType(t) === 'component' ? 'hardware' : (t || 'app'); }+function pageByKey(key) { return pages.find(p => p.owner + '/' + p.slug === key) || pages[0]; }+function compById(id) { return comps.find(c => c.id === id) || comps[0]; }+/* the object the overlay, header, and context cards render: page or component */+function displayEntry() {+ if (state.mode === 'component' && comps.length) {+ const c = compById(state.comp.id);+ return { owner: c.owner, slug: c.slug, type: 'component', title: c.name, brief: c.spec,+ author: c.author, score: c.score, stars: c.stars, installs30d: null,+ tags: [c.mfr.toLowerCase()], updated_at: null, mpn: c.mpn };+ }+ return pageByKey(state.page);+}++/* long mono strings in the overlay: shrink a step past 40 chars; past 54,+ middle-truncate so the owner and the tail stay readable (full string in title=) */+function fitMono(s) {+ if (s.length <= 40) return { text: s, small: false };+ if (s.length <= 54) return { text: s, small: true };+ return { text: s.slice(0, 33) + '...' + s.slice(-18), small: true };+}++function firstSentence(s, max) {+ let t = (s || '').split(/(?<=\.)\s/)[0] || s || '';+ if (t.length > max) t = t.slice(0, max).replace(/\s+\S*$/, '') + '...';+ return t;+}++function accentize(text) {+ const words = text.split(' ');+ if (words.length < 3) return '<span class="accent">' + text + '</span>';+ return '<span class="accent">' + words.slice(0, 2).join(' ') + '</span> ' + words.slice(2).join(' ');+}++function render() {+ if (!state || !pages.length) return;+ if (window.wizardRender) { try { wizardRender(); } catch (e) { console.error('wizard render:', e); } }+ const p = pageByKey(state.page);+ const d = displayEntry();+ const mode = state.mode || 'app';+ const hero = $('hero');+ document.body.dataset.mode = mode;+ hero.dataset.mode = mode;+ hero.dataset.theme = state.theme;+ hero.dataset.layout = state.layout;+ hero.dataset.clayout = state.comp.layout;+ hero.dataset.imgsrc = state.comp.imgSrc;+ hero.dataset.mpn = state.comp.mpn;+ hero.classList.toggle('guides', !!state.guides);+ // imported background art (theme 'image', hero-studio#24 ask 7)+ const bg = state.bgImage || {};+ hero.style.setProperty('--bgimg', bg.src ? 'url(' + bg.src + ')' : 'none');+ hero.classList.toggle('bg-cover', !!bg.cover);+ $('bg-cover').checked = !!bg.cover;+ // ?? not ||: an angle of 0 is a real choice, not an unset value+ hero.style.setProperty('--ga', (state.gradAngle ?? 155) + 'deg');+ $('rng-angle').value = state.gradAngle ?? 155;+ $('angle-val').textContent = state.gradAngle ?? 155;++ // header subject+ $('subj-title').textContent = d.title;+ $('subj-meta').textContent = mode === 'component'+ ? d.type + ' · score ' + d.score + ' · MPN ' + d.mpn+ : d.type + ' · score ' + d.score + ' · ' + d.stars + ' stars · ' + d.installs30d + ' installs/30d';+ $('btn-guides').classList.toggle('on', !!state.guides);++ // verification chrome (the pose gate sets state.verify); per-item confirms+ const confirm = state.confirm || {};+ const flags = state.flags || {};+ $('v-title').checked = !!confirm.title;+ $('v-sub').checked = !!confirm.subtitle;+ $('v-vitrine').checked = !!confirm.vitrine;+ $('v-type').checked = !!confirm.type;+ // "Needs fixing" verdicts (#14): flags hold Generate and are visible to the+ // driving AI via state, so it can adjust and re-present instead of the+ // human laundering a problem through an approve-only checklist.+ for (const [el, key] of [['v-title','title'],['v-sub','subtitle'],['v-vitrine','vitrine'],['v-type','type']]) {+ const label = $(el).parentElement;+ let mark = label.querySelector('.flagged');+ if (flags[key] && !mark) {+ mark = document.createElement('span');+ mark.className = 'flagged';+ mark.textContent = 'NEEDS FIXING (flagged; the AI sees this in state.flags)';+ label.appendChild(mark);+ } else if (!flags[key] && mark) mark.remove();+ }+ const types = (Array.isArray(d.types) && d.types.length ? d.types : [d.type]);+ const modeMatchesType = (canonType(d.type) === 'component') === (mode === 'component');+ $('v-type-label').textContent = 'Page type matches: page is "' + types.map(wireLabel).join(' + ') + '", studio mode is "' + mode + '"' + (modeMatchesType ? '' : ' (MISMATCH: switch mode at the top of the panel)');+ $('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;++ // author-adjustable image-zone type (hero-studio#4); cqw so preview == render+ const fs = state.fontSize || {};+ 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');+ 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);+ }++ // the gate visibly CLOSES (hero-studio#5): approval is persistent state+ const ap = state.approved;+ const approvedHere = !!(ap && ap.page === state.page);+ $('approvedbar').style.display = approvedHere ? 'block' : 'none';+ if (approvedHere) {+ $('ab-body').textContent = ap.file + ', generated ' + new Date(ap.ts).toLocaleString() ++ '. The gate is satisfied; the PNG carries the provenance stamp. Apply it with: adom-wiki page hero ' + state.page + ' --image <repo path>';+ }+ $('verifybar').style.display = state.verify && !approvedHere ? 'block' : 'none';++ // stagebar (#21/#23): stage chrome above the stage, never on it+ $('stagebar').classList.toggle('on', !!state.verify || !!state.marqueePreview);+ $('sb-png').classList.toggle('on', !state.marqueePreview);+ $('sb-mq').classList.toggle('on', !!state.marqueePreview);++ // walkthrough + procedural background + measured layout lint + page fields+ showTourStep();+ scheduleLint();+ renderPageFields();+ if (state.theme === 'circuitry') requestAnimationFrame(drawCircuitry);+ $('btn-reroll').style.display = state.theme === 'circuitry' ? '' : 'none';++ // marquee overlay preview (ask 5): today's REAL homepage treatment,+ // composited over the hero so redundancy and balance are judgeable+ hero.classList.toggle('show-mq', !!state.marqueePreview && mode === 'app');+ if (state.marqueePreview) {+ $('mqo-badges').innerHTML = '<span class="badge badge-' + canonType(d.type) + '">' + wireLabel(d.type) + '</span>' ++ (d.score > 0 ? '<span class="ts-slide-score">' + TREND13 + ' ' + d.score + '</span>' : '') ++ (d.stars > 0 ? '<span class="ts-slide-stars">' + STAR12 + ' ' + d.stars + '</span>' : '');+ // TODAY-REALITY on purpose (Colby): the homepage still overlays its own+ // title, so show the doubling instead of hiding it; the tag names the+ // planned fix so nobody thinks it is an accident.+ $('mqo-title').textContent = d.title;+ $('mqo-brief').textContent = firstSentence(d.brief || p.brief || '', 120);+ $('mqo-owner').textContent = 'wiki.adom.inc/' + d.owner + '/' + d.slug;+ // sample prompt: DYNAMIC by design (#22): metadata the page renders with a+ // copy button; if it were pixels nobody could copy it+ const sp = (Array.isArray(d.sample_prompts) && d.sample_prompts[0] && d.sample_prompts[0].prompt) || state.samplePrompt || '';+ $('mqo-prompt').style.display = sp ? 'flex' : 'none';+ $('mqo-prompt-text').textContent = sp ? '"' + sp + '"' : '';+ }+ $('chk-mqpreview').checked = !!state.marqueePreview;++ // vitrine 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; }+ 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 ++ ' (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);+ // 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.+ 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;++ // Adom branding only on adom-owned pages; everyone else: no mark, own palette.+ // Every preview wordmark (hero AND the in-context mock cards) follows.+ const foreign = d.owner !== 'adom';+ document.querySelectorAll('.wordmark').forEach(w => w.classList.toggle('noadom', foreign));+ // #37: the standard-wordmark stamp for imports; adom-owned pages only (the+ // no-Adom-mark-for-foreign-owners policy applies to the stamp too)+ hero.classList.toggle('stamp-logo', !!(state.overlay || {}).logo && !foreign);+ $('stamp-logo-row').style.display = (simOverImport && !foreign) ? '' : 'none';+ $('chk-stamp-logo').checked = !!(state.overlay || {}).logo;+ $('fi-logo').classList.toggle('sim-fenced', simOverImport && !!(state.overlay || {}).logo);+ const brand = state.brand || {};+ hero.classList.toggle('branded', foreign && !!(brand.accent || brand.bg));+ if (foreign && brand.accent) hero.style.setProperty('--brand-accent', brand.accent);+ else hero.style.removeProperty('--brand-accent');+ if (foreign && brand.bg) hero.style.setProperty('--brand-bg', brand.bg);+ else hero.style.removeProperty('--brand-bg');+ const secBrand = document.getElementById('sec-brand');+ if (secBrand) {+ secBrand.style.display = (mode === 'app' && foreign) ? '' : 'none';+ if (brand.accent) $('brand-accent').value = brand.accent;+ if (brand.bg) $('brand-bg').value = brand.bg;+ }++ // component image zone+ if (mode === 'component' && comps.length) {+ const c = compById(state.comp.id);+ $('c-family').textContent = c.family;+ $('c-mpn-chip').textContent = c.mpn;+ $('c-name').textContent = c.name;+ $('c-mpn-under').textContent = c.mpn;+ $('c-spec').textContent = c.spec;+ }++ // image zone+ 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 = 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';+ const subText = state.subtitle || firstSentence(p.brief, 80);+ if (Array.isArray(state.accentWords)) {+ $('el-sub').innerHTML = '';+ const on = new Set(state.accentWords);+ let run = null;+ subText.split(/\s+/).filter(Boolean).forEach((w, i) => {+ const sel = on.has(i);+ if (run && run.dataset.acc === (sel ? '1' : '0')) { run.textContent += ' ' + w; return; }+ if (run) $('el-sub').appendChild(document.createTextNode(' '));+ run = document.createElement('span');+ run.dataset.acc = sel ? '1' : '0';+ if (sel) run.className = 'accent';+ run.textContent = w;+ $('el-sub').appendChild(run);+ });+ } else {+ $('el-sub').innerHTML = accentize(subText);+ }+ $('el-desc').style.display = state.show.description ? 'block' : 'none';+ $('el-desc').textContent = firstSentence(p.brief.slice(80), 110);+ $('win-label').textContent = p.title + ' · Adom webview';++ // lower band: gone for app pages (the page shows the image bare); kept in+ // component mode for the MPN-in-overlay placement option+ hero.querySelector('.lower').style.display = mode === 'app' ? 'none' : '';+ const showTitleInOverlay = mode === 'component' || state.titleMode !== 'image-only';+ // Pill GROUP (#19): a page carrying several types (app + service) renders+ // them as one clustered badge box; the linter treats the cluster as one box.+ const ptr = $('pill-tr');+ ptr.style.display = state.overlay.pill ? '' : 'none';+ ptr.innerHTML = types.map(t => '<span class="pill ' + canonType(t) + '">' + wireLabel(t).toUpperCase() + '</span>').join('');+ $('l-trend').style.display = state.overlay.scores ? '' : 'none';+ $('l-trend').querySelector('b').textContent = d.score.toLocaleString('en-US');+ $('l-stars').style.display = state.overlay.scores ? '' : 'none';+ $('l-stars').querySelector('b').textContent = d.stars;+ $('l-installs').style.display = (state.overlay.scores && d.installs30d != null) ? '' : 'none';+ $('l-installs').querySelector('b').textContent = d.installs30d;+ const url = 'https://wiki.adom.inc/' + d.owner + '/' + d.slug;+ $('l-title').style.display = showTitleInOverlay ? 'block' : 'none';+ $('l-title').textContent = d.title;+ $('l-title').href = url;+ $('l-desc').textContent = firstSentence(d.brief || p.brief || '', 140);+ const subtitleInImage = mode === 'app' && state.show.subtitle;+ $('l-desc').style.display = (!subtitleInImage && (d.brief || p.brief)) ? 'block' : 'none';+ $('l-author').style.display = state.overlay.author ? '' : 'none';+ $('l-author').innerHTML = 'by <a href="' + url + '" target="_blank">' + d.author + '</a>';+ const mpnFit = fitMono(d.mpn || '');+ $('l-mpn').textContent = mpnFit.text;+ $('l-mpn').title = d.mpn || '';+ $('l-mpn').classList.toggle('fit', mpnFit.small);+ const ref = 'wiki.adom.inc/' + d.owner + '/' + d.slug;+ const refFit = fitMono(ref);+ $('l-slug').style.display = state.overlay.slug ? '' : 'none';+ $('l-slug').textContent = refFit.text;+ $('l-slug').title = ref;+ $('l-slug').classList.toggle('fit', refFit.small);+ $('l-slug').href = url;++ // controls reflect state+ document.querySelectorAll('#grp-mode .opt').forEach(b => b.classList.toggle('on', b.dataset.v === mode));+ if (comps.length) $('sel-comp').value = compById(state.comp.id).id;+ document.querySelectorAll('#grp-imgsrc .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.imgSrc));+ document.querySelectorAll('#grp-clayout .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.layout));+ document.querySelectorAll('#grp-mpn .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.comp.mpn));+ $('sel-page').value = state.page;+ document.querySelectorAll('#grp-layout .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.layout));+ document.querySelectorAll('#grp-theme .swatch').forEach(b => b.classList.toggle('on', b.dataset.v === state.theme));+ document.querySelectorAll('#grp-titlemode .opt').forEach(b => b.classList.toggle('on', b.dataset.v === state.titleMode));+ $('chk-logo').checked = state.show.logo;+ $('chk-title').checked = state.show.title;+ $('chk-subtitle').checked = state.show.subtitle;+ $('chk-description').checked = state.show.description;+ $('chk-pill').checked = state.overlay.pill;+ $('chk-scores').checked = state.overlay.scores;+ $('chk-author').checked = state.overlay.author;+ $('chk-slug').checked = state.overlay.slug;++ syncPreviews();+}++/* in-context previews: markup mirrors git-wiki lib/templates.js (popularMarquee + categoryListPage) */+const STAR12 = '<svg width="12" height="12" viewBox="0 0 24 24" fill="#d29922" stroke="#d29922" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';+const TREND13 = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>';+const IMGPH = '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#484f58" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';++function relTime(iso) {+ if (!iso) return '';+ const s = (Date.now() - new Date(iso).getTime()) / 1000;+ if (s < 3600) return Math.max(1, Math.floor(s / 60)) + 'm ago';+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';+ return Math.floor(s / 86400) + 'd ago';+}++function marqueeTile(p, isLive) {+ const heroPart = isLive ? '<div class="clone-slot"></div>'+ : '<div class="ts-hero-fallback"><span>' + p.title + '</span></div>';+ // DECIDED (Colby, 2026-07-26, adom/wiki#80): hero-bearing tiles get badges+ // and owner only; the baked title is the single title. Heroless fallback+ // tiles keep the text overlay, which is their only title.+ const textPart = isLive+ ? ''+ : '<h3 class="ts-slide-title">' + p.title + '</h3>' ++ (p.brief ? '<p class="ts-slide-brief">' + p.brief + '</p>' : '');+ return '<a class="ts-slide mq-slide">' + heroPart ++ '<div class="ts-slide-shade"></div><div class="ts-slide-info">' ++ '<div class="ts-slide-badges"><span class="badge badge-' + p.type + '">' + p.type + '</span>' ++ (p.score > 0 ? '<span class="ts-slide-score">' + TREND13 + ' ' + p.score + '</span>' : '') ++ (p.stars > 0 ? '<span class="ts-slide-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') ++ '</div>' + textPart ++ '<span class="ts-slide-owner">' + p.owner + '/' + p.slug + '</span></div></a>';+}++function indexCard(p, isLive) {+ const hero = isLive ? '<div class="clone-slot"></div>'+ : '<div class="page-card-hero-placeholder">' + IMGPH + '</div>';+ return '<a class="page-card">' ++ '<div class="page-card-hero">' + hero + '</div>' ++ '<div class="page-card-body">' ++ '<div class="page-card-top"><span class="badge badge-' + p.type + '">' + p.type + '</span></div>' ++ '<h3 class="page-card-title">' + p.title + '</h3>' ++ (p.brief ? '<p class="page-card-brief">' + p.brief + '</p>' : '') ++ '<div class="page-card-meta"><span>' + p.owner + '/' + p.slug + '</span>' ++ (p.updated_at ? '<span class="dot-sep">·</span><span>' + relTime(p.updated_at) + '</span>' : '') ++ (p.stars > 0 ? '<span class="dot-sep">·</span><span class="page-card-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') ++ '</div>' ++ (p.tags && p.tags.length ? '<div class="page-card-tags">' + p.tags.map(t => '<span class="chip-sm">' + t + '</span>').join('') + '</div>' : '') ++ '<div class="page-card-install"><code>adom-wiki pkg install ' + p.owner + '/' + p.slug + '</code></div>' ++ '</div></a>';+}++function cloneHero(imageOnly) {+ const c = $('hero').cloneNode(true);+ c.removeAttribute('id');+ c.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));+ c.classList.add('preview');+ c.classList.remove('guides');+ if (imageOnly) { const l = c.querySelector('.lower'); if (l) l.remove(); }+ return c;+}++function syncPreviews() {+ const p = displayEntry();+ const nb = pages.filter(x => x.owner + '/' + x.slug !== p.owner + '/' + p.slug).slice(0, 3);+ // marquee: live tile among real neighbors, content doubled for the -50% loop+ const seq = marqueeTile(p, true) + nb.map(x => marqueeTile(x)).join('');+ $('mq-track').innerHTML = seq + seq;+ // index grid: live card first, then real neighbor cards (no-hero placeholder icon)+ $('wikigrid').innerHTML = indexCard(p, true) + nb.map(x => indexCard(x)).join('');+ // wiki surfaces get the IMAGE PART only (the wiki overlays its own text);+ // the desk bar gets the full v2 hero with its baked-in text overlay+ document.querySelectorAll('#contexts .clone-slot').forEach(s => s.appendChild(cloneHero(true)));+ document.querySelectorAll('#contexts .heroslot').forEach(s => { s.innerHTML = ''; s.appendChild(cloneHero(false)); });+}++let seenToastSeq = 0;+function handleToast(t) {+ if (t && t.seq > seenToastSeq) { seenToastSeq = t.seq; toast(t.text, t.kind); }+}++function toast(text, kind) {+ const el = document.createElement('div');+ el.className = 'toast ' + (kind || 'info');+ el.textContent = text;+ $('toasts').appendChild(el);+ setTimeout(() => el.remove(), 4000);+}++/* wire controls: server owns the truth, SSE echoes back */+document.querySelectorAll('#grp-mode .opt').forEach(b => b.addEventListener('click', () => send({ mode: b.dataset.v })));+$('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 => {+ 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.+ post('refresh-page?page=' + encodeURIComponent(v))+ .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 })));+$('chk-logo').addEventListener('change', e => send({ show: { logo: e.target.checked } }));+$('chk-title').addEventListener('change', e => send({ show: { title: e.target.checked } }));+$('chk-subtitle').addEventListener('change', e => send({ show: { subtitle: e.target.checked } }));+$('chk-description').addEventListener('change', e => send({ show: { description: e.target.checked } }));+$('chk-pill').addEventListener('change', e => send({ overlay: { pill: e.target.checked } }));+$('chk-scores').addEventListener('change', e => send({ overlay: { scores: e.target.checked } }));+$('chk-author').addEventListener('change', e => send({ overlay: { author: e.target.checked } }));+$('chk-slug').addEventListener('change', e => send({ overlay: { slug: e.target.checked } }));+$('btn-guides').addEventListener('click', () => send({ guides: !state.guides }));+let angleTimer = null;+$('rng-angle').addEventListener('input', e => {+ $('angle-val').textContent = e.target.value;+ $('hero').style.setProperty('--ga', e.target.value + 'deg'); // instant local feedback+ clearTimeout(angleTimer);+ angleTimer = setTimeout(() => send({ gradAngle: +e.target.value }), 120);+});+$('btn-rand').addEventListener('click', () => {+ const themes = ['midnight','pcb','onion','pill','grid','gradient'];+ const angles = [65, 100, 155, 210, 245, 290, 335];+ const theme = themes[Math.floor(Math.random()*themes.length)];+ const gradAngle = angles[Math.floor(Math.random()*angles.length)];+ if (state.mode === 'component') {+ const clayouts = ['photo-right','photo-left','photo-full'];+ const srcs = ['photo','raytrace','phong'];+ const mpns = ['eyebrow','under-title','overlay'];+ const c = comps[Math.floor(Math.random() * comps.length)];+ send({ theme, gradAngle, comp: { id: c.id, layout: clayouts[Math.floor(Math.random()*clayouts.length)],+ imgSrc: srcs[Math.floor(Math.random()*srcs.length)], mpn: mpns[Math.floor(Math.random()*mpns.length)] } });+ toast('Randomized: ' + c.name, 'info');+ } else {+ const layouts = ['bleed-bottom','bleed-right','bleed-top','bleed-corner-br','bleed-corner-tr','contained','mirror'];+ const p = pages[Math.floor(Math.random() * pages.length)];+ send({ page: p.owner + '/' + p.slug, layout: layouts[Math.floor(Math.random()*layouts.length)], theme, gradAngle });+ toast('Randomized: ' + p.title, 'info');+ }+});+let seenGenSeq = 0;+function showGenerated(g) {+ $('gen-img').src = g.url + '?t=' + g.ts;+ $('gen-path').textContent = g.file;+ $('gen-open').href = g.url;+ $('genmodal').classList.add('show');+}+/* fires for local clicks AND for API-triggered generates (SSE or poll), deduped by seq */+function handleGen(g) {+ if (g && g.ok && window.wizardOnGenerated) { try { window.wizardOnGenerated(); } catch {} }+ if (g && g.ok && g.seq > seenGenSeq) { seenGenSeq = g.seq; showGenerated(g); }+}+$('gen-close').addEventListener('click', e => { e.preventDefault(); $('genmodal').classList.remove('show'); });+$('genmodal').addEventListener('click', e => { if (e.target === $('genmodal')) $('genmodal').classList.remove('show'); });+function runGenerate() {+ const b = $('btn-generate');+ b.disabled = true; b.textContent = 'Generating...';+ toast('Rendering the hero on the server', 'info');+ post('generate', { fromGate: true }).then(r => r.json()).then(g => {+ b.disabled = false; b.textContent = 'Generate hero PNG';+ if (g.ok) handleGen(g); // success toast arrives via the live channel+ else toast('Generate failed: ' + (g.error || 'unknown') + (g.hint ? '. ' + g.hint : ''), 'error');+ }).catch(() => { b.disabled = false; b.textContent = 'Generate hero PNG'; toast('Generate failed: network error', 'error'); });+}+$('btn-generate').addEventListener('click', runGenerate);+$('v-generate').addEventListener('click', runGenerate);++$('btn-reset').addEventListener('click', () => {+ post('preset', { name: 'studio-default' });+ toast('Back to the studio defaults (server truth, not a client copy)', 'success');+});++/* tooltips: fixed div, 600ms delay, clamped to viewport on both axes */+(function () {+ const tip = $('tip');+ let timer = null;+ document.addEventListener('mouseover', e => {+ const t = e.target.closest('[data-tip]');+ if (!t) return;+ clearTimeout(timer);+ timer = setTimeout(() => {+ tip.textContent = t.getAttribute('data-tip');+ tip.classList.add('show');+ const r = t.getBoundingClientRect();+ let x = r.left + 14, y = r.bottom + 10;+ const w = tip.offsetWidth, h = tip.offsetHeight, M = 8;+ if (y + h > innerHeight - M) y = r.top - h - 10;+ x = Math.max(M, Math.min(x, innerWidth - w - M));+ y = Math.max(M, Math.min(y, innerHeight - h - M));+ tip.style.left = x + 'px'; tip.style.top = y + 'px';+ }, 600);+ });+ document.addEventListener('mouseout', e => {+ if (e.target.closest && e.target.closest('[data-tip]')) { clearTimeout(timer); tip.classList.remove('show'); }+ });+})();++/* verification checklist + vitrine controls */+[['v-title','title'],['v-sub','subtitle'],['v-vitrine','vitrine'],['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 } }));+});+$('verifybar').addEventListener('click', e => {+ if (e.target.id !== 'v-generate' && !$('v-generate').disabled) return;+ if ($('v-generate').disabled && (e.target.id === 'v-generate' || e.target.closest('#v-generate'))) {+ const flags = Object.entries(state.flags || {}).filter(([, v]) => v).map(([k]) => k);+ const fails = (state.lint || []).filter(l => l.level === 'fail');+ let why = 'Confirm all four checks first; Generate unlocks when every item is ratified';+ if (flags.length) why = 'Generate is locked: ' + flags.join(', ') + ' flagged as needs-fixing. Fix and re-tick, or the AI adjusts and re-presents';+ else if (fails.length) why = 'Generate is locked by the layout linter: ' + fails.map(f => f.msg).join(' | ');+ handleToast({ text: why, kind: 'warn', seq: Date.now() });+ }+});+$('bg-file').addEventListener('change', e => {+ const f = e.target.files && e.target.files[0];+ if (!f) return;+ const r = new FileReader();+ r.onload = () => send({ bgImage: { src: r.result }, theme: 'image' });+ r.readAsDataURL(f);+});+$('bg-cover').addEventListener('change', e => send({ bgImage: { cover: e.target.checked } }));+$('vitrine-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.readAsDataURL(f);+});+$('vitrine-full').addEventListener('change', e => send({ vitrine: { 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' } }));+$('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))+ .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() }))+ .catch(() => {});+});++/* verification walkthrough: spotlight each checklist item in place */+const TOUR_STEPS = [+ { key: 'title', el: 'el-title', title: 'This is your title',+ 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',+ 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.' },+];+let tourIdx = -1;+function tourTarget(step) {+ if (step.winSel) { const w = hero.querySelector(step.winSel); if (w && w.offsetParent) return w; }+ const el = $(step.el);+ if (el && el.offsetParent) return el;+ return step.fallbackEl ? $(step.fallbackEl) : null;+}+function clearTourGlow() {+ document.querySelectorAll('.tour-glow').forEach(n => n.classList.remove('tour-glow'));+}+function showTourStep() {+ clearTourGlow();+ const confirm = state.confirm || {};+ const flags = state.flags || {};+ // a flagged step is reviewed (verdict: needs fixing), so the tour moves on;+ // the flag itself keeps Generate locked until it is cleared+ const remaining = TOUR_STEPS.filter(st => !confirm[st.key] && !flags[st.key]);+ if (!state.tour || !state.verify || remaining.length === 0) {+ $('tourpop').style.display = 'none';+ if (state.tour && remaining.length === 0) send({ tour: false });+ return;+ }+ const step = remaining[0];+ tourIdx = TOUR_STEPS.indexOf(step);+ const target = tourTarget(step);+ if (!target) { $('tourpop').style.display = 'none'; return; }+ target.classList.add('tour-glow');+ const r = target.getBoundingClientRect();+ const pop = $('tourpop');+ $('tp-title').textContent = step.title;+ $('tp-body').textContent = step.body;+ pop.style.display = 'block';+ placeTourPop(pop, target, r);+}+/* The card must NEVER cover the element it asks the human to verify: a+ coach-mark over its own subject collects blind confirmations (#10).+ Try below / above / right / left; take the first spot that fits the+ viewport and does not intersect the target. If no spot exists (target+ nearly fills the window), lift the target above the card instead. */+function placeTourPop(pop, target, r) {+ const W = window.innerWidth, H = window.innerHeight, M = 12, GAP = 14;+ const pw = pop.offsetWidth, ph = pop.offsetHeight;+ const candidates = [+ { top: r.bottom + GAP, left: r.left },+ { top: r.top - ph - GAP, left: r.left },+ { top: r.top, left: r.right + GAP },+ { top: r.top, left: r.left - pw - GAP },+ ];+ const fits = c => c.top >= M && c.left >= M && c.top + ph <= H - M && c.left + pw <= W - M;+ const hides = c => !(c.left + pw <= r.left || c.left >= r.right || c.top + ph <= r.top || c.top >= r.bottom);+ let spot = candidates.find(c => fits(c) && !hides(c));+ if (!spot) {+ // clamp into the viewport on the roomier vertical side of the target+ const left = Math.max(M, Math.min(W - pw - M, r.left));+ spot = (H - r.bottom >= r.top)+ ? { top: Math.max(M, Math.min(H - ph - M, r.bottom + GAP)), left }+ : { top: Math.max(M, r.top - ph - GAP), left };+ }+ document.querySelectorAll('.tour-above').forEach(n => n.classList.remove('tour-above'));+ if (hides(spot)) target.classList.add('tour-above');+ // apply the human's drag displacement (#13): anchored spawn gives context,+ // the remembered offset keeps later steps out of the way they chose+ pop._anchor = { top: spot.top, left: spot.left };+ const top = Math.max(4, Math.min(window.innerHeight - 60, spot.top + tpDrag.dy));+ const left = Math.max(4, Math.min(window.innerWidth - 80, spot.left + tpDrag.dx));+ pop.style.top = top + 'px';+ pop.style.left = left + 'px';+}+window.addEventListener('resize', () => { if (state.tour && state.verify) showTourStep(); });++/* ---- layout linter (#18): the studio notices, the human decides ----+ Most engineers are not graphic designers; the studio is the only party that+ reliably knows the geometry, so it encodes the judgment. Runs after every+ render (debounced), posts results to state.lint (visible to the driving AI),+ and hard failures (clipped text) lock Generate. Warnings advise. */+let lintTimer = null;+let lastLintKey = '';+function scheduleLint() { clearTimeout(lintTimer); lintTimer = setTimeout(runLint, 450); }+function runLint() {+ if (!state) return;+ const hero = $('hero');+ const H = hero.getBoundingClientRect();+ if (H.width < 60) return;+ const items = [];+ const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height });+ const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2;+ const boxOf = (el) => rel(el.getBoundingClientRect());+ const overlapPx = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));++ const ptrEl = $('pill-tr');+ const pill = vis(ptrEl) ? boxOf(ptrEl) : null;+ const winEl = hero.querySelector('.window');+ const vitrine = (winEl && winEl.offsetParent && state.mode !== 'component') ? boxOf(winEl) : null;+ const texts = [['title', 'el-title'], ['subtitle', 'el-sub'], ['detail line', 'el-desc']]+ .filter(([, id]) => vis($(id)))+ .map(([name, id]) => ({ name, id, box: boxOf($(id)) }));++ // 1. pill vs vitrine / pill vs baked text (#17). Pill placement is+ // contract-fixed top right, so the in-contract fix is a backdrop scrim+ // (reads as a deliberate badge) plus this warning.+ ptrEl.classList.toggle('scrim', !!(pill && vitrine && overlapPx(pill, vitrine) > 9));+ if (pill && vitrine && overlapPx(pill, vitrine) > 9) {+ items.push({ id: 'pill-vitrine', level: 'warn', el: 'pill-tr', msg: 'type pill sits on the vitrine (scrim applied so it reads as a badge). If the app has its own corner UI there, pick a layout that leaves the top-right clear' });+ }+ for (const t of texts) {+ if (pill && overlapPx(pill, t.box) > 9) items.push({ id: 'pill-' + t.id, level: 'warn', el: 'pill-tr', msg: 'type pill overlaps the ' + t.name });+ }++ // 2. image-zone text intruding into the dynamic marquee band (#14): the+ // homepage overlays its own info over the lower band; keep it calm.+ const bandTop = H.height * 0.76;+ for (const t of texts) {+ if (t.box.bottom > bandTop + 2) {+ items.push({ id: 'band-' + t.id, level: 'warn', el: t.id, msg: t.name + ' extends ' + Math.round(t.box.bottom - bandTop) + 'px into the dynamic marquee band; shrink the title font or shorten the copy so the lower band stays calm' });+ }+ }++ // 3. clipped text = hard fail (the registry would ship a broken hero);+ // near-edge text = warning. The vitrine is exempt: bleeding off the edge is+ // a deliberate layout feature.+ const inset = H.width * 0.015;+ for (const t of texts) {+ if (t.box.left < -1 || t.box.top < -1 || t.box.right > H.width + 1 || t.box.bottom > H.height + 1) {+ items.push({ id: 'clip-' + t.id, level: 'fail', el: t.id, msg: t.name + ' is CLIPPED by the hero edge; Generate stays locked until it fits' });+ } else if (t.box.left < inset || t.box.top < inset || H.width - t.box.right < inset) {+ items.push({ id: 'inset-' + t.id, level: 'warn', el: t.id, msg: t.name + ' is closer than 1.5% to the hero edge' });+ }+ }++ // 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 || {};+ 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' });+ if (bandScan.status === 'done' && bandScan.texty) {+ items.push({ id: 'import-band-text', level: 'warn', el: 'fullimport-img', msg: 'the imported image appears to carry text or graphics in the lower dynamic band (heuristic edge scan): the homepage overlays its own info there, keep the band calm' });+ }+ }++ // 4c. prompt-looking text baked into pixels (#22): sample prompts belong in+ // page metadata (sample_prompts), rendered dynamically with a copy button.+ for (const t of texts) {+ const txt = ($(t.id).textContent || '');+ if (/["\u201c\u201d'][^"\u201c\u201d']{12,}["\u201c\u201d']/.test(txt)) {+ items.push({ id: 'prompt-' + t.id, level: 'warn', el: t.id, msg: t.name + ' contains quoted prompt-looking text; a baked prompt cannot be copied. Put it in sample_prompts (page metadata) and the page renders it dynamically with a copy button' });+ }+ }++ // 4. title wrap count: a 4-line title is almost never intended.+ const tEl = $('el-title');+ if (vis(tEl)) {+ const lh = parseFloat(getComputedStyle(tEl).lineHeight);+ const lines = lh ? Math.round(tEl.getBoundingClientRect().height / lh) : 1;+ if (lines >= 4) items.push({ id: 'title-wrap', level: 'warn', el: 'el-title', msg: 'title wraps to ' + lines + ' lines; shorten it or lower the title size (left panel)' });+ }++ // 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 {}+ }++ // 6b. vitrine coverage (#43): the title is the message, the vitrine is+ // supporting evidence. When the vitrine 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));+ 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' });+ }+ }++ // 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');+ if (vit2.src && !vit2.full && vit2.cover && vimg && vimg.naturalWidth > 0) {+ const win2 = hero.querySelector('.appbody');+ if (win2 && win2.offsetParent) {+ const wb = win2.getBoundingClientRect();+ const arImg = vimg.naturalWidth / vimg.naturalHeight;+ const arSlot = wb.width / wb.height;+ 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' });+ }+ 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' });+ }+ }+ }++ // 6d. multi-message import (#43): one hero, one message.+ if (vit2.full && vit2.src && bandScan.status === 'done' && bandScan.busyCells >= 6) {+ items.push({ id: 'import-multi-message', level: 'warn', el: 'fullimport-img', msg: 'the imported image reads as ' + bandScan.busyCells + ' of 12 zones text/graphic-dense: that is several competing messages, and a hero sends ONE. The title should be the message; white space is what makes it land' });+ }++ // 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));+ if (key !== lastLintKey) {+ lastLintKey = key;+ send({ lint: items.map(({ id, level, msg }) => ({ id, level, msg })) });+ }+}+function renderLintBox(items) {+ const box = $('lintbox');+ if (!state.verify) { box.innerHTML = ''; return; }+ if (!items.length) { box.innerHTML = '<div class="lint-ok">layout linter: no findings (pill, margins, band, clipping, wrap all clear)</div>'; return; }+ box.innerHTML = items.map((i, n) =>+ '<div class="lint-item lint-' + i.level + '" data-el="' + (i.el || '') + '" data-n="' + n + '">' ++ (i.level === 'fail' ? 'BLOCKS GENERATE: ' : '') + i.msg + '</div>').join('');+}+$('lintbox').addEventListener('click', (e) => {+ const item = e.target.closest('.lint-item');+ if (!item || !item.dataset.el) return;+ const el = $(item.dataset.el);+ if (!el) return;+ clearTourGlow();+ el.classList.add('tour-glow');+ setTimeout(() => el.classList.remove('tour-glow'), 2200);+});++/* draggable tour dialogs (#13): pointer-capture drag on the header, clamped;+ the offset persists across steps for this tour session */+let tpDrag = { dx: 0, dy: 0 };+(() => {+ const pop = $('tourpop');+ const head = pop.querySelector('.tp-head');+ let grab = null;+ head.addEventListener('pointerdown', (e) => {+ grab = { x: e.clientX - pop.offsetLeft, y: e.clientY - pop.offsetTop };+ pop.classList.add('dragging');+ head.setPointerCapture(e.pointerId);+ });+ head.addEventListener('pointermove', (e) => {+ if (!grab) return;+ const left = Math.max(4, Math.min(window.innerWidth - 80, e.clientX - grab.x));+ const top = Math.max(4, Math.min(window.innerHeight - 60, e.clientY - grab.y));+ pop.style.left = left + 'px';+ pop.style.top = top + 'px';+ if (pop._anchor) tpDrag = { dx: left - pop._anchor.left, dy: top - pop._anchor.top };+ });+ const drop = () => { grab = null; pop.classList.remove('dragging'); };+ head.addEventListener('pointerup', drop);+ head.addEventListener('pointercancel', drop);+})();+$('tp-ok').addEventListener('click', () => {+ const step = TOUR_STEPS[tourIdx];+ if (step) send({ confirm: { [step.key]: true }, flags: { [step.key]: false } });+});+$('tp-flag').addEventListener('click', () => {+ const step = TOUR_STEPS[tourIdx];+ if (!step) return;+ send({ flags: { [step.key]: true }, confirm: { [step.key]: false } });+ handleToast({ text: step.key + ' flagged as needs-fixing: Generate stays locked and the driving AI can read the flag from state and adjust', kind: 'warn', seq: Date.now() });+});+$('tp-skip').addEventListener('click', () => { clearTourGlow(); $('tourpop').style.display = 'none'; send({ tour: false }); });++$('mqo-prompt-copy').addEventListener('click', async (e) => {+ e.stopPropagation();+ const txt = $('mqo-prompt-text').textContent.replace(/^"|"$/g, '');+ try { await navigator.clipboard.writeText(txt); } catch {}+ handleToast({ text: 'sample prompt copied', kind: 'success', seq: Date.now() });+});++/* full-hero import band scan (#22.1): composed-layout lint cannot see imported+ pixels, so the class of hero most likely to violate the band skipped every+ check. A cheap edge-density scan of the bottom 24% flags text-like structure+ in the band; it is a heuristic and says so. */+let bandScan = { key: '', status: 'idle', texty: false, busyCells: 0, cells: 12 };+function edgeDensity(g, sx, sy, sw, sh, w, h) {+ // draw a region to a small canvas and return horizontal edge density+ g.canvas.width = w; g.canvas.height = h;+ g.drawImage(g._img, sx, sy, sw, sh, 0, 0, w, h);+ const d = g.getImageData(0, 0, w, h).data;+ let edges = 0, n = 0;+ for (let y = 0; y < h; y++) {+ for (let x = 1; x < w; x++) {+ const i = (y * w + x) * 4, j = i - 4;+ const a = d[i] * .3 + d[i + 1] * .59 + d[i + 2] * .11;+ const b = d[j] * .3 + d[j + 1] * .59 + d[j + 2] * .11;+ if (Math.abs(a - b) > 60) edges++;+ n++;+ }+ }+ return n ? edges / n : 0;+}+function scanImportBand(src) {+ const key = src.length + ':' + src.slice(40, 72);+ if (bandScan.key === key) return;+ bandScan = { key, status: 'pending', texty: false, busyCells: 0, cells: 12 };+ const img = new Image();+ img.onload = () => {+ try {+ const g = document.createElement('canvas').getContext('2d');+ g._img = img;+ // 1. the calm-band check (bottom 24%), unchanged+ const w = Math.min(800, img.width);+ const bandH = Math.max(10, Math.round(img.height * 0.24 * (w / img.width)));+ bandScan.texty = edgeDensity(g, 0, img.height * 0.76, img.width, img.height * 0.24, w, bandH) > 0.0015;+ // 2. message-block grid (#43): a hero sends ONE message; count how many+ // zones of the upper 76% read as text/graphic-dense. 4x3 grid, cheap.+ let busy = 0;+ for (let gy = 0; gy < 3; gy++) {+ for (let gx = 0; gx < 4; gx++) {+ const dens = edgeDensity(g,+ img.width * gx / 4, img.height * 0.76 * gy / 3,+ img.width / 4, img.height * 0.76 / 3, 200, 100);+ if (dens > 0.003) busy++;+ }+ }+ bandScan.busyCells = busy;+ bandScan.status = 'done';+ } catch { bandScan.status = 'failed'; }+ scheduleLint();+ };+ img.onerror = () => { bandScan.status = 'failed'; scheduleLint(); };+ img.src = 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 */+let cropScan = { key: '', status: 'idle', cut: false, where: '' };+function scanCropEdges(vimg, arSlot) {+ const key = vimg.src.length + ':' + arSlot.toFixed(3);+ if (cropScan.key === key) return;+ cropScan = { key, status: 'pending', cut: false, where: '' };+ try {+ const g = document.createElement('canvas').getContext('2d');+ g._img = vimg;+ const iw = vimg.naturalWidth, ih = vimg.naturalHeight;+ const arImg = iw / ih;+ let cut = false, where = '';+ if (arImg > arSlot) {+ const visW = ih * arSlot;+ const cutL = (iw - visW) / 2;+ const strip = Math.max(8, iw * 0.02);+ if (edgeDensity(g, cutL, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = 'left'; }+ if (edgeDensity(g, cutL + visW - strip, 0, strip, ih, 24, 300) > 0.004) { cut = true; where = where ? 'left and right' : 'right'; }+ } else if (arImg < arSlot) {+ const visH = iw / arSlot;+ const cutT = (ih - visH) / 2;+ const strip = Math.max(8, ih * 0.02);+ if (edgeDensity(g, 0, cutT, iw, strip, 300, 24) > 0.004) { cut = true; where = 'top'; }+ if (edgeDensity(g, 0, cutT + visH - strip, iw, strip, 300, 24) > 0.004) { cut = true; where = where ? 'top and bottom' : 'bottom'; }+ }+ cropScan.cut = cut; cropScan.where = where; cropScan.status = 'done';+ } catch { cropScan.status = 'failed'; }+}++/* auto-fit (#40): the largest title size that keeps <=3 lines, positive+ headroom, and no clipping, measured with the REAL layout: sweep the CSS+ var coarse-to-fine, read the boxes, restore. Runs on the fit SSE event+ (drive verb + preset re-fit) and on headline edits; never in render mode. */+function solveTitleFit(maxCqw) {+ const tEl = $('el-title');+ if (!tEl || !tEl.offsetParent || document.body.dataset.render !== undefined) return null;+ const H = hero.getBoundingClientRect();+ if (H.width < 60) return null;+ const bandTop = H.top + H.height * 0.76;+ const measure = (v) => {+ hero.style.setProperty('--fs-title', v + 'cqw');+ void tEl.offsetHeight;+ const b = tEl.getBoundingClientRect();+ const lh = parseFloat(getComputedStyle(tEl).lineHeight) || 1;+ return { b, lines: Math.max(1, Math.round(b.height / lh)) };+ };+ const inHero = (b) => b.bottom < bandTop && b.right <= H.right + 1 && b.left >= H.left - 1 && b.top >= H.top - 1;+ const fits = (v) => { const m = measure(v); return m.lines <= 3 && inHero(m.b); };+ const fitsLoose = (v) => inHero(measure(v).b);+ const hi = Math.min(maxCqw || 13, 13);+ const sweep = (pred) => {+ let best = null;+ for (let v = hi; v >= 5 && best == null; v = Math.round((v - 0.5) * 10) / 10) {+ if (pred(v)) best = v;+ }+ if (best != null && best < hi) {+ for (let v = Math.min(hi, best + 0.4); v > best; v = Math.round((v - 0.1) * 10) / 10) {+ if (pred(v)) { best = v; break; }+ }+ }+ return best;+ };+ let best = sweep(fits);+ let loose = false;+ if (best == null) {+ // Nothing satisfies <=3 lines at any legible size: the headline itself is+ // the problem. Relax to the HARD constraints only (inside the hero, above+ // the band) so the pose is at least never broken, and say so.+ loose = true;+ best = sweep(fitsLoose);+ }+ const cur = (state.fontSize || {}).title || 8;+ hero.style.setProperty('--fs-title', cur + 'cqw');+ if (best == null) return null;+ hero.style.setProperty('--fs-title', best + 'cqw');+ void tEl.offsetHeight;+ const lines = Math.max(1, Math.round(tEl.getBoundingClientRect().height / (parseFloat(getComputedStyle(tEl).lineHeight) || 1)));+ hero.style.setProperty('--fs-title', cur + 'cqw');+ return { size: best, loose, lines };+}+function runFit(maxCqw) {+ const r = solveTitleFit(maxCqw);+ if (r == null) {+ handleToast({ text: 'fit: no title size in 5-13cqw keeps this headline inside the hero for this layout; shorten the headline or change layout', kind: 'warn', seq: Date.now() });+ return;+ }+ const cur = (state.fontSize || {}).title || 8;+ if (Math.abs(r.size - cur) >= 0.05) send({ fontSize: { title: r.size } });+ if (r.loose) {+ handleToast({ text: 'fit: this headline cannot make 3 lines at any legible size; set ' + r.size + 'cqw (' + r.lines + ' lines, still clear of the band). SHORTER COPY READS BIGGER: the title is the one message, aim for a few words', kind: 'warn', seq: Date.now() });+ } else if (Math.abs(r.size - cur) >= 0.05) {+ handleToast({ text: 'fit: title ' + r.size + 'cqw (largest size with <=3 lines and positive headroom for this headline + layout)', kind: 'info', seq: Date.now() });+ }+}++/* the stagebar (#21/#23): explicit dynamic on/off, keyboard D, legend off-stage */+$('sb-png').addEventListener('click', () => send({ marqueePreview: false }));+$('sb-mq').addEventListener('click', () => send({ marqueePreview: true }));+document.addEventListener('keydown', (e) => {+ if (e.key.toLowerCase() === 'd' && !e.target.closest('input,textarea')) {+ send({ marqueePreview: !state.marqueePreview });+ }+});++/* hold-to-hide the dynamic overlay (#16): press the band to see the pure+ export, release to bring the overlay back */+(() => {+ const mq = $('mqoverlay');+ const hero = $('hero');+ mq.addEventListener('pointerdown', (e) => { e.preventDefault(); hero.classList.add('mq-hold'); });+ document.addEventListener('pointerup', () => hero.classList.remove('mq-hold'));+ document.addEventListener('pointercancel', () => hero.classList.remove('mq-hold'));+})();++/* ---- page fields + Apply + Copy Changelog Prompt (#20) ----+ The human edits page-level fields at the gate; the change ledger on the+ server records everything (page fields AND pose deltas), Apply writes to+ the wiki or stashes for the first publish, and the prompt hands the whole+ session back to the AI that owns the package working copy. */+let pfEditing = false;+function renderPageFields() {+ if (pfEditing || !state) return;+ const d = pageByKey(state.page);+ if (!d) return;+ $('pf-title').value = d.title || '';+ $('pf-brief').value = d.brief || '';+ $('pf-tags').value = (d.tags || []).join(', ');+}+async function refreshChangesBadge() {+ try {+ const c = await (await fetch('changes')).json();+ const n = Object.keys(c.page || {}).length;+ $('pf-apply').textContent = 'Apply changes to wiki' + (n && !c.applied ? ' (' + n + ' unapplied)' : '');+ $('pf-apply').disabled = !n || !!c.applied;+ if (c.applied) $('pf-status').textContent = c.applied === 'wiki'+ ? 'Applied to the wiki. Still run Copy Changelog Prompt: the local package checkout needs syncing too.'+ : 'Stashed for the next publish (the page or edit route is not on the wiki yet); the changelog prompt carries the edits.';+ } catch {}+}+function pushPageField() {+ const [owner, slug] = (state.page || '/').split('/');+ const body = { owner, slug, title: $('pf-title').value.trim(), brief: $('pf-brief').value.trim(),+ tags: $('pf-tags').value.split(',').map(t => t.trim()).filter(Boolean) };+ post('api/pages', body)+ .then(() => fetch('api/pages')).then(r => r.json()).then(rows => { pages = rows; render(); refreshChangesBadge(); })+ .catch(() => {});+}+for (const id of ['pf-title', 'pf-brief', 'pf-tags']) {+ $(id).addEventListener('focus', () => { pfEditing = true; });+ $(id).addEventListener('blur', () => { pfEditing = false; pushPageField(); });+}+$('pf-apply').addEventListener('click', async () => {+ const r = await (await post('apply-page')).json().catch(() => ({}));+ if (!r.ok) { handleToast({ text: r.error || 'nothing to apply', kind: 'warn', seq: Date.now() }); return; }+ handleToast({+ text: r.applied === 'wiki' ? 'Page edits applied to the wiki (' + r.fields.join(', ') + ')'+ : 'Edits stashed for the next publish' + (r.http ? ' (wiki said HTTP ' + r.http + ')' : ''),+ kind: r.applied === 'wiki' ? 'success' : 'info', seq: Date.now(),+ });+ refreshChangesBadge();+});+$('pf-prompt').addEventListener('click', async () => {+ const c = await (await fetch('changes')).json().catch(() => null);+ if (!c) return;+ const lines = ['While reviewing the hero in Hero Studio I made these changes to ' + (c.ref || state.page) + ':'];+ const fmt = (v) => typeof v === 'string' ? '"' + v + '"' : JSON.stringify(v);+ for (const [f, ch] of Object.entries(c.page || {})) lines.push('- ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));+ for (const [f, ch] of Object.entries(c.pose || {})) lines.push('- pose ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));+ if (lines.length === 1) lines.push('- (no changes recorded this session)');+ lines.push('- Applied to wiki: ' + (c.applied === 'wiki' ? 'yes' : c.applied === 'stashed' ? 'no, stashed: please include in the next publish' : 'no: please include in the next publish'));+ lines.push('Please update the local package (page.json / package.json / README) to match.');+ const text = lines.join('\n');+ try {+ await navigator.clipboard.writeText(text);+ handleToast({ text: 'Changelog prompt copied: paste it into the AI session that owns the package', kind: 'success', seq: Date.now() });+ } catch {+ const ta = document.createElement('textarea');+ ta.value = text; document.body.appendChild(ta); ta.select();+ document.execCommand('copy'); ta.remove();+ handleToast({ text: 'Changelog prompt copied (fallback path)', kind: 'success', seq: Date.now() });+ }+});+setInterval(refreshChangesBadge, 3000);++/* zen focus (#12): the hero gets the whole surface */+$('btn-zen').addEventListener('click', () => {+ const on = document.body.classList.toggle('zen');+ $('btn-zen').classList.toggle('on', on);+ try { on ? document.documentElement.requestFullscreen() : document.exitFullscreen(); } catch {}+});+document.addEventListener('keydown', (e) => {+ if (e.key === 'Escape' && document.body.classList.contains('zen')) {+ document.body.classList.remove('zen');+ $('btn-zen').classList.remove('on');+ }+});++/* procedural circuitry background (seeded, gently different every roll) */+function mulberry32(a) {+ return function () {+ a |= 0; a = (a + 0x6D2B79F5) | 0;+ let t = Math.imul(a ^ (a >>> 15), 1 | a);+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;+ };+}+let lastCircuitKey = '';+function drawCircuitry() {+ const cv = $('circuitcanvas');+ const rect = hero.getBoundingClientRect();+ if (rect.width < 10) return;+ const key = state.circuitSeed + 'x' + Math.round(rect.width);+ if (key === lastCircuitKey) return;+ lastCircuitKey = key;+ const W = (cv.width = Math.round(rect.width * 2));+ const H = (cv.height = Math.round(rect.height * 2));+ const g = cv.getContext('2d');+ const rnd = mulberry32(state.circuitSeed || 1);+ g.clearRect(0, 0, W, H);+ const grid = Math.round(W / 46);+ const traces = 34;+ for (let i = 0; i < traces; i++) {+ const hue = rnd();+ g.strokeStyle = hue < 0.72 ? 'rgba(0,184,177,' + (0.05 + rnd() * 0.10).toFixed(3) + ')'+ : hue < 0.9 ? 'rgba(88,166,255,' + (0.05 + rnd() * 0.08).toFixed(3) + ')'+ : 'rgba(140,107,247,' + (0.05 + rnd() * 0.08).toFixed(3) + ')';+ g.lineWidth = rnd() < 0.85 ? 2 : 4;+ g.lineCap = 'round';+ let x = Math.round(rnd() * (W / grid)) * grid;+ let y = Math.round(rnd() * (H / grid)) * grid;+ g.beginPath();+ g.arc(x, y, g.lineWidth * 1.6, 0, Math.PI * 2);+ g.stroke();+ g.beginPath();+ g.moveTo(x, y);+ let dir = Math.floor(rnd() * 4); // 0 r, 1 d, 2 l, 3 u+ const segs = 3 + Math.floor(rnd() * 5);+ for (let sVar = 0; sVar < segs; sVar++) {+ const len = grid * (1 + Math.floor(rnd() * 5));+ // 45-degree bend half the time, PCB style+ if (rnd() < 0.5) dir = (dir + (rnd() < 0.5 ? 1 : 3)) % 4;+ const diag = rnd() < 0.4;+ const dx = dir === 0 ? len : dir === 2 ? -len : diag ? len * 0.7 : 0;+ const dy = dir === 1 ? len : dir === 3 ? -len : diag ? len * 0.7 : 0;+ x += dx; y += dy;+ g.lineTo(x, y);+ }+ g.stroke();+ g.beginPath();+ g.arc(x, y, g.lineWidth * 1.6, 0, Math.PI * 2);+ g.stroke();+ }+}+$('btn-reroll').addEventListener('click', () => { post('reroll').catch(() => {}); });++/* image-zone type sizes: instant local feedback, debounced send */+let fsTimer = null;+let hlTimer = null;+$('headline-input').addEventListener('input', e => {+ clearTimeout(hlTimer);+ const v = e.target.value;+ hlTimer = setTimeout(() => { send({ headline: v }); setTimeout(() => runFit(null), 250); }, 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;+ hero.style.setProperty('--' + id.replace('fs-', 'fs-'), v + 'cqw');+ hero.style.setProperty('--fs-' + key.replace('sub', 'sub').replace('desc', 'desc'), v + 'cqw');+ clearTimeout(fsTimer);+ fsTimer = setTimeout(() => send({ fontSize: { [key]: v } }), 150);+ });+}+$('ab-again').addEventListener('click', runGenerate);++/* owner brand controls */+$('brand-accent').addEventListener('input', e => send({ brand: { accent: e.target.value } }));+$('brand-bg').addEventListener('input', e => send({ brand: { bg: e.target.value } }));+$('brand-clear').addEventListener('click', () => send({ brand: { accent: '', bg: '' } }));++/* boot: load real pages, subscribe to the live channel */+Promise.all([+ fetch('api/pages').then(r => r.json()),+ fetch('api/components').then(r => r.json()),+ fetch('state?src=ref').then(r => r.json()),+]).then(([pg, cp, st]) => {+ pages = pg;+ comps = cp;+ state = st;+ seenToastSeq = (st._toast && st._toast.seq) || 0; // don't replay a stale toast on load+ seenGenSeq = (st._gen && st._gen.seq) || 0; // nor a stale generated modal+ const sel = $('sel-page');+ sel.innerHTML = pages.map(p =>+ '<option value="' + p.owner + '/' + p.slug + '">' + p.title + ' (' + p.type + ')</option>').join('');+ $('sel-comp').innerHTML = comps.map(c =>+ '<option value="' + c.id + '">' + c.name + (c.sample ? ' (sample)' : '') + '</option>').join('');+ render();+ console.log('hero-studio ready: ' + pages.length + ' live pages');+ if (location.hash) {+ const el = document.querySelector(location.hash);+ if (el) setTimeout(() => el.scrollIntoView({ block: 'start' }), 300);+ }+ const es = new EventSource('events');+ es.addEventListener('state', e => { state = JSON.parse(e.data); render(); });+ es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });+ es.addEventListener('toast', e => { handleToast(JSON.parse(e.data)); });+ es.addEventListener('generated', e => { handleGen(JSON.parse(e.data)); });+ // poll fallback in case the proxy buffers the event stream; the ETag+ // makes the idle second cost a 304 with no body instead of the full state+ let stateEtag = '';+ setInterval(() => {+ fetch('state?src=ref', { headers: stateEtag ? { 'If-None-Match': stateEtag } : {} }).then(r => {+ if (r.status === 304) return null;+ stateEtag = r.headers.get('ETag') || '';+ return r.json();+ }).then(st => {+ if (!st) return;+ handleToast(st._toast);+ handleGen(st._gen);+ if (JSON.stringify(st) !== JSON.stringify(state)) { state = st; render(); }+ }).catch(()=>{});+ }, 1000);+});
public/js/wizard.jsadded+503@@ -0,0 +1,503 @@+/* Hero Studio wizard (M4): the bottom-dock, one-decision-at-a-time chrome+ Colby picked over the panel. Runs as the DEFAULT surface; the classic+ panel stays one toggle away (header "Classic"). This file binds the+ mockup's proven interaction to the LIVE state: every choice is a send()+ patch, every render follows the same SSE loop the panel uses.+ Loads after app.js (classic scripts share top-level bindings). */++/* ---------- step engine ---------- */+let wizStep = 'package';+let wizExpanded = false; // seasoned checklist expanded back to one-by-one+let wizUploads = []; // this session's uploaded images (client-side gallery)+let wizFineTune = false;++function wp() { return pageByKey(state.page); }+function wSubText() { return state.subtitle || firstSentence(wp().brief, 80); }+function wSubWords() { return wSubText().split(/\s+/).filter(Boolean); }++const WIZ_LAYOUTS = [+ { v: 'bleed-bottom', label: 'Off the bottom' },+ { v: 'bleed-right', label: 'Off the right' },+ { v: 'bleed-top', label: 'Off the top' },+ { v: 'bleed-corner-br', label: 'Bottom-right corner' },+ { v: 'bleed-corner-tr', label: 'Top-right corner' },+ { v: 'contained', label: 'Floating inside' },+ { v: 'mirror', label: 'Mirrored (text right)' },+ { v: 'showcase', label: 'Backdrop' },+];+const WIZ_THEMES = [+ { v: 'midnight', label: 'Midnight' },+ { v: 'pcb', label: 'Circuit board' },+ { v: 'onion', label: 'Onion' },+ { v: 'pill', label: 'Pill' },+ { v: 'grid', label: 'Grid' },+ { v: 'gradient', label: 'Gradient' },+ { v: 'aurora', label: 'Aurora' },+ { v: 'mesh', label: 'Mesh' },+ { v: 'circuitry', label: 'Circuitry' },+ { v: 'image', label: 'Your image' },+];++const WIZ_STEPS = [+ {+ id: 'package', title: 'Package',+ hint: 'Which page this hero is for.',+ summary: () => (wp().title || state.page),+ options: () => pages.slice(0, 24).map(pg => ({ v: pg.owner + '/' + pg.slug, label: pg.title, sub: pg.owner + '/' + pg.slug })),+ get: () => state.page,+ set: (v) => { if (!state.verify) send({ page: v }); else handleToast({ text: 'The page is locked during a review gate.', kind: 'warn', seq: Date.now() }); },+ },+ {+ id: 'showcase', title: 'Showcase image',+ hint: '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'),+ 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' },+ ...wizUploads.map((u, i) => ({ v: 'up:' + i, label: u.name.length > 18 ? u.name.slice(0, 17) + '…' : u.name, sub: 'Uploaded image ' + (i + 1) })),+ { 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);+ return i >= 0 ? 'up:' + i : 'page';+ },+ set: (v) => {+ if (v === 'mock') { send({ 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:')) { send({ vitrine: { src: wizUploads[Number(v.slice(3))].data } }); }+ },+ },+ {+ id: 'placement', title: 'Placement',+ hint: 'Where the screenshot sits. Arrow keys work too.',+ summary: () => (WIZ_LAYOUTS.find(l => l.v === state.layout) || {}).label,+ options: () => WIZ_LAYOUTS.map(l => ({ v: l.v, label: l.label, pict: l.v })),+ get: () => state.layout,+ set: (v) => send({ layout: v }),+ },+ {+ id: 'background', title: 'Background',+ hint: 'The scene behind everything.',+ summary: () => (WIZ_THEMES.find(t => t.v === state.theme) || {}).label,+ options: () => WIZ_THEMES.map(t => ({ v: t.v, label: t.label })),+ get: () => state.theme,+ set: (v) => {+ if (v === 'image' && !(state.bgImage && state.bgImage.src)) {+ handleToast({ text: 'Pick a background file in Classic controls (bg section), or hero-studio bg --file <image>.', kind: 'info', seq: Date.now() });+ }+ send({ theme: v });+ },+ },+ {+ id: 'headline', title: 'Headline and text',+ hint: 'The words in your hero. Fewer words read bigger.',+ summary: () => state.headline || wp().title,+ custom: (host) => {+ const wrap = document.createElement('div');+ wrap.className = 'wiz-headline';+ const mk = (labText) => {+ const l = document.createElement('div');+ l.className = 'wiz-lab'; l.textContent = labText;+ return l;+ };+ const hin = document.createElement('input');+ hin.type = 'text'; hin.className = 'hin';+ hin.placeholder = wp().title + ' (the page title)';+ hin.value = state.headline || '';+ let ht = null;+ hin.addEventListener('input', () => {+ clearTimeout(ht);+ ht = setTimeout(() => { send({ headline: hin.value }); setTimeout(() => runFit(null), 300); }, 350);+ });+ const fitb = document.createElement('button');+ fitb.className = 'btn small'; fitb.textContent = 'Make it as big as fits';+ fitb.addEventListener('click', () => runFit(null));+ const sin = document.createElement('input');+ sin.type = 'text'; sin.className = 'hin';+ sin.placeholder = firstSentence(wp().brief, 80);+ sin.value = state.subtitle || '';+ let st = null;+ sin.addEventListener('input', () => {+ clearTimeout(st);+ st = setTimeout(() => {+ const patch = { subtitle: sin.value };+ if (Array.isArray(state.accentWords)) {+ const n = (sin.value || firstSentence(wp().brief, 80)).split(/\s+/).filter(Boolean).length;+ patch.accentWords = state.accentWords.filter(i => i < n);+ }+ send(patch);+ }, 350);+ });+ const pick = document.createElement('div');+ pick.className = 'wordpick';+ const renderPick = () => {+ pick.textContent = '';+ const on = new Set(Array.isArray(state.accentWords) ? state.accentWords : []);+ wSubWords().forEach((w, i) => {+ const t = document.createElement('span');+ t.className = 'wtok' + (on.has(i) ? ' on' : '');+ t.textContent = w;+ t.addEventListener('click', () => {+ const set = new Set(Array.isArray(state.accentWords) ? state.accentWords : []);+ set.has(i) ? set.delete(i) : set.add(i);+ send({ accentWords: [...set].sort((a, b) => a - b) });+ });+ pick.appendChild(t);+ });+ };+ renderPick();+ wrap.append(mk('Headline'), hin, fitb, mk('Text under it'), sin, mk('Tap words to color them'), pick);+ host.appendChild(wrap);+ },+ },+ {+ id: 'finishing', title: 'Finishing',+ hint: 'The small marks in the corners.',+ summary: () => ['pill', 'logo', 'slug', 'author'].filter(k => state.overlay[k]).join(' + ') || 'clean',+ custom: (host) => {+ const wrap = document.createElement('div');+ wrap.className = 'wiz-finish';+ const items = [+ ['pill', 'Type pill (top right)'],+ ['author', 'Author line'],+ ['slug', 'Wiki address (bottom right)'],+ ['logo', 'Adom wordmark stamp (full imports)'],+ ];+ items.forEach(([k, lab]) => {+ const c = document.createElement('div');+ c.className = 'card wide' + (state.overlay[k] ? ' on' : '');+ c.innerHTML = '<div class="card-label">' + lab + '</div><div class="card-sub">' + (state.overlay[k] ? 'Shown' : 'Hidden') + '</div>';+ c.addEventListener('click', () => send({ overlay: { [k]: !state.overlay[k] } }));+ wrap.appendChild(c);+ });+ host.appendChild(wrap);+ },+ },+ {+ id: 'review', title: 'Review and generate',+ hint: 'See it everywhere it will show up, then make it real.',+ review: true,+ summary: () => (wizAllConfirmed() ? 'ready' : 'pending'),+ custom: (host) => wizRenderReview(host),+ },+];++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+ && !Object.values(state.flags || {}).some(Boolean)+ && !(state.lint || []).some(l => l.level === 'fail');+}+function wizGoTo(id) {+ wizStep = id;+ wizExpanded = false;+ wizardRender();+ requestAnimationFrame(positionWizRing);+}++/* ---------- rail ---------- */+function wizRenderRail() {+ const rail = $('wsteprail');+ rail.textContent = '';+ WIZ_STEPS.forEach((s, i) => {+ const chip = document.createElement('div');+ const done = s.review ? wizAllConfirmed() : i < wizStepIndex(wizStep);+ chip.className = 'schip' + (s.id === wizStep ? ' active' : '') + (done ? ' done' : '');+ const n = document.createElement('span');+ n.className = 'n';+ n.innerHTML = done && s.id !== wizStep ? '<svg viewBox="0 0 24 24"><polyline points="5 13 10 18 19 7"/></svg>' : String(i + 1);+ const t = document.createElement('span');+ t.className = 't'; t.textContent = s.title;+ chip.append(n, t);+ const sum = s.summary();+ if (sum && s.id !== wizStep) {+ const v = document.createElement('span');+ v.className = 'v'; v.textContent = sum;+ chip.appendChild(v);+ }+ chip.addEventListener('click', () => wizGoTo(s.id));+ rail.appendChild(chip);+ });+ wizFitRail();+ const act = rail.querySelector('.schip.active');+ if (act) act.scrollIntoView({ inline: 'center', block: 'nearest' });+}+function wizFitRail() {+ const rail = $('wsteprail');+ const wrap = $('wrailwrap');+ rail.classList.remove('compact', 'mini');+ if (rail.scrollWidth > rail.clientWidth + 1) rail.classList.add('compact');+ if (rail.scrollWidth > rail.clientWidth + 1) rail.classList.add('mini');+ const upd = () => {+ wrap.classList.toggle('can-left', rail.scrollLeft > 2);+ wrap.classList.toggle('can-right', rail.scrollLeft + rail.clientWidth < rail.scrollWidth - 2);+ };+ upd();+ rail.onscroll = upd;+}+window.addEventListener('resize', () => { wizFitRail(); positionWizRing(); });+if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => wizFitRail());++/* ---------- edit ring: the dashed box around what this step edits ---------- */+const WIZ_EDIT_TARGETS = {+ showcase: () => (state.vitrine && state.vitrine.full ? $('hero') : hero.querySelector('.window')),+ placement: () => hero.querySelector('.window'),+ background: () => $('hero'),+ headline: () => hero.querySelector('.textzone'),+ finishing: () => $('pill-tr'),+};+function positionWizRing() {+ const ring = $('weditring');+ if (!ring || !document.body.classList.contains('wizard')) return;+ const getT = WIZ_EDIT_TARGETS[wizStep];+ const t = getT && getT();+ if (!t || (!t.offsetParent && t !== $('hero'))) { ring.classList.remove('on'); return; }+ const hb = $('hero').getBoundingClientRect();+ const tb = t.getBoundingClientRect();+ const pad = t === $('hero') ? -8 : 8;+ ring.style.left = (tb.left - hb.left - pad) + 'px';+ ring.style.top = (tb.top - hb.top - pad) + 'px';+ ring.style.width = (tb.width + pad * 2) + 'px';+ ring.style.height = (tb.height + pad * 2) + 'px';+ ring.classList.add('on');+}++/* ---------- review ---------- */+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: 'type', q: () => 'Page type matches (this page says "' + wireLabel(wp().type) + '")' },+];+function wizRenderReview(host) {+ const wrap = document.createElement('div');+ wrap.className = 'wiz-review';++ const views = document.createElement('div');+ views.className = 'rv-col';+ const vlab = document.createElement('div');+ vlab.className = 'wiz-lab'; vlab.textContent = 'See it as';+ const seg = document.createElement('div');+ seg.className = 'wseg';+ [{ v: false, label: 'Exactly my PNG' }, { v: true, label: 'Homepage preview' }].forEach(o => {+ const b = document.createElement('button');+ b.textContent = o.label;+ if (!!state.marqueePreview === o.v) b.classList.add('on');+ b.addEventListener('click', () => send({ marqueePreview: o.v }));+ seg.appendChild(b);+ });+ const legend = document.createElement('div');+ legend.className = 'wiz-legend';+ legend.textContent = 'Amber-outlined = the homepage adds it, it is not in your PNG. Press D to flip.';+ views.append(vlab, seg, legend);++ const checks = document.createElement('div');+ checks.className = 'rv-col';+ const seasoned = (state.gatesDone || 0) >= 4;+ if (seasoned && !wizExpanded) {+ const row = document.createElement('div');+ row.className = 'rv-check ok';+ const q = document.createElement('span');+ q.className = 'q';+ q.textContent = 'You’ve done this ' + state.gatesDone + ' times, so the checks are pre-ticked. You saw every step on the way here.';+ const ex = document.createElement('button');+ ex.className = 'btn small';+ ex.textContent = 'Check one by one';+ ex.addEventListener('click', () => {+ wizExpanded = true;+ send({ confirm: { title: false, subtitle: false, vitrine: 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)) {+ send({ confirm: { title: true, subtitle: true, vitrine: true, type: true } });+ }+ } else {+ const allb = document.createElement('button');+ allb.className = 'btn small';+ allb.textContent = 'All of it looks good';+ allb.addEventListener('click', () => send({+ confirm: { title: true, subtitle: true, vitrine: true, type: true },+ flags: { title: false, subtitle: false, vitrine: false, type: false },+ }));+ checks.appendChild(allb);+ WIZ_CHECKS.forEach(c => {+ const row = document.createElement('div');+ row.className = 'rv-check' + (state.confirm[c.key] ? ' ok' : '') + (state.flags[c.key] ? ' flag' : '');+ const q = document.createElement('span');+ q.className = 'q'; q.textContent = typeof c.q === 'function' ? c.q() : c.q;+ const ok = document.createElement('button');+ ok.className = 'btn small';+ ok.textContent = 'Looks good';+ ok.addEventListener('click', () => send({ confirm: { [c.key]: true }, flags: { [c.key]: false } }));+ const fl = document.createElement('button');+ fl.className = 'btn small';+ fl.textContent = 'Needs fixing';+ fl.addEventListener('click', () => send({ flags: { [c.key]: true }, confirm: { [c.key]: false } }));+ row.append(q, ok, fl);+ checks.appendChild(row);+ });+ }++ const act = document.createElement('div');+ act.className = 'rv-col';+ const lintBox = document.createElement('div');+ const fails = (state.lint || []).filter(l => l.level === 'fail').length;+ const warns = (state.lint || []).filter(l => l.level === 'warn').length;+ lintBox.innerHTML = '<div class="wiz-lab">Layout lint</div>' + (+ fails ? '<div class="lint-item lint-fail">' + fails + ' hard problem(s): Generate stays locked. Details in the classic lint box.</div>'+ : warns ? '<div class="lint-item lint-warn">' + warns + ' warning(s). The studio measures; you decide.</div>'+ : '<div class="lint-item lint-ok">No layout findings.</div>');+ act.append(lintBox);++ wrap.append(views, checks, act);+ host.appendChild(wrap);+}++/* ---------- confetti: the finale earns it ---------- */+function wizConfetti(fromEl) {+ const c = document.createElement('canvas');+ c.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999';+ c.width = innerWidth; c.height = innerHeight;+ document.body.appendChild(c);+ const g = c.getContext('2d');+ const b = fromEl.getBoundingClientRect();+ const ox = b.left + b.width / 2, oy = b.top + b.height / 2;+ const colors = ['#00b8b0', '#7a5cff', '#3fb950', '#e6edf3', '#37d6cd'];+ const parts = Array.from({ length: 140 }, (_, i) => ({+ x: ox, y: oy,+ vx: Math.cos(i * 2.399) * (2 + (i % 7)) * (0.6 + (i % 5) / 5),+ vy: Math.sin(i * 2.399) * (2 + (i % 7)) * (0.6 + (i % 5) / 5) - 6,+ s: 3 + (i % 4), r: i * 0.7, vr: ((i % 9) - 4) * 0.12,+ col: colors[i % colors.length],+ }));+ let t = 0;+ (function tick() {+ t++;+ g.clearRect(0, 0, c.width, c.height);+ for (const p of parts) {+ p.x += p.vx; p.y += p.vy; p.vy += 0.22; p.vx *= 0.99; p.r += p.vr;+ g.save(); g.translate(p.x, p.y); g.rotate(p.r);+ g.fillStyle = p.col; g.globalAlpha = Math.max(0, 1 - t / 90);+ g.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * 0.6);+ g.restore();+ }+ if (t < 95) requestAnimationFrame(tick);+ else c.remove();+ })();+}+window.wizardOnGenerated = () => { const b = $('wbtn-generate'); if (b) wizConfetti(b); };++/* ---------- dock render ---------- */+function wizCard(o, on, onPick) {+ const c = document.createElement('div');+ c.className = 'card' + (on ? ' on' : '') + (o.sub && o.sub.length > 24 ? ' wide' : '');+ c.innerHTML = '<div class="card-label"></div>' + (o.sub ? '<div class="card-sub"></div>' : '');+ c.querySelector('.card-label').textContent = o.label;+ if (o.sub) c.querySelector('.card-sub').textContent = o.sub;+ c.addEventListener('click', onPick);+ return c;+}+function wizardRender() {+ if (!document.body.classList.contains('wizard')) return;+ if (!state || !pages.length || !$('wdock')) return;+ const s = wizActive();+ wizRenderRail();+ $('wintro-ref').textContent = state.page;+ $('wd-title').textContent = (wizStepIndex(wizStep) + 1) + '. ' + s.title;+ $('wd-hint').textContent = s.hint;+ const next = WIZ_STEPS[wizStepIndex(wizStep) + 1];+ $('wbtn-next').textContent = next ? 'Next: ' + next.title : 'Done';+ $('wbtn-next').style.display = next && !s.review ? '' : 'none';+ const gbtn = $('wbtn-generate');+ gbtn.style.display = s.review ? '' : 'none';+ if (s.review) {+ gbtn.disabled = !wizAllConfirmed();+ gbtn.title = gbtn.disabled ? 'Confirm the four checks first' : '';+ }++ const cards = $('wcards');+ cards.textContent = '';+ const showArrows = !!s.options;+ $('warr-l').style.visibility = showArrows ? 'visible' : 'hidden';+ $('warr-r').style.visibility = showArrows ? 'visible' : 'hidden';+ if (s.options) {+ const opts = s.options();+ const cur = s.get();+ const idx = opts.findIndex(o => o.v === cur);+ $('wd-count').textContent = (Math.max(0, idx) + 1) + ' of ' + opts.length;+ opts.forEach(o => cards.appendChild(wizCard(o, o.v === cur, () => { s.set(o.v); })));+ const on = cards.querySelector('.card.on');+ if (on) on.scrollIntoView({ inline: 'center', block: 'nearest' });+ } else {+ $('wd-count').textContent = '';+ if (s.custom) s.custom(cards);+ }+ requestAnimationFrame(positionWizRing);+}++/* arrows cycle option steps; left/right keys too */+function wizCycle(dir) {+ const s = wizActive();+ if (!s.options) return;+ const opts = s.options();+ const i = opts.findIndex(o => o.v === s.get());+ const n = opts[(i + dir + opts.length) % opts.length];+ s.set(n.v);+}+$('warr-l').addEventListener('click', () => wizCycle(-1));+$('warr-r').addEventListener('click', () => wizCycle(1));+$('wbtn-next').addEventListener('click', () => {+ const next = WIZ_STEPS[wizStepIndex(wizStep) + 1];+ if (next) wizGoTo(next.id);+});+$('wbtn-generate').addEventListener('click', () => {+ if ($('wbtn-generate').disabled) return;+ runGenerate();+});+document.addEventListener('keydown', (e) => {+ if (!document.body.classList.contains('wizard')) return;+ if (e.target.closest('input,textarea')) return;+ if (e.key === 'ArrowLeft') wizCycle(-1);+ if (e.key === 'ArrowRight') wizCycle(1);+});++/* multi-upload rides the existing file input; keep a session gallery */+$('vitrine-file').setAttribute('multiple', '');+$('vitrine-file').addEventListener('change', () => {+ if (!document.body.classList.contains('wizard')) return;+ const files = [...$('vitrine-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]) {+ send({ vitrine: { src: r.result } });+ }+ };+ r.readAsDataURL(f);+ });+});++/* classic toggle: the old panel is one click away, never gone */+$('wiz-classic').addEventListener('click', () => {+ const toClassic = document.body.classList.contains('wizard');+ document.body.classList.toggle('wizard', !toClassic);+ document.body.classList.toggle('classic', toClassic);+ $('wiz-classic').textContent = toClassic ? 'Wizard' : 'Classic';+ if (!toClassic) wizardRender();+});++/* boot: wizard is the default surface; a pose (verify) starts at Showcase+ since Package is locked. Component pages ride the same wizard for now+ (Colby, 2026-08-13); their extra image options stay in Classic. */+if (document.body.dataset.render === undefined) document.body.classList.add('wizard');+setTimeout(() => {+ if (state && state.verify) wizStep = 'showcase';+ wizardRender();+}, 400);
public/mock-sizes.htmladded+44@@ -0,0 +1,44 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<title>Wizard mockup size harness</title>+<style>+body { background:#0d1117; color:#e6edf3; font-family:sans-serif; margin:0; padding:12px; display:flex; flex-wrap:wrap; gap:14px; }+.slot { border:1px solid #30363d; border-radius:8px; overflow:hidden; background:#000; position:relative; }+.slot .tag { position:absolute; left:6px; top:6px; z-index:2; font-size:11px; font-family:monospace; color:#e8a13c;+ background:rgba(5,9,14,.85); border:1px solid rgba(232,161,60,.5); border-radius:5px; padding:2px 7px; }+iframe { border:0; transform-origin:top left; display:block; }+</style>+</head>+<body>+<script>+'use strict';+const TARGET_W = 590;+const SIZES = [+ { w:1440, h:900, q:'?step=placement' },+ { w:1152, h:720, q:'?step=background&theme=circuitry&ft=1' },+ { w:1024, h:640, q:'?step=review&confirm=1' },+ { w:900, h:600, q:'?step=headline&ft=1' },+ { w:760, h:560, q:'?step=package' },+ { w:640, h:520, q:'?step=showcase' },+];+for (const s of SIZES) {+ const scale = TARGET_W / s.w;+ const slot = document.createElement('div');+ slot.className = 'slot';+ slot.style.width = (s.w * scale) + 'px';+ slot.style.height = (s.h * scale) + 'px';+ const tag = document.createElement('span');+ tag.className = 'tag';+ tag.textContent = s.w + ' x ' + s.h + ' ' + s.q;+ const f = document.createElement('iframe');+ f.src = 'mock.html' + s.q;+ f.width = s.w; f.height = s.h;+ f.style.transform = 'scale(' + scale + ')';+ slot.append(tag, f);+ document.body.appendChild(slot);+}+</script>+</body>+</html>
public/mock.htmladded+1448@@ -0,0 +1,1448 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Hero Studio wizard mockup</title>+<style>+@font-face { font-family: 'Familjen Grotesk'; src: url('fonts/familjen-grotesk-700.woff2') format('woff2'); font-weight: 700; font-display: swap; }+@font-face { font-family: 'Familjen Grotesk'; src: url('fonts/familjen-grotesk-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('fonts/satoshi-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }+@font-face { font-family: 'Satoshi'; src: url('fonts/satoshi-500.woff2') format('woff2'); font-weight: 500; font-display: swap; }+@font-face { font-family: 'JetBrains Mono'; src: url('fonts/jetbrains-mono-400.woff2') format('woff2'); font-weight: 400; font-display: swap; }++:root {+ --bg:#0d1117; --surface:#161b22; --elevated:#1c2128; --overlay:#21262d; --border:#30363d;+ --text:#e6edf3; --text-2:#8b949e; --text-3:#484f58;+ --accent:#00b8b0; --accent-bright:#00e6dc; --purple:#8c6bf7; --blue:#58a6ff;+ --green:#3fb950; --red:#f85149; --yellow:#d29922;+ --grad:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7);+}+* { box-sizing:border-box; margin:0; padding:0; }+html,body { height:100%; }+body {+ background:var(--bg); color:var(--text);+ font-family:'Satoshi',sans-serif; font-size:14px;+ display:flex; flex-direction:column; overflow:hidden;+}++/* ---------- 44px header ---------- */+header {+ height:44px; flex:0 0 44px; display:flex; align-items:center; gap:12px;+ padding:0 12px; background:var(--surface); border-bottom:1px solid var(--border);+}+.brand { display:flex; align-items:center; gap:8px; white-space:nowrap; }+.brand svg { width:16px; height:16px; stroke:var(--text); }+.brand .name { font-family:'Familjen Grotesk'; font-weight:500; font-size:14px; color:var(--accent); }+.brand .sub { color:var(--text-2); font-size:13px; }+.mocktag {+ font-family:'JetBrains Mono',monospace; font-size:10px; letter-spacing:.1em; color:var(--yellow);+ border:1px solid rgba(210,153,34,.5); background:rgba(210,153,34,.1); border-radius:6px; padding:2px 7px;+}+.vsep { width:1px; height:22px; background:var(--border); flex:0 0 1px; }+.subject { display:flex; align-items:baseline; gap:10px; min-width:0; }+.subject b { font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }+.subject .meta { font-family:'JetBrains Mono',monospace; font-size:11.5px; color:var(--text-2); white-space:nowrap; }+.actions { margin-left:auto; display:flex; gap:6px; }+.iconbtn {+ width:30px; height:30px; border:1px solid var(--border); border-radius:8px; background:transparent;+ display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all .15s ease;+}+.iconbtn svg { width:16px; height:16px; stroke:var(--text); fill:none; stroke-width:1.8; }+.iconbtn:hover { background:var(--elevated); border-color:var(--text-3); }+.iconbtn.on { border-color:var(--accent); background:rgba(0,184,176,.08); }+@media (max-width:720px){ .subject .meta { display:none; } }++/* ---------- stage ---------- */+main { flex:1; display:flex; flex-direction:column; min-height:0; }+#stage { flex:1; display:flex; align-items:center; justify-content:center; padding:16px 22px; min-height:0; position:relative; }+.hero {+ --inset:4.5cqw; --badge-inset:2.6cqw;+ aspect-ratio:16/10; position:relative; overflow:hidden;+ border-radius:12px; border:1px solid var(--border); container-type:inline-size;+ --ga:155deg;+}+#hero { flex:0 0 auto; box-shadow:0 24px 70px rgba(0,0,0,.5); }++.bg { position:absolute; inset:0; }+.hero[data-layout=mirror] .bg { transform:scaleX(-1); }+.hero[data-theme=midnight] .bg {+ background:+ radial-gradient(1200px 800px at 88% -10%, rgba(0,184,177,.22), transparent 60%),+ radial-gradient(900px 700px at -5% 110%, rgba(140,107,247,.18), transparent 55%),+ linear-gradient(var(--ga),#0a0e14 0%,#0d1117 45%,#071318 100%);+}+.hero[data-theme=midnight] .bg::after {+ content:''; position:absolute; inset:0;+ background-image:radial-gradient(rgba(255,255,255,.05) 1px, transparent 1.4px);+ background-size:2.2cqw 2.2cqw;+ -webkit-mask-image:radial-gradient(700px 560px at 30% 30%, #000, transparent 75%);+ mask-image:radial-gradient(700px 560px at 30% 30%, #000, transparent 75%);+}+.hero[data-theme=pcb] .bg {+ background:+ radial-gradient(1100px 760px at 85% -8%, rgba(0,230,220,.14), transparent 60%),+ linear-gradient(var(--ga),#031b19 0%,#04302c 55%,#02211e 100%);+}+.hero[data-theme=pcb] .bg::after {+ content:''; position:absolute; inset:0; opacity:.5;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160"><g stroke="%2300e6dc" stroke-opacity=".09" fill="none" stroke-width="1.4"><path d="M10 20 h56 l18 18 v50"/><path d="M84 150 v-36 l22 -22 h44"/><path d="M20 140 v-48 l14 -14"/><circle cx="10" cy="20" r="3.2"/><circle cx="84" cy="88" r="3.2"/><circle cx="150" cy="92" r="3.2"/><circle cx="20" cy="140" r="3.2"/><circle cx="34" cy="78" r="3.2"/></g></svg>');+ background-size:10cqw 10cqw;+}+.hero[data-theme=onion] .bg {+ background:+ radial-gradient(900px 700px at -5% 112%, rgba(140,107,247,.16), transparent 55%),+ linear-gradient(var(--ga),#0b1322 0%,#101a2c 55%,#0a111e 100%);+}+.hero[data-theme=onion] .bg::after {+ content:''; position:absolute; inset:0;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="1" x2="1" y2="0"><stop offset="0" stop-color="%238c6bf7"/><stop offset=".55" stop-color="%2339b8ff"/><stop offset="1" stop-color="%2300e6dc"/></linearGradient></defs><g fill="none" stroke="url(%23g)" stroke-width="4" opacity=".5"><path d="M356 400 V234 Q356 60 200 60 Q44 60 44 234 V400"/><path d="M312 400 V250 Q312 110 200 110 Q88 110 88 250 V400"/><path d="M268 400 V266 Q268 160 200 160 Q132 160 132 266 V400"/><path d="M224 400 V282 Q224 210 200 210 Q176 210 176 282 V400"/></g></svg>');+ background-repeat:no-repeat;+ background-position:right -7cqw bottom -9cqw;+ background-size:50cqw auto;+}+.hero[data-theme=pill] .bg {+ background:+ radial-gradient(1000px 720px at 90% -8%, rgba(0,184,177,.12), transparent 60%),+ linear-gradient(var(--ga),#082a29 0%,#0c3a3c 55%,#062220 100%);+}+.hero[data-theme=pill] .bg::after {+ content:''; position:absolute; inset:0;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="260" height="200"><g fill="none" stroke-width="2"><rect x="16" y="22" width="26" height="62" rx="13" stroke="%2364ABFF" stroke-opacity=".3"/><circle cx="112" cy="38" r="12" stroke="%2300e6dc" stroke-opacity=".28"/><rect x="196" y="14" width="28" height="72" rx="14" stroke="%2339b8ff" stroke-opacity=".3"/><rect x="84" y="128" width="62" height="26" rx="13" stroke="%2300b8b1" stroke-opacity=".28"/><circle cx="226" cy="150" r="10" stroke="%238c6bf7" stroke-opacity=".26"/><rect x="20" y="150" width="26" height="40" rx="13" stroke="%2300e6dc" stroke-opacity=".2"/></g></svg>');+ background-size:16cqw auto;+}+.hero[data-theme=grid] .bg {+ background:+ radial-gradient(1000px 700px at 85% -10%, rgba(0,230,220,.10), transparent 60%),+ linear-gradient(var(--ga),#07302e 0%,#0c3a3c 55%,#052220 100%);+}+.hero[data-theme=grid] .bg::after {+ content:''; position:absolute; inset:0; opacity:.55;+ background-image:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><rect x="4" y="4" width="18" height="18" fill="none" stroke="%23ffffff" stroke-opacity=".13" stroke-width="1.3"/></svg>');+ background-size:2.4cqw 2.4cqw;+}+.hero[data-theme=gradient] .bg {+ background:+ linear-gradient(155deg, rgba(5,10,18,.42), rgba(5,10,18,.14) 55%, rgba(5,10,18,.5)),+ linear-gradient(var(--ga),#00c8bf 0%,#2f9dfa 55%,#8c6bf7 100%);+}+.hero[data-theme=aurora] .bg {+ background:+ radial-gradient(90cqw 60cqw at 12% -10%, rgba(0,184,177,.28), transparent 60%),+ radial-gradient(70cqw 55cqw at 95% 20%, rgba(88,101,242,.22), transparent 65%),+ radial-gradient(80cqw 70cqw at 55% 115%, rgba(140,107,247,.20), transparent 60%),+ #0a0f16;+}+.hero[data-theme=mesh] .bg {+ background:+ conic-gradient(from var(--ga) at 28% 30%, rgba(0,184,177,.16), transparent 32%),+ conic-gradient(from calc(var(--ga) + 120deg) at 75% 65%, rgba(88,166,255,.13), transparent 36%),+ conic-gradient(from calc(var(--ga) + 240deg) at 45% 90%, rgba(140,107,247,.12), transparent 30%),+ #0b1016;+}+.hero[data-theme=circuitry] .bg { background:#080d13; }+#circuitcanvas { display:none; position:absolute; inset:0; width:100%; height:100%; z-index:0; }+.hero[data-theme=circuitry] #circuitcanvas { display:block; }+.hero[data-theme=image] .bg { background-color:#0a0e14; background-image:var(--bgimg, none); background-repeat:repeat; background-size:var(--bgtile, 22cqw); }+.hero[data-theme=image].bg-cover .bg { background-repeat:no-repeat; background-size:cover; background-position:center; }++/* text column */+.textzone { position:absolute; left:var(--inset); top:var(--inset); width:34cqw; z-index:3; padding-right:1.5cqw; }+.hero[data-layout=mirror] .textzone { left:auto; right:var(--inset); }+/* Fixed anchors: the logo's top-left sits at the inset corner and the+ headline's top-left sits exactly one logo slot below it, on every hero,+ at every font size. The slot is reserved even when no logo renders. */+.wordmark { display:flex; align-items:flex-start; height:1.72cqw; margin-bottom:3.2cqw; }+.wordmark .wmark {+ display:block; width:7.8cqw; height:1.72cqw; background:#e6edf3;+ -webkit-mask:url('adom-logo.svg') no-repeat left top / contain;+ mask:url('adom-logo.svg') no-repeat left top / contain;+}+.wordmark.noadom { visibility:hidden; }+.h-title {+ font-family:'Familjen Grotesk'; font-weight:700; font-size:var(--fs-title, 8cqw); line-height:1.04;+ letter-spacing:-.14cqw; color:#e6edf3; margin-bottom:calc(1.8cqw * var(--fs-gap, 1)); max-width:30cqw;+ /* Colby 2026-08-12: the title's INK top-left never moves, at any font size.+ line-height is FIXED (no leading control), and cap trimming removes the+ size-dependent gap between the box top and the capital letters, so the+ first cap always sits exactly at the anchor. */+ text-box-trim: trim-start;+ text-box-edge: cap alphabetic;+}+.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; }+.h-desc { font-size:var(--fs-desc, 1.35cqw); line-height:1.5; color:#8b949e; max-width:30cqw; }+.hero.hide-logo .wordmark { visibility:hidden; }+.hero.hide-title .h-title, .hero.hide-sub .h-sub, .hero.hide-desc .h-desc { display:none; }++/* app window */+.window {+ position:absolute; z-index:2; border-radius:.9cqw .9cqw 0 0; overflow:hidden;+ background:#10151c; border:1px solid rgba(255,255,255,.09);+ box-shadow:0 1.7cqw 5cqw rgba(0,0,0,.62), 0 0 8cqw rgba(0,184,177,.07);+ display:flex; flex-direction:column;+}+.hero[data-layout=bleed-bottom] .window { right:4.5cqw; top:6cqw; width:56cqw; height:64cqw; }+.hero[data-layout=bleed-right] .window { right:-8cqw; top:6cqw; width:56cqw; height:44cqw; border-radius:.9cqw 0 0 .9cqw; }+.hero[data-layout=bleed-top] .window { right:4.5cqw; top:-6cqw; width:52cqw; height:42cqw; border-radius:0 0 .9cqw .9cqw; }+.hero[data-layout=bleed-corner-br] .window { right:-7cqw; top:14cqw; width:56cqw; height:52cqw; border-radius:.9cqw 0 0 0; }+.hero[data-layout=bleed-corner-tr] .window { right:-7cqw; top:-6cqw; width:56cqw; height:44cqw; border-radius:0 0 0 .9cqw; }+.hero[data-layout=contained] .window { right:4.5cqw; top:6cqw; width:50cqw; height:33cqw; border-radius:.9cqw; }+.hero[data-layout=mirror] .window { left:4.5cqw; top:6cqw; width:56cqw; height:64cqw; }+.hero[data-layout=showcase] .window { right:-4cqw; top:4cqw; width:66cqw; height:58cqw; border-radius:.9cqw 0 0 .9cqw; }+.hero[data-layout=showcase]::after {+ content:''; position:absolute; inset:0; z-index:2; pointer-events:none;+ background:linear-gradient(100deg, rgba(5,9,14,.88) 0%, rgba(5,9,14,.55) 30%, rgba(5,9,14,0) 55%);+}+.hero[data-layout=showcase] .textzone { z-index:3; width:30cqw; }+.titlebar {+ flex:0 0 2.1cqw; background:#181f28; border-bottom:1px solid rgba(255,255,255,.06);+ display:flex; align-items:center; padding:0 .9cqw; gap:.6cqw;+}+.titlebar .t { font-family:'Satoshi'; font-size:.82cqw; color:#8b949e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }+.winbtns { margin-left:auto; display:flex; gap:.9cqw; align-items:center; }+.winbtns svg { width:.72cqw; height:.72cqw; stroke:#8b949e; stroke-width:2; fill:none; }+.appbody { flex:1; display:flex; min-height:0; position:relative; }+.vitrine-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.has-vitrine .vitrine-img { display:block; }+.hero.no-chrome .window .titlebar { display:none; }+.hero.bare-chrome .window .titlebar { display:none; }+.hero.bare-chrome .window { background:transparent; border:none; box-shadow:none; border-radius:0; }+.sidebar { flex:0 0 11cqw; background:#131920; border-right:1px solid rgba(255,255,255,.05); padding:.8cqw .7cqw; }+.srow { height:1.5cqw; border-radius:.35cqw; background:rgba(255,255,255,.05); margin-bottom:.55cqw; }+.srow.on { background:rgba(0,184,176,.18); border:1px solid rgba(0,184,176,.35); }+.content { flex:1; padding:1cqw 1.1cqw; display:flex; flex-direction:column; gap:.8cqw; }+.crow { display:flex; gap:.6cqw; align-items:center; }+.chipk { height:1.3cqw; border-radius:999px; background:rgba(255,255,255,.07); }+.bars { flex:1; display:flex; align-items:flex-end; gap:.5cqw; min-height:0; }+.bar { flex:1; border-radius:.3cqw .3cqw 0 0; background:linear-gradient(180deg, rgba(0,230,220,.55), rgba(0,184,176,.12)); }+.tbl { display:flex; flex-direction:column; gap:.45cqw; }+.trow { display:flex; gap:.5cqw; }+.tcell { height:1.05cqw; border-radius:.25cqw; background:rgba(255,255,255,.06); }++/* owner brand */+#hero.branded { background: var(--brand-bg, #0d1117) !important; }+#hero.branded .bar { background: linear-gradient(180deg,+ color-mix(in srgb, var(--brand-accent, #00e6dc) 55%, transparent),+ color-mix(in srgb, var(--brand-accent, #00b8b0) 12%, transparent)); }+#hero.branded .h-sub .accent { background: linear-gradient(90deg,+ var(--brand-accent, #00e6dc),+ color-mix(in srgb, var(--brand-accent, #00e6dc) 45%, #fff));+ -webkit-background-clip:text; background-clip:text; }++/* badges + homepage preview overlay */+.pill-tr { position:absolute; top:var(--badge-inset); right:var(--badge-inset); z-index:6; display:flex; gap:.5cqw; }+.slug-br { position:absolute; right:var(--badge-inset); bottom:var(--badge-inset); z-index:6; }+.pill {+ font-family:'Familjen Grotesk'; font-weight:700; font-size:.85cqw; letter-spacing:.14em;+ padding:.3cqw .85cqw; border-radius:999px; border:1px solid; text-transform:uppercase;+}+.pill.app { color:#00e6dc; border-color:rgba(0,230,220,.45); background:rgba(0,184,176,.10); }+.pill.skill { color:#b39dfb; border-color:rgba(140,107,247,.5); background:rgba(140,107,247,.12); }+.pill.component { color:#e3b341; border-color:rgba(210,153,34,.5); background:rgba(210,153,34,.12); }+.pill-tr.scrim .pill { background:rgba(8,12,18,.72); backdrop-filter:blur(5px); box-shadow:0 .2cqw 1cqw rgba(0,0,0,.5); }+.l-slug { font-family:'JetBrains Mono',monospace; font-size:.95cqw; color:#00b8b1; text-decoration:none; }+#mqoverlay { display:none; position:absolute; left:0; right:0; bottom:0; z-index:5;+ padding:3cqw 3cqw 2.2cqw;+ background:linear-gradient(180deg, rgba(5,9,14,0) 0%, rgba(5,9,14,.55) 35%, rgba(5,9,14,.9) 100%); }+.hero.show-mq #mqoverlay { display:block; animation:mqbreathe 3.4s ease-in-out infinite;+ outline:2px dashed rgba(232,161,60,.85); outline-offset:-2px; background-color:rgba(232,161,60,.045); }+@keyframes mqbreathe { 0%,100% { opacity:1; } 50% { opacity:.5; } }+#mqoverlay .mqo-badges { display:flex; gap:1cqw; align-items:center; margin-bottom:.9cqw; }+#mqoverlay .mqo-stat { font-family:'JetBrains Mono',monospace; font-size:1cqw; color:#fff;+ background:rgba(5,9,14,.72); padding:.2cqw .9cqw; border-radius:1cqw; }+#mqoverlay .mqo-title { font-family:'Familjen Grotesk'; font-weight:700; font-size:3.2cqw; color:#fff; margin:0 0 .5cqw; }+#mqoverlay .mqo-brief { font-size:1.5cqw; color:#c9d1d9; margin:0 0 .7cqw; max-width:60cqw; }+#mqoverlay .mqo-owner { font-family:'JetBrains Mono',monospace; font-size:1.1cqw; color:#00b8b1; }+.guide { position:absolute; left:0; right:0; top:66.6%; z-index:6; border-top:1.5px dashed rgba(0,230,220,.55); pointer-events:none; display:none; }+.guide span {+ position:absolute; right:1cqw; top:.4cqw; font-family:'JetBrains Mono',monospace; font-size:.8cqw;+ color:#00e6dc; background:rgba(10,14,20,.7); padding:.2cqw .6cqw; border-radius:.35cqw;+}+.hero.guides .guide { display:block; }+.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; }++/* ---------- review context views ---------- */+#ctxview { display:none; width:100%; max-width:980px; max-height:100%; overflow:auto; }+#ctxview.show { display:block; }+#stage.ctxmode #hero { display:none; }+.ctxframe { background:#0d1117; border:1px solid var(--border); border-radius:10px; padding:14px; }+.page-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:16px; }+.page-card { position:relative; display:flex; flex-direction:column; color:#e6edf3; overflow:hidden; background:#161b22; border:1px solid #30363d; border-radius:6px; }+.page-card-hero { aspect-ratio:16/10; background:#1c2128; display:flex; align-items:center; justify-content:center; overflow:hidden; border-bottom:1px solid #30363d; position:relative; }+.page-card-body { padding:16px; display:flex; flex-direction:column; gap:6px; flex:1; }+.page-card-title { font-size:15px; font-weight:600; line-height:1.3; margin:0; }+.page-card-brief { color:#8b949e; font-size:13px; margin:0; }+.page-card-meta { font-size:12px; color:#484f58; display:flex; align-items:center; gap:4px; margin-top:auto; }+.ts-hero-fallback { width:100%; height:100%; display:flex; align-items:center; justify-content:center; padding:32px; text-align:center; background:linear-gradient(135deg,#0f766e,#1d4ed8 60%,#6d28d9); font-size:22px; font-weight:700; color:#fff; font-family:'Familjen Grotesk'; }+.deskmock { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:10px; display:flex; flex-direction:column; gap:10px; max-width:420px; margin:0 auto; }+.deskhead { font-family:'Familjen Grotesk'; font-weight:500; font-size:11px; letter-spacing:.07em; text-transform:uppercase; color:var(--text-2); }+.clonewrap .hero { width:100%; border-radius:8px; box-shadow:none; }+.clonewrap .hero .guide { display:none !important; }+.page-card-hero .clonewrap { position:absolute; inset:0; }+.page-card-hero .clonewrap .hero { border-radius:0; border:none; }++/* ---------- the wizard dock ---------- */+#dock { flex:0 0 auto; background:var(--surface); border-top:1px solid var(--border); padding:10px 18px 14px; display:flex; flex-direction:column; gap:10px;+ height:min(48vh, 360px); overflow-y:auto; scrollbar-width:thin; }+#railwrap { position:relative; min-width:0; }+#railwrap::before, #railwrap::after { content:''; position:absolute; top:0; bottom:2px; width:36px; pointer-events:none; opacity:0; transition:opacity .15s ease; z-index:2; }+#railwrap::before { left:0; background:linear-gradient(90deg, var(--surface) 20%, transparent); }+#railwrap::after { right:0; background:linear-gradient(270deg, var(--surface) 20%, transparent); }+#railwrap.can-left::before { opacity:1; }+#railwrap.can-right::after { opacity:1; }+#steprail { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; scrollbar-width:none; }+/* fit by MEASUREMENT, not breakpoints (fitRail): drop summaries first, then+ collapse inactive chips to their number. 7 number-only chips fit anywhere,+ so the rail is normally never in scroll at all. */+#steprail.compact .schip .v { display:none; }+#steprail.mini .schip:not(.active) .t { display:none; }+#steprail.mini .schip:not(.active) { padding:4px 8px 4px 5px; }+/* center when there is room, scroll (never clip) when there is not:+ auto margins on the ends collapse to 0 inside an overflowing scroller */+#steprail > :first-child, #cards > :first-child { margin-left:auto; }+#steprail > :last-child, #cards > :last-child { margin-right:auto; }+#steprail::-webkit-scrollbar, #cards::-webkit-scrollbar { display:none; }+.schip { display:flex; align-items:center; gap:8px; border:1px solid var(--border); background:var(--elevated); border-radius:999px;+ padding:4px 12px 4px 5px; cursor:pointer; white-space:nowrap; transition:all .15s ease; font-family:'Satoshi'; }+.schip:hover { border-color:var(--text-3); }+.schip .n { width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center;+ font-size:11px; font-family:'JetBrains Mono',monospace; background:var(--overlay); color:var(--text-2); border:1px solid var(--border); flex:0 0 20px; }+.schip .n svg { width:11px; height:11px; stroke:var(--green); stroke-width:2.5; fill:none; }+.schip .t { font-size:12.5px; color:var(--text-2); }+.schip .v { font-size:11px; color:var(--text-3); max-width:110px; overflow:hidden; text-overflow:ellipsis; }+.schip.active { border-color:var(--accent); background:rgba(0,184,176,.10); }+.schip.active .t { color:var(--text); font-weight:500; }+.schip.active .n { background:var(--accent); color:#04211f; border-color:transparent; }+.schip.done .n { background:rgba(63,185,80,.12); border-color:rgba(63,185,80,.45); }+.schip.locked .t::after { content:' (locked)'; color:var(--text-3); font-size:10.5px; }++#btn-generate { font-size:15px; font-weight:600; padding:10px 26px; border-radius:10px; }+#btn-generate:not(:disabled) { box-shadow:0 0 18px rgba(0,184,176,.35); }+#editring { position:absolute; border:2px dashed var(--accent); border-radius:10px; pointer-events:none;+ z-index:9; opacity:0; transition:opacity .25s ease, top .2s ease, left .2s ease, width .2s ease, height .2s ease;+ box-shadow:0 0 22px rgba(0,184,176,.18); }+#editring.on { opacity:.85; }+.wordpick { display:flex; flex-wrap:wrap; gap:4px; max-width:440px; }+.wtok { padding:2px 8px; border:1px solid var(--border); border-radius:6px; font-size:12px; cursor:pointer;+ color:var(--text-2); background:var(--elevated); transition:all .12s ease; user-select:none; }+.wtok:hover { border-color:var(--text-3); }+.wtok.on { border-color:var(--accent); background:var(--grad); -webkit-background-clip:text; background-clip:text; color:transparent; font-weight:600; }+#dockintro { font-size:12px; color:var(--text-2); padding:0 2px 2px; }+#dockintro b { color:var(--text); font-weight:600; }+#dockintro .ref { font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--accent); }+#dockhead { display:flex; align-items:baseline; gap:8px 12px; min-height:22px; flex-wrap:wrap; }+#dockhead .dtitle { font-family:'Familjen Grotesk'; font-weight:700; font-size:15px; color:var(--text); }+#dockhead .dcount { font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--text-2); }+#dockhead .dhint { font-size:12px; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0; }+#dockhead .dright { margin-left:auto; display:flex; gap:8px; flex:0 0 auto; }++#dockmain { display:flex; align-items:stretch; gap:12px; min-height:96px; }+.arrow { flex:0 0 46px; border:1px solid var(--border); border-radius:10px; background:var(--elevated); cursor:pointer;+ display:flex; align-items:center; justify-content:center; transition:all .15s ease; }+.arrow svg { width:20px; height:20px; stroke:var(--text); stroke-width:2; fill:none; }+.arrow:hover { border-color:var(--accent); background:rgba(0,184,176,.08); }+.arrow[disabled] { opacity:.3; cursor:default; }+.arrow[disabled]:hover { border-color:var(--border); background:var(--elevated); }+#cards { flex:1; display:flex; gap:10px; align-items:stretch; overflow-x:auto; padding:2px; min-width:0; scrollbar-width:none; }+.card { flex:0 0 auto; width:118px; border:1px solid var(--border); border-radius:10px; background:var(--elevated); cursor:pointer;+ padding:8px 8px 7px; display:flex; flex-direction:column; align-items:center; gap:6px; transition:all .15s ease; }+.card:hover { border-color:var(--text-3); }+.card.on { border-color:var(--accent); background:rgba(0,184,176,.10); box-shadow:0 0 0 1px var(--accent); }+.card .thumb { width:100%; aspect-ratio:16/10; border-radius:6px; overflow:hidden; border:1px solid var(--border); position:relative; }+.card .thumb svg { display:block; width:100%; height:100%; }+.card .lbl { font-size:12px; color:var(--text); text-align:center; line-height:1.25; }+.card .sub { font-size:10.5px; color:var(--text-2); text-align:center; line-height:1.3; }+.card.on .lbl { color:var(--accent-bright); }+.card.wide { width:170px; }+.card .swx { width:100%; aspect-ratio:16/10; border-radius:6px; border:1px solid var(--border); }+.sw-midnight { background:linear-gradient(155deg,#0a0e14,#0d1117 45%,#071318); }+.sw-pcb { background:linear-gradient(155deg,#03211f,#04302c); }+.sw-onion { background:repeating-radial-gradient(circle at 90% 10%, #101a2c 0 4px, #16223a 4px 6px); }+.sw-pill { background:#0c3a3c; }+.sw-grid { background:#0c3a3c url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><rect x="2" y="2" width="8" height="8" fill="none" stroke="%23ffffff" stroke-opacity=".35" stroke-width="1"/></svg>'); }+.sw-grad { background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7); }+.sw-aurora { background:radial-gradient(28px 20px at 30% 20%, rgba(0,230,220,.8), transparent), radial-gradient(34px 26px at 70% 80%, rgba(140,107,247,.7), transparent), #0a0f16; }+.sw-mesh { background:conic-gradient(from 155deg at 40% 40%, rgba(0,184,177,.5), transparent 40%), #0b1016; }+.sw-circuitry { background:repeating-linear-gradient(45deg, rgba(0,184,177,.5) 0 1.5px, transparent 1.5px 7px), #080d13; }+.sw-image { background:repeating-conic-gradient(#1c2530 0 25%, #0d1117 0 50%) 0 0/12px 12px; }++.btn {+ padding:8px 14px; border-radius:8px; border:1px solid var(--border);+ background:var(--elevated); color:var(--text); font-family:'Satoshi'; font-size:13px; cursor:pointer;+ transition:all .15s ease;+}+.btn:hover { border-color:var(--accent); background:rgba(0,184,176,.08); }+.btn-primary { border-color:var(--accent); background:rgba(0,184,176,.12); font-weight:500; }+.btn-primary:hover { background:rgba(0,184,176,.2); }+.btn:disabled { opacity:.45; cursor:not-allowed; }+.btn.small { padding:5px 10px; font-size:12px; }++#finetune { display:none; border-top:1px dashed var(--border); padding-top:10px; }+#finetune.open { display:flex; gap:26px; flex-wrap:wrap; align-items:flex-start; }+.ft-group { display:flex; flex-direction:column; gap:6px; min-width:170px; }+.ft-group .ft-label { font-family:'Familjen Grotesk'; font-weight:500; font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--text-2); }+label.chk { display:flex; align-items:center; gap:8px; padding:2px 0; cursor:pointer; color:var(--text); font-size:12.5px; }+label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:pointer; }+.seg { display:inline-flex; flex-wrap:wrap; border:1px solid var(--border); border-radius:8px; overflow:hidden; max-width:100%; }+.seg button { background:var(--bg); color:var(--text); border:0; padding:6px 12px; font-size:12.5px; cursor:pointer; font-family:'Satoshi'; white-space:nowrap; }+.seg button.on { background:rgba(0,184,176,.18); color:var(--accent-bright); }+.sl { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--text-2); }+.sl label { width:74px; flex:0 0 74px; }+.sl input[type=range] { width:120px; accent-color:var(--accent); cursor:pointer; }+.sl .val { font-family:'JetBrains Mono',monospace; font-size:11px; width:34px; }+.brandrow { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--text-2); }+.brandrow input[type=color] { width:38px; height:24px; border:1px solid var(--border); border-radius:6px; background:var(--surface); padding:1px; cursor:pointer; }+input[type=text].hin { background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px;+ padding:8px 10px; font:inherit; font-size:13px; width:min(380px, 40vw); }+input[type=text].hin:focus { border-color:var(--accent); outline:none; }++/* review step */+.review { flex:1; display:flex; gap:14px 22px; align-items:flex-start; justify-content:center; min-width:0; flex-wrap:wrap; }+.rv-col { display:flex; flex-direction:column; gap:8px; min-width:0; }+.rv-check { display:flex; align-items:center; gap:10px; border:1px solid var(--border); border-radius:8px; padding:6px 10px; background:var(--elevated); }+.rv-check .q { font-size:12.5px; flex:1; min-width:0; }+.rv-check.ok { border-color:rgba(63,185,80,.5); }+.rv-check.ok .q { color:var(--green); }+.rv-check.flag { border-color:rgba(248,81,73,.55); }+.rv-check.flag .q { color:var(--red); }+.rv-check .btn.small.okon { border-color:var(--green); color:var(--green); }+.rv-check .btn.small.flagon { border-color:var(--red); color:var(--red); }+.lint-item { font-size:12px; margin:2px 0; line-height:1.35; }+.lint-warn { color:#e8a13c; }+.lint-ok { color:#3fb950; }+#rv-generate { font-size:14px; padding:10px 18px; }++/* toasts */+#toasts { position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:99998; display:flex; flex-direction:column-reverse; gap:8px; }+.toast {+ display:flex; align-items:center; gap:8px; background:var(--overlay); border:1px solid var(--border);+ border-left:3px solid var(--accent); border-radius:8px; padding:9px 13px; font-size:13px;+ box-shadow:0 8px 30px rgba(0,0,0,.45); animation:tin .2s ease;+}+.toast.success { border-left-color:var(--green); }+@keyframes tin { from { opacity:0; transform:translateY(6px);} }++/* ---------- responsive: every width and height must degrade, never clip ---------- */+@media (max-width:900px) {+ #dockhead .dhint { display:none; }+ .card { width:104px; }+ .card.wide { width:150px; }+ .arrow { flex:0 0 38px; }+ #dock { padding:8px 10px 10px; }+ #stage { padding:12px; }+ input[type=text].hin { width:min(300px, 56vw); }+}+@media (max-width:640px) {+ .subject .meta { display:none; }+ .card { width:92px; }+ #dockmain { gap:8px; }+}+@media (max-height:760px) {+ #dockmain { min-height:78px; }+ #stage { padding:10px; }+ #dock { gap:7px; padding-bottom:10px; }+}+</style>+</head>+<body>++<header>+ <div class="brand">+ <svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="14.5" x2="21" y2="14.5"/><line x1="6" y1="17.5" x2="13" y2="17.5"/></svg>+ <span class="name">Hero Studio</span>+ <span class="sub">hero image studio</span>+ <span class="mocktag">UI MOCKUP</span>+ </div>+ <div class="vsep"></div>+ <div class="subject"><b id="subj-title">...</b><span class="meta" id="subj-meta"></span></div>+ <div class="actions">+ <button class="iconbtn" id="btn-guides" title="Toggle the lower-third guide line">+ <svg viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.6"/></svg>+ </button>+ <button class="iconbtn" id="btn-rand" title="Randomize placement and background">+ <svg viewBox="0 0 24 24"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>+ </button>+ </div>+</header>++<main>+ <div id="stage">+ <div id="hero" class="hero" data-theme="midnight" data-layout="bleed-bottom">+ <div class="bg"></div>+ <canvas id="circuitcanvas"></canvas>+ <div class="textzone">+ <div class="wordmark" id="el-logo"><span class="wmark"></span></div>+ <div class="h-title" id="el-title"></div>+ <div class="h-sub" id="el-sub"></div>+ <div class="h-desc" id="el-desc"></div>+ </div>+ <div class="window" id="el-window">+ <div class="titlebar">+ <span class="t" id="win-label"></span>+ <span class="winbtns">+ <svg viewBox="0 0 10 10"><line x1="1" y1="5" x2="9" y2="5"/></svg>+ <svg viewBox="0 0 10 10"><rect x="1.5" y="1.5" width="7" height="7"/></svg>+ <svg viewBox="0 0 10 10"><line x1="1.5" y1="1.5" x2="8.5" y2="8.5"/><line x1="8.5" y1="1.5" x2="1.5" y2="8.5"/></svg>+ </span>+ </div>+ <div class="appbody">+ <img class="vitrine-img" id="shot-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>+ </div>+ <div class="content">+ <div class="crow"><span class="chipk" style="width:22%"></span><span class="chipk" style="width:12%"></span><span class="chipk" style="width:16%"></span></div>+ <div class="bars">+ <div class="bar" style="height:42%"></div><div class="bar" style="height:66%"></div>+ <div class="bar" style="height:35%"></div><div class="bar" style="height:82%"></div>+ <div class="bar" style="height:58%"></div><div class="bar" style="height:91%"></div>+ <div class="bar" style="height:49%"></div><div class="bar" style="height:73%"></div>+ </div>+ <div class="tbl">+ <div class="trow"><span class="tcell" style="flex:2"></span><span class="tcell" style="flex:1"></span><span class="tcell" style="flex:1"></span></div>+ <div class="trow"><span class="tcell" style="flex:2"></span><span class="tcell" style="flex:1"></span><span class="tcell" style="flex:1"></span></div>+ <div class="trow"><span class="tcell" style="flex:2"></span><span class="tcell" style="flex:1"></span><span class="tcell" style="flex:1"></span></div>+ </div>+ </div>+ </div>+ </div>+ <div id="mqoverlay">+ <div class="mqo-badges" id="mqo-badges"></div>+ <h3 class="mqo-title" id="mqo-title"></h3>+ <p class="mqo-brief" id="mqo-brief"></p>+ <span class="mqo-owner" id="mqo-owner"></span>+ </div>+ <div class="pill-tr scrim" id="pill-tr"><span class="pill app" id="l-pill">APP</span></div>+ <div class="slug-br" id="slug-br"><a class="l-slug" id="l-slug" href="#" onclick="return false"></a></div>+ <div class="guide"><span>lower third</span></div>+ <div id="editring"></div>+ </div>+ <div id="ctxview"></div>+ </div>++ <div id="dock">+ <div id="dockintro">You're designing the <b>hero image</b> for <span class="ref" id="intro-ref"></span>, the picture at the top of its wiki page. Everything updates live above. Nothing is saved until you hit Generate.</div>+ <div id="railwrap"><div id="steprail"></div></div>+ <div id="dockhead">+ <span class="dtitle" id="d-title"></span>+ <span class="dcount" id="d-count"></span>+ <span class="dhint" id="d-hint"></span>+ <div class="dright">+ <button class="btn small" id="btn-finetune">Fine tune</button>+ <button class="btn btn-primary" id="btn-generate" style="display:none">Generate hero PNG</button>+ <button class="btn small btn-primary" id="btn-next">Next</button>+ </div>+ </div>+ <div id="dockmain">+ <button class="arrow" id="arr-l" title="Previous option (left arrow key)"><svg viewBox="0 0 24 24"><polyline points="14 5 7 12 14 19"/></svg></button>+ <div id="cards"></div>+ <button class="arrow" id="arr-r" title="Next option (right arrow key)"><svg viewBox="0 0 24 24"><polyline points="10 5 17 12 10 19"/></svg></button>+ </div>+ <div id="finetune"></div>+ </div>+</main>++<div id="toasts"></div>+<input type="file" id="file-shot" accept="image/*" multiple style="display:none">+<input type="file" id="file-bg" accept="image/*" style="display:none">++<script>+'use strict';+const $ = (id) => document.getElementById(id);++/* ---------- mock data ---------- */+const PAGES = [+ { key:'adom/adom-tts', title:'Adom TTS', type:'app', owner:'adom', brief:'Text to speech for every container, one CLI verb away.', score:97, stars:31, installs:214 },+ { key:'adom/parts-search', title:'Parts Search', type:'app', owner:'adom', brief:'One search across Mouser, DigiKey and JLCPCB.', score:93, stars:24, installs:180 },+ { key:'adom/hero-studio', title:'Hero Studio', type:'app', owner:'adom', brief:'Design hero images with a human approval gate.', score:88, stars:19, installs:122 },+ { key:'john/wire-bender', title:'Wire Bender', type:'skill', owner:'john', brief:'Parametric wire forming layouts for the workcell.', score:74, stars:12, installs:64 },+];+const pageOf = () => PAGES.find(p => p.key === mock.page) || PAGES[0];++const mock = {+ page:'adom/adom-tts', layout:'bleed-bottom', theme:'midnight',+ headline:'', titleMode:'image-only',+ source:'mock', cover:false, frame:'auto', full:false,+ show:{ logo:true, title:true, subtitle:true, description:false },+ fs:{ title:8, sub:2.5, desc:1.35, gap:1 },+ uploads:[], // [{name, data}] every image the user brought in+ upload:-1, // active index into uploads+ gatesDone:0, // completed-gate count (seasoned mode, #45; ?gates=N)+ subtitle:'', // '' = the page brief's first sentence+ accentWords:[0, 1, 2], // word indices that get the gradient treatment // leading removed on purpose: fixed anchors beat adjustable leading (Colby 2026-08-12)+ pill:true, slug:false, gradAngle:155, guides:false, mq:false,+ brand:{ accent:'', bg:'' },+ step:'package', view:'png', finetuneOpen:false,+ confirm:{ title:false, subtitle:false, showcase:false, type:false },+ flags:{ title:false, subtitle:false, showcase:false, type:false },+ seed:7,+};++/* ---------- toasts ---------- */+function toast(msg, cls) {+ const t = document.createElement('div');+ t.className = 'toast' + (cls ? ' ' + cls : '');+ t.textContent = msg;+ $('toasts').appendChild(t);+ setTimeout(() => t.remove(), 4000);+}++/* ---------- placement thumbnails ---------- */+function layoutThumb(l) {+ const win = {+ 'bleed-bottom': { x:42, y:9, w:52, h:58 },+ 'bleed-right': { x:52, y:9, w:52, h:40 },+ 'bleed-top': { x:42, y:-8, w:48, h:38 },+ 'bleed-corner-br': { x:50, y:20, w:54, h:48 },+ 'bleed-corner-tr': { x:50, y:-8, w:54, h:40 },+ 'contained': { x:46, y:9, w:46, h:30 },+ 'mirror': { x:4, y:9, w:52, h:58 },+ 'showcase': { x:30, y:4, w:74, h:54 },+ }[l];+ const mirror = l === 'mirror';+ const tx = mirror ? 62 : 8;+ const scrim = l === 'showcase'+ ? '<rect x="0" y="0" width="46" height="62" fill="url(#scr)"/>' : '';+ return `<svg viewBox="0 0 100 62" preserveAspectRatio="none">+ <defs><linearGradient id="scr" x1="0" y1="0" x2="1" y2="0">+ <stop offset="0" stop-color="#05090e" stop-opacity=".95"/><stop offset="1" stop-color="#05090e" stop-opacity="0"/>+ </linearGradient></defs>+ <rect x="0" y="0" width="100" height="62" fill="#0d1117"/>+ <rect x="${win.x}" y="${win.y}" width="${win.w}" height="${win.h}" rx="2" fill="#1c2530" stroke="#3a4552" stroke-width="1"/>+ ${scrim}+ <rect x="${tx}" y="12" width="26" height="6" rx="1.5" fill="#e6edf3" opacity=".9"/>+ <rect x="${tx}" y="22" width="22" height="3" rx="1.5" fill="#8b949e" opacity=".8"/>+ <rect x="${tx}" y="28" width="18" height="3" rx="1.5" fill="#8b949e" opacity=".5"/>+ </svg>`;+}++/* ---------- steps ---------- */+const LAYOUTS = [+ { v:'bleed-bottom', label:'Off the bottom' },+ { v:'bleed-right', label:'Off the right' },+ { v:'bleed-top', label:'Off the top' },+ { v:'bleed-corner-br', label:'Bottom-right corner' },+ { v:'bleed-corner-tr', label:'Top-right corner' },+ { v:'contained', label:'Floating inside' },+ { v:'mirror', label:'Flipped, text right' },+ { v:'showcase', label:'Backdrop' },+];+const THEMES = [+ { v:'midnight', label:'Midnight', sw:'sw-midnight' },+ { v:'pcb', label:'Circuit board', sw:'sw-pcb' },+ { v:'onion', label:'Onion arcs', sw:'sw-onion' },+ { v:'pill', label:'Pill field', sw:'sw-pill' },+ { v:'grid', label:'Square grid', sw:'sw-grid' },+ { v:'gradient', label:'Bold gradient', sw:'sw-grad' },+ { v:'aurora', label:'Aurora', sw:'sw-aurora' },+ { v:'mesh', label:'Color mesh', sw:'sw-mesh' },+ { v:'circuitry', label:'Generated traces', sw:'sw-circuitry' },+ { v:'image', label:'Your image', sw:'sw-image' },+];+const STEPS = [+ {+ id:'package', title:'Package',+ hint:'Which page this hero is for.',+ summary:() => pageOf().title,+ options:() => PAGES.map(p => ({ v:p.key, label:p.title, sub:p.key, kind:'page' })),+ get:() => mock.page,+ set:(v) => { mock.page = v; },+ },+ {+ id:'showcase', title:'Showcase image',+ hint:'The screenshot shown in your hero. Use your real app, not a mock.',+ summary:() => ({ page:'From the page', upload:'Uploaded', mock:'Drawn mock' })[mock.source],+ options:() => [+ { v:'page', label:'From the wiki page', sub:'The screenshot already on the page' },+ { v:'upload', label:'Upload images', sub:mock.uploads.length ? 'Add more; keep several and switch' : 'Pick PNGs from this machine' },+ ...mock.uploads.map((u, i) => ({ v:'up:' + i, label:u.name.length > 18 ? u.name.slice(0, 17) + '\u2026' : u.name, sub:'Uploaded image ' + (i + 1) })),+ { v:'mock', label:'Drawn mock', sub:'The generic drawn window' },+ ],+ get:() => (mock.source === 'upload' && mock.upload >= 0 ? 'up:' + mock.upload : mock.source),+ set:(v) => pickSource(v),+ fineTune:(box) => {+ ftChk(box, 'Crop to fill instead of letterboxing', mock.cover, (on) => { mock.cover = on; apply(); });+ ftSeg(box, 'Window frame', [+ { v:'auto', label:'Auto' },+ { v:'fake', label:'Drawn title bar' },+ { v:'none', label:'No title bar' },+ { v:'bare', label:'Bare image' },+ ], mock.frame, (v) => { mock.frame = v; apply(); });+ ftChk(box, 'Use this image as the entire hero', mock.full, (on) => {+ if (on && mock.source === 'mock') { toast('Upload or pull an image first; the drawn mock can\'t be the whole hero.'); renderDock(); return; }+ mock.full = on; apply();+ toast(on ? 'In the real app this disables all overlays and re-stamps provenance.' : 'Back to the composed hero.');+ });+ },+ },+ {+ id:'placement', title:'Placement',+ hint:'Where the screenshot sits. Arrow keys work too.',+ summary:() => LAYOUTS.find(l => l.v === mock.layout).label,+ options:() => LAYOUTS.map(l => ({ v:l.v, label:l.label, thumb:layoutThumb(l.v) })),+ get:() => mock.layout,+ set:(v) => { mock.layout = v; },+ },+ {+ id:'background', title:'Background',+ hint:'The scene behind everything.',+ summary:() => THEMES.find(t => t.v === mock.theme).label,+ options:() => THEMES.map(t => ({ v:t.v, label:t.label, sw:t.sw })),+ get:() => mock.theme,+ set:(v) => {+ mock.theme = v;+ if (v === 'image' && !heroBg) { $('file-bg').click(); }+ },+ fineTune:(box) => {+ ftSlider(box, 'Angle', 0, 360, 5, mock.gradAngle, (v) => { mock.gradAngle = v; apply(); });+ if (mock.theme === 'circuitry') {+ ftBtn(box, 'Reroll the traces', () => { mock.seed = (mock.seed * 16807) % 2147483647; apply(); });+ }+ if (mock.theme === 'image') {+ ftBtn(box, 'Import background art', () => $('file-bg').click());+ }+ if (pageOf().owner !== 'adom') {+ const g = ftGroup(box, 'Owner brand (' + pageOf().owner + ' brings their own colors)');+ ftColor(g, 'Accent', mock.brand.accent || '#00e6dc', (v) => { mock.brand.accent = v; apply(); });+ ftColor(g, 'Background', mock.brand.bg || '#0d1117', (v) => { mock.brand.bg = v; apply(); });+ ftBtn(g, 'Use the studio palette', () => { mock.brand = { accent:'', bg:'' }; apply(); renderDock(); });+ }+ },+ },+ {+ id:'headline', title:'Headline and text',+ hint:'The words in your hero. Fewer words read bigger.',+ summary:() => (mock.headline || pageOf().title),+ custom:(host) => {+ const wrap = document.createElement('div');+ wrap.style.cssText = 'flex:1;display:flex;gap:22px;align-items:center;justify-content:center;flex-wrap:wrap';+ const left = document.createElement('div');+ left.style.cssText = 'display:flex;flex-direction:column;gap:8px';+ const lab = document.createElement('div');+ lab.style.cssText = 'font-size:11px;color:var(--text-2);letter-spacing:.06em;text-transform:uppercase;font-family:Familjen Grotesk';+ lab.textContent = 'Headline';+ const inp = document.createElement('input');+ inp.type = 'text'; inp.className = 'hin'; inp.placeholder = pageOf().title + ' (the page title)'; inp.value = mock.headline;+ inp.addEventListener('input', () => { mock.headline = inp.value; apply(); });+ const fitb = document.createElement('button');+ fitb.className = 'btn small'; fitb.textContent = 'Make it as big as fits';+ fitb.addEventListener('click', () => { fitTitle(); });+ const slab = document.createElement('div');+ slab.style.cssText = lab.style.cssText; slab.style.marginTop = '6px';+ slab.textContent = 'Text under it';+ const sinp = document.createElement('input');+ sinp.type = 'text'; sinp.className = 'hin';+ sinp.placeholder = pageOf().brief.split('.')[0] + '.';+ sinp.value = mock.subtitle;+ const glab = document.createElement('div');+ glab.style.cssText = lab.style.cssText; glab.style.marginTop = '6px';+ glab.textContent = 'Tap words to color them';+ const pick = document.createElement('div');+ pick.className = 'wordpick';+ const renderPick = () => {+ pick.textContent = '';+ const on = new Set(mock.accentWords);+ subtitleWords().forEach((w, i) => {+ const t = document.createElement('span');+ t.className = 'wtok' + (on.has(i) ? ' on' : '');+ t.textContent = w;+ t.addEventListener('click', () => {+ const set = new Set(mock.accentWords);+ set.has(i) ? set.delete(i) : set.add(i);+ mock.accentWords = [...set].sort((a, b) => a - b);+ renderPick(); apply();+ });+ pick.appendChild(t);+ });+ };+ sinp.addEventListener('input', () => {+ mock.subtitle = sinp.value;+ const n = subtitleWords().length;+ mock.accentWords = mock.accentWords.filter((i) => i < n);+ renderPick(); apply();+ });+ renderPick();+ left.append(lab, inp, fitb, slab, sinp, glab, pick);+ wrap.appendChild(left);+ host.appendChild(wrap);+ },+ fineTune:(box) => {+ const g = ftGroup(box, 'Show');+ ftChk(g, 'Adom wordmark', mock.show.logo, (on) => { mock.show.logo = on; apply(); });+ ftChk(g, 'Title', mock.show.title, (on) => { mock.show.title = on; apply(); });+ ftChk(g, 'Value-prop subtitle', mock.show.subtitle, (on) => { mock.show.subtitle = on; apply(); });+ ftChk(g, 'Detail line', mock.show.description, (on) => { mock.show.description = on; apply(); });+ const s = ftGroup(box, 'Sizes');+ ftSlider(s, 'Title', 5, 13, 0.1, mock.fs.title, (v) => { mock.fs.title = v; apply(); });+ ftSlider(s, 'Subtitle', 1.8, 3.5, 0.1, mock.fs.sub, (v) => { mock.fs.sub = v; apply(); });+ ftSlider(s, 'Detail', 1.0, 2.0, 0.05, mock.fs.desc, (v) => { mock.fs.desc = v; apply(); });+ ftSlider(s, 'Gap', 0.5, 2, 0.05, mock.fs.gap, (v) => { mock.fs.gap = v; apply(); });+ },+ },+ {+ id:'finishing', title:'Finishing',+ hint:'The small marks in the corners.',+ summary:() => [mock.pill ? 'pill' : null, mock.slug ? 'slug' : null].filter(Boolean).join(' + ') || 'none',+ toggles:() => [+ { label:'Type pill, top right', sub:'APP, SKILL or COMPONENT', on:mock.pill, flip:() => { mock.pill = !mock.pill; } },+ { label:'Wiki slug, bottom right', sub:'owner/slug in mono', on:mock.slug, flip:() => { mock.slug = !mock.slug; } },+ ],+ },+ {+ id:'review', title:'Review and generate',+ hint:'See it everywhere it will show up, then make it real.',+ summary:() => allConfirmed() ? 'ready' : 'pending',+ review:true,+ },+];++const stepIndex = (id) => STEPS.findIndex(s => s.id === id);+const activeStep = () => STEPS[stepIndex(mock.step)];+const allConfirmed = () => Object.values(mock.confirm).every(Boolean) && !Object.values(mock.flags).some(Boolean);++/* ---------- fine tune builders ---------- */+function ftGroup(box, label) {+ const g = document.createElement('div');+ g.className = 'ft-group';+ const l = document.createElement('div');+ l.className = 'ft-label'; l.textContent = label;+ g.appendChild(l);+ box.appendChild(g);+ return g;+}+function ftChk(box, label, on, fn) {+ const l = document.createElement('label');+ l.className = 'chk';+ const c = document.createElement('input');+ c.type = 'checkbox'; c.checked = on;+ c.addEventListener('change', () => fn(c.checked));+ l.append(c, document.createTextNode(label));+ box.appendChild(l);+}+function ftSeg(box, label, options, value, fn) {+ const g = ftGroup(box, label);+ const seg = document.createElement('div');+ seg.className = 'seg';+ options.forEach(o => {+ const b = document.createElement('button');+ b.textContent = o.label;+ if (o.v === value) b.classList.add('on');+ b.addEventListener('click', () => { fn(o.v); renderDock(); });+ seg.appendChild(b);+ });+ g.appendChild(seg);+}+function ftSlider(box, label, min, max, step, value, fn) {+ const row = document.createElement('div');+ row.className = 'sl';+ const l = document.createElement('label'); l.textContent = label;+ const r = document.createElement('input');+ r.type = 'range'; r.min = min; r.max = max; r.step = step; r.value = value;+ const v = document.createElement('span'); v.className = 'val'; v.textContent = value;+ r.addEventListener('input', () => { v.textContent = r.value; fn(parseFloat(r.value)); });+ row.append(l, r, v);+ box.appendChild(row);+}+function ftBtn(box, label, fn) {+ const b = document.createElement('button');+ b.className = 'btn small'; b.textContent = label;+ b.addEventListener('click', fn);+ box.appendChild(b);+}+function ftColor(box, label, value, fn) {+ const row = document.createElement('div');+ row.className = 'brandrow';+ const l = document.createElement('span'); l.textContent = label;+ const c = document.createElement('input');+ c.type = 'color'; c.value = value;+ c.addEventListener('input', () => fn(c.value));+ row.append(l, c);+ box.appendChild(row);+}++/* ---------- dock rendering ---------- */+function card(opt, on, pick) {+ const c = document.createElement('div');+ c.className = 'card' + (on ? ' on' : '');+ if (opt.thumb) {+ const t = document.createElement('div');+ t.className = 'thumb'; t.innerHTML = opt.thumb;+ c.appendChild(t);+ } else if (opt.sw) {+ const t = document.createElement('div');+ t.className = 'swx ' + opt.sw;+ c.appendChild(t);+ }+ const l = document.createElement('div');+ l.className = 'lbl'; l.textContent = opt.label;+ c.appendChild(l);+ if (opt.sub) {+ const s = document.createElement('div');+ s.className = 'sub'; s.textContent = opt.sub;+ c.appendChild(s);+ }+ c.addEventListener('click', pick);+ return c;+}++function renderRail() {+ const rail = $('steprail');+ rail.textContent = '';+ STEPS.forEach((s, i) => {+ const chip = document.createElement('div');+ const done = s.review ? allConfirmed() : i < stepIndex(mock.step);+ chip.className = 'schip' + (s.id === mock.step ? ' active' : '') + (done ? ' done' : '');+ const n = document.createElement('span');+ n.className = 'n';+ n.innerHTML = done && s.id !== mock.step ? '<svg viewBox="0 0 24 24"><polyline points="5 13 10 18 19 7"/></svg>' : String(i + 1);+ const t = document.createElement('span');+ t.className = 't'; t.textContent = s.title;+ chip.append(n, t);+ const sum = s.summary();+ if (sum && s.id !== mock.step) {+ const v = document.createElement('span');+ v.className = 'v'; v.textContent = sum;+ chip.appendChild(v);+ }+ chip.addEventListener('click', () => goTo(s.id));+ rail.appendChild(chip);+ });+ fitRail();+ const act = rail.querySelector('.schip.active');+ if (act) act.scrollIntoView({ inline:'center', block:'nearest' });+}++/* The rail must never CLIP (hero-studio wizard feedback 2026-08-12): degrade+ until it fits, and when it still scrolls, SAY so (edge fades + wheel). */+function fitRail() {+ const rail = $('steprail');+ const wrap = document.getElementById('railwrap');+ rail.classList.remove('compact', 'mini');+ if (rail.scrollWidth > rail.clientWidth + 1) rail.classList.add('compact');+ if (rail.scrollWidth > rail.clientWidth + 1) rail.classList.add('mini');+ const upd = () => {+ wrap.classList.toggle('can-left', rail.scrollLeft > 2);+ wrap.classList.toggle('can-right', rail.scrollLeft + rail.clientWidth < rail.scrollWidth - 2);+ };+ upd();+ rail.onscroll = upd;+}+window.addEventListener('resize', fitRail);+// web fonts change chip widths after the first measure; re-fit at ready so an+// early over-collapse (or overflow) never sticks+if (document.fonts && document.fonts.ready) document.fonts.ready.then(fitRail);+document.getElementById('steprail').addEventListener('wheel', (e) => {+ const rail = e.currentTarget;+ if (!e.deltaX && e.deltaY && rail.scrollWidth > rail.clientWidth) {+ rail.scrollLeft += e.deltaY;+ e.preventDefault();+ }+}, { passive: false });++// what each step edits, for the on-stage cue ring (Colby 2026-08-13)+const EDIT_TARGETS = {+ showcase: () => mock.full ? $('hero') : hero.querySelector('.window'),+ placement: () => hero.querySelector('.window'),+ background:() => $('hero'),+ headline: () => hero.querySelector('.textzone'),+ finishing: () => $('pill-tr'),+};+function positionEditRing() {+ const ring = $('editring');+ const getT = EDIT_TARGETS[mock.step];+ const t = getT && getT();+ if (!t || !t.offsetParent && t !== $('hero')) { ring.classList.remove('on'); return; }+ const hb = $('hero').getBoundingClientRect();+ const tb = t.getBoundingClientRect();+ const pad = t === $('hero') ? -8 : 8;+ ring.style.left = (tb.left - hb.left - pad) + 'px';+ ring.style.top = (tb.top - hb.top - pad) + 'px';+ ring.style.width = (tb.width + pad * 2) + 'px';+ ring.style.height = (tb.height + pad * 2) + 'px';+ ring.classList.add('on');+}+window.addEventListener('resize', () => positionEditRing());++function renderDock() {+ const s = activeStep();+ renderRail();+ requestAnimationFrame(positionEditRing);+ $('d-title').textContent = (stepIndex(mock.step) + 1) + '. ' + s.title;+ $('d-hint').textContent = s.hint;+ const next = STEPS[stepIndex(mock.step) + 1];+ $('btn-next').textContent = next ? 'Next: ' + next.title : 'Done';+ $('btn-next').style.display = next && !s.review ? '' : 'none';+ const gbtn = $('btn-generate');+ gbtn.style.display = s.review ? '' : 'none';+ if (s.review) {+ gbtn.disabled = !allConfirmed();+ gbtn.title = gbtn.disabled ? 'Confirm the four checks first' : '';+ }+ $('btn-finetune').style.display = s.fineTune ? '' : 'none';++ const cards = $('cards');+ cards.textContent = '';+ const showArrows = !!s.options;+ $('arr-l').style.visibility = showArrows ? 'visible' : 'hidden';+ $('arr-r').style.visibility = showArrows ? 'visible' : 'hidden';++ if (s.options) {+ const opts = s.options();+ const cur = s.get();+ const idx = opts.findIndex(o => o.v === cur);+ $('d-count').textContent = (idx + 1) + ' of ' + opts.length;+ opts.forEach(o => cards.appendChild(card(o, o.v === cur, () => { s.set(o.v); apply(); renderDock(); })));+ const on = cards.querySelector('.card.on');+ if (on) on.scrollIntoView({ inline:'center', block:'nearest' });+ } else if (s.toggles) {+ $('d-count').textContent = '';+ s.toggles().forEach(t => {+ const c = card({ v:t.label, label:t.label, sub:t.sub }, t.on, () => { t.flip(); apply(); renderDock(); });+ c.classList.add('wide');+ cards.appendChild(c);+ });+ } else if (s.custom) {+ $('d-count').textContent = '';+ s.custom(cards);+ } else if (s.review) {+ $('d-count').textContent = '';+ renderReview(cards);+ }++ const ft = $('finetune');+ ft.textContent = '';+ ft.classList.toggle('open', mock.finetuneOpen && !!s.fineTune);+ $('btn-finetune').classList.toggle('btn-primary', mock.finetuneOpen && !!s.fineTune);+ if (mock.finetuneOpen && s.fineTune) s.fineTune(ft);+}++/* ---------- review step ---------- */+const CHECKS = [+ { key:'title', q:'Title reads right' },+ { key:'subtitle', q:'The line under the title reads right' },+ { key:'showcase', q:'The screenshot shows the right thing' },+ { key:'type', q:'Page type matches (this page says ' + '"app")' },+];+function renderReview(host) {+ const wrap = document.createElement('div');+ wrap.className = 'review';++ const views = document.createElement('div');+ views.className = 'rv-col';+ const vlab = document.createElement('div');+ vlab.className = 'ft-label'; vlab.textContent = 'See it as';+ const seg = document.createElement('div');+ seg.className = 'seg';+ [ { v:'png', label:'Exactly my PNG' },+ { v:'mq', label:'Homepage preview' },+ { v:'grid', label:'Wiki grid' },+ { v:'desk', label:'Desktop bar' },+ ].forEach(o => {+ const b = document.createElement('button');+ b.textContent = o.label;+ if (mock.view === o.v) b.classList.add('on');+ b.addEventListener('click', () => { setView(o.v); renderDock(); });+ seg.appendChild(b);+ });+ const legend = document.createElement('div');+ legend.className = 'lint-item lint-warn';+ legend.textContent = 'Amber-outlined = the homepage adds it, it is not in your PNG. Press D to flip.';+ views.append(vlab, seg, legend);++ const checks = document.createElement('div');+ checks.className = 'rv-col';+ if (mock.gatesDone >= 4 && !mock.rvExpanded) {+ // Seasoned reviewer (#45): the steps were walked with eyes on the live+ // preview; the four boxes are ceremony by now. Pre-confirm, one Generate,+ // full checklist one click away. Hard-fail lints would still lock.+ Object.keys(mock.confirm).forEach((k) => { if (!mock.flags[k]) mock.confirm[k] = true; });+ const row = document.createElement('div');+ row.className = 'rv-check ok';+ const q = document.createElement('span');+ q.className = 'q';+ q.textContent = 'You\'ve done this ' + mock.gatesDone + ' times, so the checks are pre-ticked. You saw every step on the way here.';+ const ex = document.createElement('button');+ ex.className = 'btn small';+ ex.textContent = 'Check one by one';+ ex.addEventListener('click', () => { mock.rvExpanded = true; Object.keys(mock.confirm).forEach((k) => { mock.confirm[k] = false; }); renderDock(); });+ row.append(q, ex);+ checks.appendChild(row);+ } else {+ const allb = document.createElement('button');+ allb.className = 'btn small';+ allb.textContent = 'All of it looks good';+ allb.addEventListener('click', () => {+ Object.keys(mock.confirm).forEach((k) => { mock.confirm[k] = true; mock.flags[k] = false; });+ renderDock();+ });+ checks.appendChild(allb);+ CHECKS.forEach(c => {+ const row = document.createElement('div');+ row.className = 'rv-check' + (mock.confirm[c.key] ? ' ok' : '') + (mock.flags[c.key] ? ' flag' : '');+ const q = document.createElement('span');+ q.className = 'q'; q.textContent = c.q;+ const ok = document.createElement('button');+ ok.className = 'btn small' + (mock.confirm[c.key] ? ' okon' : '');+ ok.textContent = 'Looks good';+ ok.addEventListener('click', () => { mock.confirm[c.key] = true; mock.flags[c.key] = false; renderDock(); });+ const fl = document.createElement('button');+ fl.className = 'btn small' + (mock.flags[c.key] ? ' flagon' : '');+ fl.textContent = 'Needs fixing';+ fl.addEventListener('click', () => { mock.flags[c.key] = true; mock.confirm[c.key] = false; renderDock(); toast('In the real app the driving AI sees this flag and adjusts.'); });+ row.append(q, ok, fl);+ checks.appendChild(row);+ });+ }++ const act = document.createElement('div');+ act.className = 'rv-col';+ const lint = document.createElement('div');+ lint.innerHTML = '<div class="ft-label">Layout lint</div>'+ + '<div class="lint-item lint-ok">No layout findings. The studio measures; you decide.</div>';+ act.append(lint);++ wrap.append(views, checks, act);+ host.appendChild(wrap);+}++function fireConfetti(fromEl) {+ const c = document.createElement('canvas');+ c.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999';+ c.width = innerWidth; c.height = innerHeight;+ document.body.appendChild(c);+ const g = c.getContext('2d');+ const b = fromEl.getBoundingClientRect();+ const ox = b.left + b.width / 2, oy = b.top + b.height / 2;+ const colors = ['#00b8b0', '#7a5cff', '#3fb950', '#e6edf3', '#37d6cd'];+ const parts = Array.from({ length: 140 }, (_, i) => ({+ x: ox, y: oy,+ vx: Math.cos(i * 2.399) * (2 + (i % 7)) * (0.6 + (i % 5) / 5),+ vy: Math.sin(i * 2.399) * (2 + (i % 7)) * (0.6 + (i % 5) / 5) - 6,+ s: 3 + (i % 4), r: i * 0.7, vr: ((i % 9) - 4) * 0.12,+ col: colors[i % colors.length],+ }));+ let t = 0;+ (function tick() {+ t++;+ g.clearRect(0, 0, c.width, c.height);+ for (const p of parts) {+ p.x += p.vx; p.y += p.vy; p.vy += 0.22; p.vx *= 0.99; p.r += p.vr;+ g.save(); g.translate(p.x, p.y); g.rotate(p.r);+ g.fillStyle = p.col; g.globalAlpha = Math.max(0, 1 - t / 90);+ g.fillRect(-p.s / 2, -p.s / 2, p.s, p.s * 0.6);+ g.restore();+ }+ if (t < 95) requestAnimationFrame(tick);+ else c.remove();+ })();+}+$('btn-generate').addEventListener('click', () => {+ const btn = $('btn-generate');+ if (btn.disabled) return;+ fireConfetti(btn);+ toast('In the real app this renders the 2000x1250 PNG with the provenance stamp.', 'success');+});++/* ---------- state -> hero ---------- */+let shotData = '';+let heroBg = '';+function pickSource(v) {+ if (v.startsWith('up:')) {+ mock.upload = Number(v.slice(3));+ mock.source = 'upload';+ shotData = mock.uploads[mock.upload].data;+ return;+ }+ mock.source = v;+ if (v === 'upload') { $('file-shot').click(); return; }+ if (v === 'page') {+ shotData = '../generated/latest.png';+ toast('In the real app this pulls the screenshot from the wiki page files.');+ }+ if (v === 'mock') { shotData = ''; mock.full = false; }+}+$('file-shot').addEventListener('change', () => {+ const files = [...$('file-shot').files];+ if (!files.length) return;+ let left = files.length;+ files.forEach((f) => {+ const r = new FileReader();+ r.onload = () => {+ mock.uploads.push({ name: f.name, data: r.result });+ if (--left === 0) {+ mock.upload = mock.uploads.length - 1;+ mock.source = 'upload';+ shotData = mock.uploads[mock.upload].data;+ apply(); renderDock();+ }+ };+ r.readAsDataURL(f);+ });+});+$('file-bg').addEventListener('change', () => {+ const f = $('file-bg').files[0];+ if (!f) return;+ const r = new FileReader();+ r.onload = () => { heroBg = r.result; mock.theme = 'image'; apply(); renderDock(); };+ r.readAsDataURL(f);+});++function subtitleText() {+ const p = pageOf();+ return (mock.subtitle || (p.brief.split('.')[0] + '.')).trim();+}+function subtitleWords() {+ return subtitleText().split(/\s+/).filter(Boolean);+}++function apply() {+ const hero = $('hero');+ const p = pageOf();+ const ir = document.getElementById('intro-ref');+ if (ir) ir.textContent = p.key;++ hero.dataset.theme = mock.theme;+ hero.dataset.layout = mock.layout;+ hero.style.setProperty('--ga', mock.gradAngle + 'deg');+ hero.style.setProperty('--fs-title', mock.fs.title + 'cqw');+ hero.style.setProperty('--fs-sub', mock.fs.sub + 'cqw');+ hero.style.setProperty('--fs-desc', mock.fs.desc + 'cqw');+ hero.style.setProperty('--fs-gap', mock.fs.gap);+ hero.style.setProperty('--bgimg', heroBg ? 'url(' + heroBg + ')' : 'none');++ $('el-title').textContent = mock.headline || p.title;+ $('el-sub').innerHTML = '';+ const words = subtitleWords();+ const on = new Set(mock.accentWords);+ let run = null;+ words.forEach((w, i) => {+ const sel = on.has(i);+ if (sel && run && run.dataset.acc === '1') { run.textContent += ' ' + w; return; }+ if (!sel && run && run.dataset.acc === '0') { run.textContent += ' ' + w; return; }+ if (run) $('el-sub').appendChild(document.createTextNode(' '));+ run = document.createElement('span');+ run.dataset.acc = sel ? '1' : '0';+ if (sel) run.className = 'accent';+ run.textContent = w;+ $('el-sub').appendChild(run);+ });+ $('el-desc').textContent = 'Part of the Adom platform.';+ $('win-label').textContent = p.key.split('/')[1];+ $('l-slug').textContent = 'wiki.adom.inc/' + p.key;+ $('l-pill').textContent = p.type.toUpperCase();+ $('l-pill').className = 'pill ' + p.type;+ $('subj-title').textContent = p.title;+ $('subj-meta').textContent = p.key + ' | ' + p.type + ' | score ' + p.score + ' | ' + p.stars + ' stars';++ $('el-logo').classList.toggle('noadom', p.owner !== 'adom');+ hero.classList.toggle('hide-logo', !mock.show.logo);+ hero.classList.toggle('hide-title', !mock.show.title || mock.titleMode === 'overlay-only');+ hero.classList.toggle('hide-sub', !mock.show.subtitle);+ hero.classList.toggle('hide-desc', !mock.show.description);++ hero.classList.toggle('has-vitrine', !!shotData && mock.source !== 'mock');+ $('shot-img').src = shotData || '';+ hero.classList.toggle('vitrine-cover', mock.cover);+ const hasImport = !!shotData && mock.source !== 'mock';+ const frame = mock.frame === 'auto' ? (hasImport ? 'none' : 'fake') : mock.frame;+ hero.classList.toggle('no-chrome', frame === 'none');+ hero.classList.toggle('bare-chrome', frame === 'bare');++ $('pill-tr').style.display = mock.pill ? '' : 'none';+ $('slug-br').style.display = mock.slug ? '' : 'none';+ hero.classList.toggle('guides', mock.guides);+ hero.classList.toggle('show-mq', mock.mq || mock.view === 'mq');++ hero.classList.toggle('branded', !!(mock.brand.accent || mock.brand.bg));+ hero.style.setProperty('--brand-accent', mock.brand.accent || '#00e6dc');+ hero.style.setProperty('--brand-bg', mock.brand.bg || '#0d1117');++ $('mqo-badges').innerHTML = '<span class="mqo-stat">score ' + p.score + '</span><span class="mqo-stat">' + p.stars + ' stars</span>';+ $('mqo-title').textContent = p.title;+ $('mqo-brief').textContent = p.brief;+ $('mqo-owner').textContent = p.key;++ if (mock.view !== 'png' && mock.view !== 'mq') buildCtx();+ fitHero();+ if (mock.theme === 'circuitry') drawCircuitry();+}++/* ---------- review context views ---------- */+function setView(v) {+ mock.view = v;+ const ctx = v === 'grid' || v === 'desk';+ $('stage').classList.toggle('ctxmode', ctx);+ $('ctxview').classList.toggle('show', ctx);+ apply();+}+function cloneHero() {+ const c = $('hero').cloneNode(true);+ c.removeAttribute('id');+ c.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));+ c.classList.remove('show-mq', 'guides');+ c.style.width = '100%';+ const w = document.createElement('div');+ w.className = 'clonewrap';+ w.appendChild(c);+ return w;+}+function buildCtx() {+ const host = $('ctxview');+ host.textContent = '';+ const p = pageOf();+ if (mock.view === 'grid') {+ const frame = document.createElement('div');+ frame.className = 'ctxframe';+ const grid = document.createElement('div');+ grid.className = 'page-grid';+ const mine = document.createElement('div');+ mine.className = 'page-card';+ const mh = document.createElement('div');+ mh.className = 'page-card-hero';+ mh.appendChild(cloneHero());+ const mb = document.createElement('div');+ mb.className = 'page-card-body';+ mb.innerHTML = '<h3 class="page-card-title">' + p.title + '</h3><p class="page-card-brief">' + p.brief + '</p><div class="page-card-meta">' + p.installs + ' installs</div>';+ mine.append(mh, mb);+ grid.appendChild(mine);+ [['Parts Search','#0f766e'], ['KiCad Tour','#7c2d8f'], ['Shotlog','#1d4ed8']].forEach(([t, c]) => {+ const pc = document.createElement('div');+ pc.className = 'page-card';+ pc.innerHTML = '<div class="page-card-hero"><div class="ts-hero-fallback" style="background:linear-gradient(135deg,' + c + ',#0d1117)">' + t + '</div></div>'+ + '<div class="page-card-body"><h3 class="page-card-title">' + t + '</h3><p class="page-card-brief">Another page on the index grid.</p></div>';+ grid.appendChild(pc);+ });+ frame.appendChild(grid);+ host.appendChild(frame);+ }+ if (mock.view === 'desk') {+ const d = document.createElement('div');+ d.className = 'deskmock';+ d.innerHTML = '<div class="deskhead">Trending on the wiki</div>';+ d.appendChild(cloneHero());+ host.appendChild(d);+ }+}++/* ---------- circuitry ---------- */+function drawCircuitry() {+ const cv = $('circuitcanvas');+ const hero = $('hero');+ cv.width = hero.clientWidth * 2;+ cv.height = hero.clientHeight * 2;+ const g = cv.getContext('2d');+ g.clearRect(0, 0, cv.width, cv.height);+ let s = mock.seed;+ const rnd = () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };+ g.lineWidth = 2;+ for (let i = 0; i < 26; i++) {+ let x = rnd() * cv.width, y = rnd() * cv.height;+ g.strokeStyle = 'rgba(0,184,177,' + (0.05 + rnd() * 0.12) + ')';+ g.beginPath(); g.moveTo(x, y);+ for (let k = 0; k < 4; k++) {+ const len = 40 + rnd() * 220;+ const dir = Math.floor(rnd() * 4);+ if (dir === 0) x += len; else if (dir === 1) x -= len; else if (dir === 2) y += len; else y -= len;+ g.lineTo(x, y);+ }+ g.stroke();+ g.fillStyle = 'rgba(0,230,220,.25)';+ g.beginPath(); g.arc(x, y, 4, 0, 7); g.fill();+ }+}++/* ---------- sizing ---------- */+function fitHero() {+ const stage = $('stage');+ const hero = $('hero');+ const availW = stage.clientWidth - 44;+ const availH = stage.clientHeight - 32;+ hero.style.width = Math.min(1100, availW, availH * 1.6) + 'px';+}+window.addEventListener('resize', () => { fitHero(); if (mock.theme === 'circuitry') drawCircuitry(); });+new ResizeObserver(() => { fitHero(); if (mock.theme === 'circuitry') drawCircuitry(); }).observe($('dock'));++/* ---------- navigation ---------- */+function goTo(id) {+ mock.step = id;+ mock.finetuneOpen = false;+ if (id !== 'review' && (mock.view === 'grid' || mock.view === 'desk')) setView('png');+ renderDock();+ apply();+}+function cycle(dir) {+ const s = activeStep();+ if (!s.options) return;+ const opts = s.options();+ const idx = opts.findIndex(o => o.v === s.get());+ const nx = (idx + dir + opts.length) % opts.length;+ s.set(opts[nx].v);+ apply();+ renderDock();+}+$('arr-l').addEventListener('click', () => cycle(-1));+$('arr-r').addEventListener('click', () => cycle(1));+$('btn-next').addEventListener('click', () => {+ const i = stepIndex(mock.step);+ if (i < STEPS.length - 1) goTo(STEPS[i + 1].id);+});+$('btn-finetune').addEventListener('click', () => { mock.finetuneOpen = !mock.finetuneOpen; renderDock(); });+$('btn-guides').addEventListener('click', () => { mock.guides = !mock.guides; $('btn-guides').classList.toggle('on', mock.guides); apply(); });+$('btn-rand').addEventListener('click', () => {+ mock.layout = LAYOUTS[Math.floor(Math.random() * LAYOUTS.length)].v;+ const th = THEMES.filter(t => t.v !== 'image');+ mock.theme = th[Math.floor(Math.random() * th.length)].v;+ apply(); renderDock();+});++document.addEventListener('keydown', (e) => {+ if (e.target.closest('input,textarea,select')) return;+ if (e.key === 'ArrowLeft') { cycle(-1); e.preventDefault(); }+ else if (e.key === 'ArrowRight') { cycle(1); e.preventDefault(); }+ else if (e.key === 'Enter' && !e.shiftKey) { $('btn-next').click(); }+ else if (e.key === 'Enter' && e.shiftKey) { const i = stepIndex(mock.step); if (i > 0) goTo(STEPS[i - 1].id); }+ else if (e.key >= '1' && e.key <= String(STEPS.length)) { goTo(STEPS[Number(e.key) - 1].id); }+ else if (e.key === 'd' || e.key === 'D') { mock.mq = !mock.mq; apply(); }+});++/* ---------- headline fit ---------- */+function fitTitle() {+ const el = $('el-title');+ const lh = () => parseFloat(getComputedStyle(el).lineHeight);+ let size = 13;+ mock.fs.title = size; apply();+ while (size > 5 && el.scrollHeight > lh() * 3.2) {+ size -= 0.2;+ mock.fs.title = Math.round(size * 10) / 10;+ apply();+ }+ toast('Fitted: ' + mock.fs.title + ' is the biggest the title can be here.');+ renderDock();+}++/* ---------- boot ---------- */+$('shot-img').addEventListener('error', () => {+ if (mock.source === 'page') { shotData = ''; mock.source = 'mock'; apply(); renderDock(); toast('No page screenshot in this mockup; back to the drawn mock.'); }+});+const q = new URLSearchParams(location.search);+if (q.get('page')) mock.page = q.get('page');+if (q.get('gates')) mock.gatesDone = Number(q.get('gates')) || 0;+mock.titleMode = 'image-only'; // composed heroes always bake the title (#43/#80); the field survives on the wire only+if (q.get('layout')) mock.layout = q.get('layout');+if (q.get('theme')) mock.theme = q.get('theme');+if (q.get('step')) mock.step = q.get('step');+if (q.get('ft')) mock.finetuneOpen = true;+if (q.get('mq')) mock.mq = true;+if (q.get('confirm')) { Object.keys(mock.confirm).forEach(k => mock.confirm[k] = true); }+if (q.get('view')) setView(q.get('view')); else apply();+renderDock();+</script>+</body>+</html>
public/studio.cssadded+359@@ -0,0 +1,359 @@+/* studio.css: the app chrome around the hero (header, panel, gate bars,+ modal, toasts, tooltips). Redesign work happens here and only here. */++++/* ---------- 44px header, three zones ---------- */+header {+ height:44px; flex:0 0 44px; display:flex; align-items:center; gap:12px;+ padding:0 12px; background:var(--surface); border-bottom:1px solid var(--border);+}++.brand { display:flex; align-items:center; gap:8px; white-space:nowrap; }++.brand svg { width:16px; height:16px; stroke:var(--text); }++.brand .name { font-family:'Familjen Grotesk'; font-weight:500; font-size:14px; color:var(--accent); }++.brand .sub { color:var(--text-2); font-size:13px; }++.vsep { width:1px; height:22px; background:var(--border); flex:0 0 1px; }++.subject { display:flex; align-items:baseline; gap:10px; min-width:0; }++.subject b { font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }++.subject .meta { font-family:'JetBrains Mono',monospace; font-size:11.5px; color:var(--text-2); white-space:nowrap; }++.actions { margin-left:auto; display:flex; gap:6px; }++.iconbtn {+ width:30px; height:30px; border:1px solid var(--border); border-radius:8px; background:transparent;+ display:flex; align-items:center; justify-content:center; cursor:pointer; transition:all .15s ease;+}++.iconbtn svg { width:16px; height:16px; stroke:var(--text); fill:none; stroke-width:1.8; }++.iconbtn:hover { background:var(--elevated); border-color:var(--text-3); }++.iconbtn.on { border-color:var(--accent); background:rgba(0,184,176,.08); }++@media (max-width:720px){ .subject .meta { display:none; } }+++/* ---------- body ---------- */+main { flex:1; display:flex; min-height:0; }++#panel {+ flex:0 0 264px; overflow-y:auto; background:var(--surface); border-right:1px solid var(--border);+ padding:14px 14px 40px;+}++#panel h3 {+ font-family:'Familjen Grotesk'; font-weight:500; font-size:11px; letter-spacing:.08em;+ text-transform:uppercase; color:var(--text-2); margin:18px 0 8px;+}++#panel h3:first-child { margin-top:2px; }++select {+ width:100%; background:var(--elevated); color:var(--text); border:1px solid var(--border);+ border-radius:8px; padding:7px 8px; font-family:'Satoshi'; font-size:13px; cursor:pointer;+}++.optgrid { display:flex; flex-wrap:wrap; gap:6px; }++.opt {+ border:1px solid var(--border); background:var(--elevated); color:var(--text-2); border-radius:8px;+ padding:6px 9px; font-size:12.5px; cursor:pointer; transition:all .15s ease; font-family:'Satoshi';+}++.opt:hover { color:var(--text); border-color:var(--text-3); }++.opt.on { color:var(--text); border-color:var(--accent); background:rgba(0,184,176,.10); }++.swatches { display:flex; gap:6px; }++.swatch {+ width:38px; height:26px; border-radius:6px; border:1px solid var(--border); cursor:pointer;+ transition:all .15s ease; position:relative;+}++.swatch.on { outline:2px solid var(--accent); outline-offset:1px; }++.sw-midnight { background:linear-gradient(155deg,#0a0e14,#0d1117 45%,#071318); }++.sw-image { background:repeating-conic-gradient(#1c2530 0 25%, #0d1117 0 50%) 0 0/8px 8px; }++.sw-pcb { background:linear-gradient(155deg,#03211f,#04302c); }++.sw-onion { background:repeating-radial-gradient(circle at 90% 10%, #101a2c 0 4px, #16223a 4px 6px); }++.sw-pill { background:#0c3a3c; }++.sw-grid { background:#0c3a3c url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><rect x="2" y="2" width="8" height="8" fill="none" stroke="%23ffffff" stroke-opacity=".35" stroke-width="1"/></svg>'); }++.sw-grad { background:linear-gradient(100deg,#00e6dc,#39b8ff 60%,#8c6bf7); }++input[type=range] { width:100%; accent-color:var(--accent); cursor:pointer; }++label.chk { display:flex; align-items:center; gap:8px; padding:5px 0; cursor:pointer; color:var(--text); font-size:13px; }++label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:pointer; }++.hint { font-size:11.5px; color:var(--text-2); line-height:1.45; margin-top:6px; }++.btn {+ width:100%; margin-top:8px; padding:8px 10px; border-radius:8px; border:1px solid var(--border);+ background:var(--elevated); color:var(--text); font-family:'Satoshi'; font-size:13px; cursor:pointer;+ transition:all .15s ease;+}++.btn:hover { border-color:var(--accent); background:rgba(0,184,176,.08); }+++/* ---------- stage + hero canvas ---------- */+#stage { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:18px; padding:22px; min-width:0; overflow:auto; }++/* the stagebar (#21/#23): stage chrome lives ABOVE the stage, never on it */+#stagebar { display:none; align-items:center; gap:10px; margin:10px 0 8px; }++#stagebar.on { display:flex; }++#stagebar .seg { display:flex; border:1px solid #30363d; border-radius:8px; overflow:hidden; }++#stagebar .seg button { background:#0d1117; color:var(--text); border:0; padding:6px 12px; font-size:12.5px; cursor:pointer; }++#stagebar .seg button.on { background:rgba(0,184,176,.18); color:var(--accent-bright); }++#stagebar .legend { font-size:12px; color:#8b949e; line-height:1.35; }++#stagebar .legend b { color:#e8a13c; font-weight:600; }++.brandrow { display:flex; align-items:center; gap:8px; margin:6px 0; }++.brandrow label { font-size:12px; color:var(--text-2); width:84px; }++.brandrow input[type=color] { width:44px; height:26px; border:1px solid var(--border); border-radius:6px; background:var(--surface); padding:1px; cursor:pointer; }+++#approvedbar { display:none; margin:0 0 14px; border:1px solid #2ea06b; background:rgba(46,160,107,.09);+ border-radius:10px; padding:14px 16px; }++#approvedbar .ab-head { font-family:'Familjen Grotesk'; font-weight:700; color:#4ade80; margin-bottom:6px; }++#approvedbar .ab-body { font-size:13px; color:var(--text); line-height:1.5; margin-bottom:10px; }++#genmodal .gen-approved { font-family:'Familjen Grotesk'; font-weight:700; color:#4ade80; margin-bottom:6px; }++#verifybar { display:none; margin:0 0 14px; border:1px solid var(--accent); background:rgba(0,184,176,.07); border-radius:10px; padding:14px 16px; }++#verifybar .vhead { font-size:13px; color:var(--text); margin-bottom:10px; line-height:1.5; }++#verifybar .vchk { display:block; margin:6px 0; }++#verifybar .vchk .warn { color:#e8a13c; }++#verifybar .vchk .flagged { color:#f85149; font-weight:600; margin-left:6px; }++#v-generate { margin-top:10px; font-size:14px; padding:10px 18px; }++#v-generate:disabled { opacity:.45; cursor:not-allowed; }++/* the layout linter (#18): the studio notices, the human decides */+#lintbox { margin-top:8px; }++#lintbox .lint-item { font-size:12.5px; margin:4px 0; cursor:pointer; line-height:1.4; }++#lintbox .lint-warn { color:#e8a13c; }++#lintbox .lint-fail { color:#f85149; font-weight:600; }++#lintbox .lint-ok { color:#3fb950; font-size:12px; }++#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; }++/* page-field editors (#20) */+.pf-in { width:100%; box-sizing:border-box; background:#0d1117; color:var(--text); border:1px solid #30363d; border-radius:6px; padding:6px 8px; font:inherit; font-size:12.5px; margin:2px 0 8px; }++.pf-in:focus { border-color:var(--accent); outline:none; }++/* zen focus (#12): the hero gets the whole surface */+body.zen #panel { display:none; }++body.zen main { max-width:none; }++.sw-aurora { background:radial-gradient(8px 6px at 30% 20%, rgba(0,230,220,.8), transparent), radial-gradient(10px 8px at 70% 80%, rgba(140,107,247,.7), transparent), #0a0f16; }++.sw-mesh { background:conic-gradient(from 155deg at 40% 40%, rgba(0,184,177,.5), transparent 40%), #0b1016; }++.sw-circuitry { background:repeating-linear-gradient(45deg, rgba(0,184,177,.5) 0 1px, transparent 1px 5px), #080d13; }+++/* verification walkthrough popout */+#tourpop { display:none; position:fixed; z-index:900; max-width:300px;+ background:var(--elevated); border:1px solid var(--accent); border-radius:10px;+ padding:12px 14px; box-shadow:0 12px 40px rgba(0,0,0,.6); font-size:13px; }++#tourpop .tp-title { font-family:'Familjen Grotesk'; font-weight:700; margin-bottom:6px; color:var(--accent-bright); }++#tourpop .tp-body { color:var(--text); line-height:1.45; margin-bottom:10px; }++#tourpop .tp-row { display:flex; gap:8px; }++.tour-above { position:relative !important; z-index:901 !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; }+++/* generated-hero preview modal */+#genmodal { position:fixed; inset:0; z-index:99997; background:rgba(5,9,14,.8); display:none; align-items:center; justify-content:center; padding:32px; }++#genmodal.show { display:flex; }++.genbox { max-width:min(1000px, 92vw); background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:14px; box-shadow:0 24px 80px rgba(0,0,0,.6); }++.genbox img { display:block; width:100%; border-radius:8px; border:1px solid var(--border); }++.genrow { display:flex; align-items:center; gap:10px; margin-top:10px; }++.genrow .hint { margin:0; flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }++.genrow a { color:var(--accent); font-size:13px; text-decoration:none; }++.genrow a:hover { color:var(--accent-bright); }++.btn-primary { border-color:var(--accent); background:rgba(0,184,176,.12); font-weight:500; }++.btn-primary:hover { background:rgba(0,184,176,.2); }++.btn-primary:disabled { opacity:.55; cursor:wait; }+++/* tooltip + toast */+#tip {+ position:fixed; z-index:99999; background:var(--overlay); border:1px solid var(--border);+ color:var(--text); font-size:12px; line-height:1.4; padding:6px 9px; border-radius:8px;+ max-width:240px; pointer-events:none; opacity:0; transform:translateY(3px);+ transition:opacity .18s ease, transform .18s ease; text-transform:none;+}++#tip.show { opacity:1; transform:none; }++#toasts { position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:99998; display:flex; flex-direction:column-reverse; gap:8px; }++.toast {+ display:flex; align-items:center; gap:8px; background:var(--overlay); border:1px solid var(--border);+ border-left:3px solid var(--accent); border-radius:8px; padding:9px 13px; font-size:13px;+ box-shadow:0 8px 30px rgba(0,0,0,.45); animation:tin .2s ease;+}++.toast.success { border-left-color:var(--green); }++.toast.error { border-left-color:var(--red); }++@keyframes tin { from { opacity:0; transform:translateY(6px);} }++/* ---------- the wizard dock (M4): default chrome; classic is one toggle away ---------- */+body.wizard #panel { display:none; }+body.wizard #tourpop { display:none !important; }+body.wizard #verifybar { display:none !important; }+body.classic #wdock, body.classic #weditring { display:none !important; }+#wdock { flex:0 0 auto; background:var(--surface); border-top:1px solid var(--border); padding:10px 18px 14px;+ display:flex; flex-direction:column; gap:10px; height:min(44vh, 330px); overflow-y:auto; scrollbar-width:thin; }+#wdockintro { font-size:12px; color:var(--text-2); padding:0 2px 2px; }+#wdockintro b { color:var(--text); font-weight:600; }+#wdockintro .ref { font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--accent); }+#wrailwrap { position:relative; min-width:0; }+#wrailwrap::before, #wrailwrap::after { content:''; position:absolute; top:0; bottom:2px; width:36px; pointer-events:none; opacity:0; transition:opacity .15s ease; z-index:2; }+#wrailwrap::before { left:0; background:linear-gradient(90deg, var(--surface) 20%, transparent); }+#wrailwrap::after { right:0; background:linear-gradient(270deg, var(--surface) 20%, transparent); }+#wrailwrap.can-left::before { opacity:1; }+#wrailwrap.can-right::after { opacity:1; }+#wsteprail { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; scrollbar-width:none; }+#wsteprail > :first-child { margin-left:auto; }+#wsteprail > :last-child { margin-right:auto; }+#wsteprail::-webkit-scrollbar { display:none; }+#wsteprail.compact .schip .v { display:none; }+#wsteprail.mini .schip:not(.active) .t { display:none; }+#wsteprail.mini .schip:not(.active) { padding:4px 8px 4px 5px; }+.schip { display:flex; align-items:center; gap:8px; border:1px solid var(--border); background:var(--elevated); border-radius:999px;+ padding:4px 12px 4px 5px; cursor:pointer; white-space:nowrap; transition:all .15s ease; font-family:'Satoshi'; }+.schip:hover { border-color:var(--text-3); }+.schip .n { width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center;+ font-size:11px; font-family:'JetBrains Mono',monospace; background:var(--overlay); color:var(--text-2); border:1px solid var(--border); flex:0 0 20px; }+.schip .n svg { width:11px; height:11px; stroke:var(--green); stroke-width:2.5; fill:none; }+.schip .t { font-size:12.5px; color:var(--text-2); }+.schip .v { font-size:11px; color:var(--text-3); max-width:110px; overflow:hidden; text-overflow:ellipsis; }+.schip.active { border-color:var(--accent); background:rgba(0,184,176,.10); }+.schip.active .t { color:var(--text); font-weight:500; }+.schip.active .n { background:var(--accent); color:#04211f; border-color:transparent; }+.schip.done .n { background:rgba(63,185,80,.12); border-color:rgba(63,185,80,.45); }+#wdockhead { display:flex; align-items:center; gap:8px 12px; min-height:26px; flex-wrap:wrap; }+#wdockhead .wdtitle { font-family:'Familjen Grotesk'; font-weight:700; font-size:15px; color:var(--text); }+#wdockhead .wdcount { font-family:'JetBrains Mono',monospace; font-size:11px; color:var(--text-2); }+#wdockhead .wdhint { font-size:12px; color:var(--text-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0; }+#wbtn-generate { font-size:15px; font-weight:600; padding:10px 26px; border-radius:10px; }+#wbtn-generate:not(:disabled) { box-shadow:0 0 18px rgba(0,184,176,.35); }+#wcardsrow { display:flex; gap:10px; align-items:stretch; min-height:0; flex:1; }+#wcards { display:flex; gap:10px; overflow-x:auto; flex:1; scrollbar-width:none; align-items:stretch; padding:2px; }+#wcards::-webkit-scrollbar { display:none; }+#wcards > :first-child { margin-left:auto; }+#wcards > :last-child { margin-right:auto; }+.warrow { flex:0 0 44px; border:1px solid var(--border); background:var(--elevated); border-radius:10px; color:var(--text-2);+ font-size:20px; cursor:pointer; }+.warrow:hover { border-color:var(--text-3); color:var(--text); }+#wcards .card { flex:0 0 auto; width:128px; border:1px solid var(--border); background:var(--elevated); border-radius:10px;+ padding:10px; cursor:pointer; display:flex; flex-direction:column; gap:4px; justify-content:center; text-align:center; transition:all .15s ease; }+#wcards .card:hover { border-color:var(--text-3); }+#wcards .card.on { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); }+#wcards .card.wide { width:170px; }+#wcards .card .card-label { font-size:13px; color:var(--text); }+#wcards .card.on .card-label { color:var(--accent); }+#wcards .card .card-sub { font-size:11px; color:var(--text-3); line-height:1.35; }+#weditring { position:absolute; border:2px dashed var(--accent); border-radius:10px; pointer-events:none;+ z-index:9; opacity:0; transition:opacity .25s ease, top .2s ease, left .2s ease, width .2s ease, height .2s ease;+ box-shadow:0 0 22px rgba(0,184,176,.18); }+#weditring.on { opacity:.85; }+.wiz-lab { font-size:11px; color:var(--text-2); letter-spacing:.06em; text-transform:uppercase; font-family:'Familjen Grotesk'; }+.wiz-headline { flex:1; display:flex; flex-direction:column; gap:7px; max-width:560px; margin:0 auto; }+.wiz-headline .hin { background:var(--overlay); border:1px solid var(--border); border-radius:8px; color:var(--text);+ padding:8px 12px; font-size:14px; font-family:'Satoshi'; }+.wordpick { display:flex; flex-wrap:wrap; gap:4px; max-width:560px; }+.wtok { padding:2px 8px; border:1px solid var(--border); border-radius:6px; font-size:12px; cursor:pointer;+ color:var(--text-2); background:var(--elevated); transition:all .12s ease; user-select:none; }+.wtok:hover { border-color:var(--text-3); }+.wtok.on { border-color:var(--accent); background:var(--grad); -webkit-background-clip:text; background-clip:text; color:transparent; font-weight:600; }+.wiz-finish { flex:1; display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap; }+.wiz-review { flex:1; display:flex; gap:14px 22px; align-items:flex-start; justify-content:center; min-width:0; flex-wrap:wrap; }+.wiz-review .rv-col { display:flex; flex-direction:column; gap:8px; min-width:230px; }+.rv-check { display:flex; align-items:center; gap:10px; border:1px solid var(--border); border-radius:9px; padding:7px 10px; }+.rv-check .q { flex:1; font-size:12.5px; color:var(--text-2); }+.rv-check.ok { border-color:rgba(63,185,80,.5); }+.rv-check.ok .q { color:var(--green); }+.rv-check.flag { border-color:rgba(248,81,73,.5); }+.wseg { display:flex; border:1px solid var(--border); border-radius:9px; overflow:hidden; width:fit-content; }+.wseg button { background:var(--elevated); border:none; color:var(--text-2); font-size:12px; padding:7px 12px; cursor:pointer; }+.wseg button.on { background:rgba(0,184,176,.14); color:var(--accent); }+.wiz-legend { font-size:11.5px; color:#e8a13c; max-width:280px; }+/* wizard layout: header / stage / dock as one column; the dock owns the+ bottom edge and the stage flexes (its own overflow already scrolls) */+body.wizard { display:flex; flex-direction:column; height:100vh; overflow:hidden; }+body.wizard main { flex:1 1 auto; min-height:0; }+/* dockhead buttons: right-aligned cluster, never stretched */+#wdockhead .wdhint { order:0; }+#wbtn-finetune, #wbtn-generate, #wbtn-next { flex:0 0 auto; width:auto; margin-left:0; }+#wbtn-generate { margin-left:auto; }+#wbtn-next { margin-left:auto; }+#wbtn-generate + #wbtn-next { margin-left:8px; }+.rv-check .q { min-width:150px; }+.wiz-review .rv-col { flex:0 1 440px; }+/* render mode exports HERO PIXELS ONLY: the wizard is stage chrome */+body[data-render] #wdock, body[data-render] #weditring { display:none !important; }
server.js+170−17@@ -3,6 +3,7 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process');+const crypto = require('crypto'); // Port: --port flag > HERO_STUDIO_PORT env > 8846 ('auto' = bind an OS-picked // free port). Multiple instances on separate ports = concurrent studios@@ -57,6 +58,8 @@ let state = { 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 headline: '', // what BAKES as the hero title; '' = use the page title (hero-studio#33: title != headline)+ subtitle: '', // the text under the title; '' = the page brief's first sentence (wizard round 3)+ accentWords: null, // subtitle word indices that get the gradient; null = legacy auto-accent (pixel parity) approved: null, // { page, file, ts } set on Generate: the persistent gate-closed record gradAngle: 155, guides: true,@@ -80,7 +83,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', 'overlay.logo',- 'brand.accent', 'brand.bg', 'fontSize.title', 'fontSize.sub', 'fontSize.desc', 'fontSize.leading', 'fontSize.gap', 'headline',+ 'brand.accent', 'brand.bg', 'fontSize.title', 'fontSize.sub', 'fontSize.desc', 'fontSize.leading', 'fontSize.gap', 'headline', 'subtitle', 'accentWords', 'vitrine.chrome', 'vitrine.full', 'vitrine.cover', ]; const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);@@ -105,6 +108,19 @@ function notePoseDiff(before) { // carries the constants pup measured and kicad/fusion/nb copied (title 12cqw, // bare right-side vitrine, 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+ // longer existed).+ 'studio-default': {+ mode: 'app',+ layout: 'bleed-bottom', theme: 'midnight', titleMode: 'image-only',+ show: { logo: true, title: true, subtitle: true, description: false },+ overlay: { pill: true, scores: false, author: true, slug: false, logo: false },+ brand: { accent: '', bg: '' },+ fontSize: { title: 8, sub: 2.5, desc: 1.35, leading: 1.02, gap: 1 },+ headline: '', gradAngle: 155,+ vitrine: { chrome: '', cover: false },+ }, 'bridge-family': { layout: 'showcase', theme: 'midnight', titleMode: 'image-only', fontSize: { title: 12, sub: 2.2, desc: 1.35 },@@ -130,8 +146,41 @@ let genSeq = Date.now(); let lastGen = null; let generating = false; +// Transport rev: every observable change bumps it. GET /state carries it as+// an ETag so pollers 304 instead of re-downloading multi-MB bodies; the+// serialized payload is cached per rev and variant.+let stateRev = 1;+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.+const srcHashCache = new Map();+function srcHash(src) {+ let h = srcHashCache.get(src);+ if (!h) {+ h = crypto.createHash('sha1').update(src).digest('hex').slice(0, 12);+ srcHashCache.set(src, h);+ if (srcHashCache.size > 4) srcHashCache.delete(srcHashCache.keys().next().value);+ }+ return h;+}+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.bgImage && state.bgImage.src && state.bgImage.src.startsWith('data:')) {+ out.bgImage = Object.assign({}, state.bgImage, { src: '/bg.png?v=' + srcHash(state.bgImage.src) });+ }+ return out;+}+ function broadcast(event, data) {- const msg = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n';+ stateRev += 1;+ const payload = event === 'state' ? leanState() : data;+ const msg = 'event: ' + event + '\ndata: ' + JSON.stringify(payload) + '\n\n'; for (const res of clients) res.write(msg); if (event === 'state') saveSnapshot(); }@@ -235,6 +284,57 @@ const MIME = { // Read-only endpoints stay open (observation and debugging are never gated); // every state-changing POST must carry the ai-thread name. const THREAD_EXEMPT = new Set(['/console', '/events']);++// #44: the strings the surface is actually rendering, plus their source, so+// a driving AI can verify what the human sees without reading pixels.+function firstSentenceSrv(str, max) {+ let t = (str || '').split(/(?<=\.)\s/)[0] || str || '';+ if (t.length > max) t = t.slice(0, max).replace(/\s+\S*$/, '') + '...';+ return t;+}+function resolvedText() {+ const [owner, slug] = String(state.page || '').split('/');+ const row = PAGES.find((x) => x.owner === owner && x.slug === slug) || {};+ const rowSrc = row.live ? 'live-wiki' : 'fixture';+ return {+ title: row.title || '',+ headline: state.headline || row.title || '',+ subtitle: state.subtitle || firstSentenceSrv(row.brief, 80),+ titleSource: rowSrc,+ headlineSource: state.headline ? 'override' : rowSrc,+ subtitleSource: state.subtitle ? 'override' : rowSrc,+ };+}++// Seasoned-review gate count (#45, Colby: same-session automation only; the+// count feeds the pre-ticked checklist). Container-local v1; per-account+// needs wiki identity (backend ask filed separately).+const GATES_FILE = path.join(INSTANCES_DIR, 'gates.json');+function readGates() {+ try { return JSON.parse(fs.readFileSync(GATES_FILE, 'utf8')).count || 0; } catch { return 0; }+}+function bumpGates() {+ try { fs.writeFileSync(GATES_FILE, JSON.stringify({ count: readGates() + 1, updated: new Date().toISOString() })); } catch {}+}++// Live wiki re-fetch for a page row, shared by /refresh-page, the dropdown,+// and now raw POST /state page changes (#44: the fixture-row hole).+async function refreshPageLive(ref) {+ 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 {}+ const r = await fetch(REG + '/api/pages/' + ref, { headers, redirect: 'follow' });+ if (!r.ok) throw new 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, live: true };+ 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 + '"');+ return pg;+} const server = http.createServer((req, res) => { const url = new URL(req.url, 'http://x'); const p = url.pathname;@@ -263,8 +363,32 @@ const server = http.createServer((req, res) => { function route(req, res, url, p) { if (p === '/state' && req.method === 'GET') {- res.writeHead(200, { 'Content-Type': 'application/json' });- return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD })));+ const lean = url.searchParams.get('src') === 'ref';+ const variant = lean ? 'lean' : 'full';+ const etag = '"' + stateRev + '-' + variant + '"';+ if (req.headers['if-none-match'] === etag) {+ res.writeHead(304, { ETag: etag });+ return res.end();+ }+ const cached = stateBodyCache[variant];+ let body;+ if (cached && cached.rev === stateRev) body = cached.body;+ else {+ body = JSON.stringify(Object.assign({}, lean ? leanState() : state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD, gatesDone: readGates(), resolved: resolvedText() }));+ stateBodyCache[variant] = { rev: stateRev, body };+ }+ 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 (!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';+ const raw = src.slice(src.indexOf(',') + 1);+ const buf = (m && m[2]) ? Buffer.from(raw, 'base64') : Buffer.from(decodeURIComponent(raw));+ res.writeHead(200, { 'Content-Type': mime, 'Content-Length': buf.length, 'Cache-Control': 'public, max-age=31536000, immutable' });+ return res.end(buf); } if (p === '/version') { res.writeHead(200, { 'Content-Type': 'application/json' });@@ -279,6 +403,7 @@ function route(req, res, url, p) { return res.end(JSON.stringify({ ok: false, error: 'invalid ' + k + ' \'' + body[k] + '\'', valid })); } }+ const pageChanged = typeof body.page === 'string' && body.page !== state.page; 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, logo: false };@@ -286,6 +411,11 @@ function route(req, res, url, p) { } const before = snapshotPose(); merge(state, body);+ if (pageChanged) {+ refreshPageLive(state.page)+ .then(() => { stateRev += 1; broadcast('state', state); })+ .catch((e) => logLine('warn', 'live refresh failed for ' + state.page + ': ' + e.message + ' (fixture row shown until it succeeds)'));+ } notePoseDiff(before); logLine('info', 'state <- ' + JSON.stringify(body).slice(0, 500)); broadcast('state', state);@@ -355,7 +485,7 @@ function route(req, res, url, p) { 'X-Accel-Buffering': 'no', }); res.write(':' + ' '.repeat(2048) + '\n\n'); // flush proxy buffers- res.write('event: state\ndata: ' + JSON.stringify(state) + '\n\n');+ res.write('event: state\ndata: ' + JSON.stringify(leanState()) + '\n\n'); clients.add(res); const hb = setInterval(() => res.write(': hb\n\n'), 15000); req.on('close', () => { clearInterval(hb); clients.delete(res); });@@ -458,16 +588,7 @@ function route(req, res, url, p) { 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 + '"');+ const pg = await refreshPageLive(ref); broadcast('state', state); finish(200, { ok: true, title: pg.title, brief: pg.brief }); } catch (e) { finish(200, { ok: false, error: e.message }); }@@ -611,6 +732,35 @@ function route(req, res, url, p) { // body {"renderer":"local"|"pup"} pins one ladder leg for this request // (the test hook for the pup path); HERO_RENDERER env pins it server-wide. const pinned = (!bodyErr && body && body.renderer) || process.env.HERO_RENDERER;+ const attest = (!bodyErr && body && body.attest) || null;+ const fromGate = !bodyErr && body && body.fromGate === true;+ const refuse = (error, hint) => {+ res.writeHead(200, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify({ ok: false, errorCode: 'HUMAN_GATE', error, hint }));+ };+ // Hard-fail lint findings lock EVERY Generate path, gate or attested.+ if ((state.lint || []).some((l) => l.level === 'fail')) {+ return refuse('hard-fail layout findings lock Generate',+ 'fix the failing items in state.lint first; they lock the human gate and automated mode alike');+ }+ if (!fromGate) {+ // Automated mode (#45, decided by Colby 2026-08-13): SAME-SESSION+ // REPEATS ONLY. The first Generate of a page is always human-clicked;+ // after that, the driving AI may re-generate THAT page with a recorded+ // attestation of the human's actual approval words.+ if (!attest) {+ return refuse('Generate is human-gated: the human clicks it in the studio',+ 'a human must generate this page first (hero-studio pose opens the gate). To RE-generate a page the human already generated this session, POST /generate with attest: {human: "<who approved>", quote: "<their actual words>"}');+ }+ if (!(state.approved && state.approved.page === state.page)) {+ return refuse('automated Generate is only allowed for a page a human already generated this session',+ 'pose ' + state.page + ' and let the human click Generate once; after that, attested re-generates of the same page are allowed');+ }+ if (!attest.human || !String(attest.quote || '').trim()) {+ return refuse('the attestation must name the human and quote their actual approval words',+ 'attest: {human: "colby", quote: "ship it"} -- the quote is recorded in the PNG provenance and shown in the Approved banner');+ }+ } generating = true; const slug = state.mode === 'component' ? ((COMPONENTS.find(c => c.id === state.comp.id) || COMPONENTS[0]).slug)@@ -705,7 +855,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 }, 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, 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 } : 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' +@@ -727,7 +877,10 @@ function route(req, res, url, p) { genSeq += 1; lastGen = { seq: genSeq, ok: true, file, url: 'generated/' + file, latest: 'generated/latest.png', ts, slug }; // hero-studio#5: approval must be a persistent state, not a toast.- state.approved = { page: state.page, file, ts };+ state.approved = attest+ ? { page: state.page, file, ts, attested: { human: attest.human, quote: String(attest.quote).slice(0, 300) } }+ : { page: state.page, file, ts };+ if (fromGate) bumpGates(); broadcast('state', state); logLine('info', 'generated ' + file); broadcast('generated', lastGen);