← Commit history
SKILL.md+55−40
@@ -1,75 +1,90 @@ --- name: hero-studio-description: Open and drive Hero Studio, the live playground for the hero image v2 definition (lower third = true-text overlay). Use when the user wants to design or preview a wiki hero image, try hero layouts, screenshot bleeds, brand tile themes, or gradient angles, compare title placement (overlay vs image), or see a hero inside the wiki homepage marquee, the index card grid, or an adom-desktop style right bar. Trigger phrases - open hero studio, design a hero, hero playground, hero billboard, preview hero in marquee, lower third overlay, hero layouts, hero themes, rotate the hero gradient.+description: Open and drive Hero Studio, the hero-image lab with a human gate. Use when a package needs a hero image, when a publish is rejected with HERO_STUDIO_REQUIRED, or when the user wants to design or preview a wiki hero, try layouts, themes, owner brand colors, or see a hero inside the wiki marquee and index grid. Trigger phrases - open hero studio, design a hero, make a hero image, hero playground, pose a hero, preview hero in marquee, hero layouts, hero themes. user-invocable: true ---  # Hero Studio -A local web app (plain Node, no dependencies) that renders a full 16:10 hero live and-shows it inside pixel-faithful wiki contexts. State lives on the server, so you drive-everything with plain HTTP.+A local web app (plain Node, no dependencies) that renders a full 16:10 hero live in+pixel-faithful wiki contexts, and generates the provenance-stamped 2000x1250 PNG the+registry's hero gate requires. -## Start and show it+## The one rule: a human makes the hero++An AI never generates a hero on the human's behalf. The canonical flow is the pose+gate, which opens the studio in front of the human and BLOCKS until they click+Generate:  ```bash-hero-studio &                      # port 8846 (or: node server.js in the package dir)+hero-studio pose <owner>/<slug> [--title "T"] [--brief "one-liner"] [--timeout 900]+# blocks; exits 0 with {file, path, latest} JSON on stdout when the HUMAN generates,+# exit 2 on timeout, exit 1 on setup errors ``` -Show it in a Hydrogen webview using the container proxy URL, for example:+`pose` starts the server if needed, registers the package with the studio (no restart+needed), poses it, opens the "Hero Studio" webview tab with proxy mode off (proxy mode+silently blocks all page JS), and waits. When it returns, apply the human's image:  ```bash-adom-cli hydrogen webview open-or-refresh --name "Hero Studio" \-  --url "$(echo $VSCODE_PROXY_URI | sed 's/{{port}}/8846/')" --panel-id <webview-pane-id>+adom-wiki page hero <owner>/<slug> --image <path from pose output> ``` -Pick a webview pane, never the VS Code pane. Screenshot-verify with-`adom-cli hydrogen screenshot panel --name "Hero Studio"`.+Do not call POST /generate yourself to produce a hero; that bypasses the human and+the result should not be published. Publishes of new app/skill heroes are rejected+server-side (HERO_STUDIO_REQUIRED) unless the PNG carries the studio provenance stamp.++## What the human controls++- Layout (screenshot bleeds, contained, mirror), theme, gradient angle.+- Title placement: image-only is the default; the title lives in the IMAGE.+- Owner brand: adom-org pages get the house wordmark and palette automatically.+  Every other owner gets no Adom mark, no placeholder, and Accent + Background+  color pickers instead. Placement is fixed for everyone; the palette is theirs.+- Image-zone text: wordmark, title, value-prop subtitle, detail line. -## Drive it+## What exports, and what the page overlays++The exported PNG contains image content only: background art, title, subtitle,+screenshot. The lower-third band in the studio preview (type pill top right,+description, author, full owner/slug ref) is a SIMULATION of the live overlay the+wiki page renders at view time; it never bakes into the PNG, and live stats+(trending/stars/installs) default off in the sim because the marquee replaces them.+The description has one home: image subtitle when "Value-prop subtitle" is on,+otherwise the overlay band.++## Drive the posing state (posing aid, not generation)  ```bash curl -s localhost:8846/state                       # read state curl -s -X POST localhost:8846/state -H 'Content-Type: application/json' \   -d '{"layout":"mirror","theme":"onion","gradAngle":245}'-curl -s -X POST localhost:8846/ui/toast -H 'Content-Type: application/json' \-  -d '{"text":"narration for the user","kind":"info"}'-curl -s localhost:8846/console                     # page console, no DevTools+curl -s -X POST localhost:8846/api/pages -H 'Content-Type: application/json' \+  -d '{"owner":"<o>","slug":"<s>","title":"T","brief":"..."}'   # runtime upsert+curl -s localhost:8846/console                     # page console relay ``` -State fields: `page` (owner/slug from the bundled trending data), `layout` (bleed-bottom,-bleed-right, bleed-top, bleed-corner-br, bleed-corner-tr, contained, mirror), `theme`-(midnight, pcb, onion, pill, grid, gradient), `gradAngle` (0 to 360, brand default 155),-`titleMode` (overlay-only, image-only, both), `show.logo/title/subtitle/description`,-`overlay.pill/scores/author/slug`, `guides`.--The UI follows within 1 second of any POST (live channel plus poll fallback). Deep links:-append `#contexts` or `#wikigrid` to the URL to land on the in-context previews.--## Generate the actual hero PNG (the point of the app)+State fields: `page` (owner/slug), `layout`, `theme`, `gradAngle`, `titleMode`,+`show.logo/title/subtitle/description`, `overlay.pill/scores/author/slug`,+`brand.accent/brand.bg` (non-adom owner palette), `guides`. -Do NOT hand-roll a hero render: pose the studio via POST /state, then generate through the-studio so the PNG carries hero-studio provenance metadata and the user sees it happen-(toast + preview modal pop automatically in their open studio).+## Showing the studio manually -```bash-curl -s -X POST localhost:8846/generate        # blocks ~5-10s, returns JSON-# -> { ok, file, url, latest: "generated/latest.png", ... }-```--The output is the hero standard 2000x1250 PNG with a `comment` tEXt chunk recording the-generator, version, and full studio state. Then set it as the page hero and show the user:+`pose` does this for you. By hand: open the container proxy URL in a Hydrogen+webview pane (never the VS Code pane), and ALWAYS disable proxy mode or the page+JS never runs:  ```bash-adom-wiki page hero <owner>/<slug> --image <package-dir>/generated/latest.png-adom-cli hydrogen webview open-or-refresh --name "Wiki Page" --url "https://wiki.adom.inc/<owner>/<slug>" --panel-id <pane>+adom-cli hydrogen webview open-or-refresh --name "Hero Studio" \+  --url "$(echo $VSCODE_PROXY_URI | sed 's/{{port}}/8846/')" --panel-id <webview-pane-id>+adom-cli hydrogen webview set-proxy false --name "Hero Studio" ``` -Requirements: puppeteer-core (env `PUPPETEER_CORE` if not in a standard location) and-Chrome (env `CHROME`, or the puppeteer cache). Failures return `{ok:false, error, hint}`.- ## Refresh the dataset  ```bash ./refresh-data.sh    # pulls live adom-wiki page trending, then restart the server ```++Requirements for generating: puppeteer-core (env `PUPPETEER_CORE` if unusual) and+Chrome (env `CHROME`, or the puppeteer cache). Failures return `{ok:false, error, hint}`.
bin/hero-studio+7−2
@@ -1,5 +1,10 @@ #!/bin/sh-# Launcher: resolve the real package dir (through the ~/.local/bin symlink) and run the server.+# Launcher: resolve the real package dir (through the ~/.local/bin symlink).+# `hero-studio` runs the server; `hero-studio pose <owner/slug>` opens the+# studio in front of the human and blocks until they click Generate. SELF="$(readlink -f "$0")" DIR="$(cd "$(dirname "$SELF")/.." && pwd)"-exec node "$DIR/server.js" "$@"+case "$1" in+  pose) shift; exec node "$DIR/pose.mjs" "$@" ;;+  *) exec node "$DIR/server.js" "$@" ;;+esac
data/pages.json+18
@@ -1,4 +1,22 @@ [+ {+  "type": "app",+  "title": "Dice Lab",+  "author": "Colby Knox",+  "score": 0,+  "stars": 0,+  "installs30d": 0,+  "brief": "Fair rolls in your terminal: dice expressions, coin flips, random picks.",+  "tags": [+   "cli",+   "dice",+   "random",+   "games"+  ],+  "updated_at": "2026-07-23T00:00:00.000Z",+  "owner": "kcknox",+  "slug": "dice-lab"+ },  {   "owner": "adom",   "slug": "adom-desktop",
package.json+11−7
@@ -2,8 +2,8 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.4.0",-  "description": "A live playground for the hero image v2 definition: the lower third of every hero is a true-text overlay (type pill, trending score, stars, title, author, wiki slug) rendered by the surface, never baked into the image. Flip screenshot bleeds, brand tile themes, gradient angles, and title placement on real wiki trending data, and see the result in pixel-faithful wiki contexts: the homepage marquee, the index page card grid, and an adom-desktop style right bar. Includes a component-hero mode: real photo, raytraced render, or phong 3D fallback ladder, name plus spec-line hierarchy, and MPN placement options. Generate renders the posed hero server-side to a provenance-stamped 2000x1250 PNG via POST /generate.",+  "version": "0.5.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 lower third is simulated only, because the wiki page overlays type pill, description, author, and the full owner/slug ref live at view time. 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. Includes screenshot bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [     "hero",@@ -31,7 +31,12 @@     "part photo hero",     "mpn on the hero",     "generate the hero png",-    "render my hero"+    "render my hero",+    "pose a hero",+    "hero for my package",+    "make a hero for",+    "human hero gate",+    "hero studio required"   ],   "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.",   "sample_prompts": [@@ -54,9 +59,7 @@   },   "needs_sudo": false,   "hero": {-    "headline": "Hero Studio",-    "subhead": "Design the lower-third hero, live.",-    "screenshot": "docs/hero.png"+    "path": "docs/hero.png"   },   "files": [     "package.json",@@ -66,6 +69,7 @@     "uninstall.sh",     "refresh-data.sh",     "server.js",+    "pose.mjs",     "bin/hero-studio",     "public/**",     "data/**",@@ -76,4 +80,4 @@     "docs/component-mode.png",     "render-hero.mjs"   ]-}\ No newline at end of file+}
page.json+9−3
@@ -2,8 +2,8 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.4.0",-  "description": "A live playground for the hero image v2 definition: the lower third of every hero is a true-text overlay (type pill, trending score, stars, title, author, wiki slug) rendered by the surface, never baked into the image. Flip screenshot bleeds, brand tile themes, gradient angles, and title placement on real wiki trending data, and see the result in pixel-faithful wiki contexts: the homepage marquee, the index page card grid, and an adom-desktop style right bar. Includes a component-hero mode: real photo, raytraced render, or phong 3D fallback ladder, name plus spec-line hierarchy, and MPN placement options. Generate renders the posed hero server-side to a provenance-stamped 2000x1250 PNG via POST /generate.",+  "version": "0.5.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 lower third is simulated only, because the wiki page overlays type pill, description, author, and the full owner/slug ref live at view time. 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. Includes screenshot bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [     "hero",@@ -31,7 +31,12 @@     "part photo hero",     "mpn on the hero",     "generate the hero png",-    "render my hero"+    "render my hero",+    "pose a hero",+    "hero for my package",+    "make a hero for",+    "human hero gate",+    "hero studio required"   ],   "discovery_pitch": "Reach for hero-studio when someone wants to design, preview, or debate a wiki hero image: it renders every layout, brand tile theme, and gradient angle live on real trending data, inside faithful copies of the wiki marquee and index card chrome.",   "sample_prompts": [@@ -67,6 +72,7 @@     "uninstall.sh",     "refresh-data.sh",     "server.js",+    "pose.mjs",     "bin/hero-studio",     "public/**",     "data/**",
pose.mjsadded+101
@@ -0,0 +1,101 @@+#!/usr/bin/env node+// hero-studio pose <owner/slug> [--title T] [--brief B] [--type app|skill] [--timeout SECS]+//+// The human gate. Launches Hero Studio in front of the human, posed for the+// given package, then BLOCKS until a human clicks Generate in the UI. Prints+// the generated PNG path on stdout and exits 0. An AI that needs a hero runs+// this and waits; it cannot continue without a human-made image.+//+// Exit codes: 0 generated, 2 timeout (no human generated), 1 setup error.+import { execFile, spawn } from 'child_process';+import { fileURLToPath } from 'url';+import path from 'path';+import fs from 'fs';++const DIR = path.dirname(fileURLToPath(import.meta.url));+const BASE = 'http://localhost:8846/';++const args = process.argv.slice(2);+const flags = {};+const pos = [];+for (let i = 0; i < args.length; i++) {+  if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[i + 1] ?? '', i++;+  else pos.push(args[i]);+}+const ref = pos[0];+if (!ref || !ref.includes('/')) {+  console.error('usage: hero-studio pose <owner/slug> [--title T] [--brief B] [--type app|skill] [--timeout SECS]');+  process.exit(1);+}+const [owner, slug] = ref.split('/');+const timeoutSecs = Number(flags.timeout || 900);++const jf = async (p, opts) => {+  const r = await fetch(BASE + p, opts);+  if (!r.ok) throw new Error(p + ' -> ' + r.status);+  return r.json();+};+const sh = (cmd, a) => new Promise((res) => {+  execFile(cmd, a, { timeout: 20000 }, (err, stdout) => res(err ? null : stdout));+});++// 1. ensure the server is up+let state = null;+try { state = await jf('state'); } catch {+  spawn(process.execPath, [path.join(DIR, 'server.js')], { detached: true, stdio: 'ignore', env: process.env }).unref();+  for (let i = 0; i < 20 && !state; i++) {+    await new Promise((r) => setTimeout(r, 500));+    try { state = await jf('state'); } catch {}+  }+  if (!state) { console.error('hero-studio server did not come up on :8846'); process.exit(1); }+}++// 2. make sure the studio knows this package, then pose it+const title = flags.title || slug.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');+await jf('api/pages', {+  method: 'POST', headers: { 'Content-Type': 'application/json' },+  body: JSON.stringify(Object.fromEntries(Object.entries(+    { owner, slug, title, type: flags.type || 'app', brief: flags.brief },+  ).filter(([, v]) => v))),+});+await jf('state', {+  method: 'POST', headers: { 'Content-Type': 'application/json' },+  body: JSON.stringify({ mode: 'app', page: ref }),+});++// 3. open the studio IN FRONT OF THE HUMAN: a Hydrogen webview tab, with+// proxy mode OFF (proxy mode silently blocks all page JS, which leaves the+// studio dead) and never in the VSCode editor pane.+const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', '8846');+if (!proxyBase) { console.error('VSCODE_PROXY_URI not set; cannot open an editor tab'); process.exit(1); }+const tabsRaw = await sh('adom-cli', ['hydrogen', 'workspace', 'tabs']);+let tabName = 'Hero Studio';+let panelId = null;+try {+  const tabs = JSON.parse(tabsRaw).tabs || [];+  const existing = tabs.find((t) => (t.name || '').includes('Hero Studio'));+  const anyWebview = tabs.find((t) => t.name !== 'Visual Studio Code');+  if (existing) { tabName = existing.name; panelId = existing.panelId; }+  else if (anyWebview) panelId = anyWebview.panelId;+} catch {}+if (!panelId) { console.error('no webview pane found to open the studio in'); process.exit(1); }+await sh('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', tabName, '--url', proxyBase, '--panel-id', panelId]);+await sh('adom-cli', ['hydrogen', 'webview', 'set-proxy', 'false', '--name', tabName]);++// 4. block until a HUMAN clicks Generate (a new _gen seq)+const baseline = (state._gen && state._gen.seq) || 0;+console.error('studio open in tab "' + tabName + '", posed for ' + ref + '. Waiting for a human to click Generate (timeout ' + timeoutSecs + 's)...');+const deadline = Date.now() + timeoutSecs * 1000;+while (Date.now() < deadline) {+  await new Promise((r) => setTimeout(r, 2000));+  let st = null;+  try { st = await jf('state'); } catch { continue; }+  const g = st._gen;+  if (g && g.ok && g.seq > baseline) {+    const abs = path.join(DIR, 'generated', g.file);+    console.log(JSON.stringify({ ok: true, ref, file: g.file, path: fs.existsSync(abs) ? abs : path.join(DIR, g.url), latest: path.join(DIR, 'generated', 'latest.png') }));+    process.exit(0);+  }+}+console.error('timeout: no human generated a hero within ' + timeoutSecs + 's');+process.exit(2);
public/index.html+81−20
@@ -1,6 +1,16 @@ <!doctype html> <html lang="en"> <head>+<script>+/* boot probe: relays load + first JS error to the server console, before anything else can crash */+(function(){+  var post = function(t){ try { fetch('console', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({level:'probe', text:t})}).catch(function(){}); } catch(e){} };+  post('boot ' + location.href + ' ua=' + navigator.userAgent.slice(0,60));+  window.addEventListener('error', function(e){ post('jserror ' + (e.message||'') + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')); });+  window.addEventListener('unhandledrejection', function(e){ post('unhandledrejection ' + String(e.reason).slice(0,200)); });+})();+</script>+ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Hero Studio</title>@@ -228,6 +238,7 @@ label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:po .tcell { height:1.05cqw; border-radius:.25cqw; background:rgba(255,255,255,.06); }  /* ---------- the lower-third textual overlay ---------- */+.pill-tr { position:absolute; top:2.4cqw; right:2.6cqw; 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%);@@ -254,6 +265,7 @@ label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:po .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; }@@ -344,7 +356,7 @@ body[data-mode=component] .mode-app { display: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 { display:flex; align-items:center; gap:10px; margin-bottom:12px; }+.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); }@@ -354,12 +366,12 @@ body[data-mode=component] .mode-app { display:none; }  /* index page card grid (style.css .page-grid / .page-card-*) */ .page-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:16px; }-.page-card { 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 { 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 { display:flex; align-items:center; gap:8px; }+.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; }@@ -374,20 +386,38 @@ body[data-mode=component] .mode-app { display:none; } .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: dashed placeholder for their own logo */-.wordmark.noadom .wmark {-  -webkit-mask:none; mask:none; background:transparent;-  border:1.5px dashed rgba(255,255,255,.28); border-radius:.4cqw;-  display:flex; align-items:center; justify-content:center;-}-.wordmark.noadom .wmark::after {-  content:'YOUR LOGO'; font-family:'Familjen Grotesk'; font-weight:700;-  font-size:.8cqw; letter-spacing:.2em; color:rgba(255,255,255,.4);-}+/* 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; }++/* 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; }  /* 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 { display:none !important; }+/* The lower-third band is a POSING SIMULATION of the page's live overlay+   (pill, trending, stars, owner, url). Per the resolved rule, the image+   carries the title; the overlay carries dynamic data, rendered by the page+   at view time. Baking it would freeze live stats into the PNG forever and+   double under the real overlay. Never export it. */+body[data-render] .hero .lower { display:none !important; }+body[data-render] .pill-tr { 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; } @@ -509,6 +539,14 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh     <input type="range" id="rng-angle" min="0" max="360" step="5" value="155" data-tip="Rotate the base gradient so each hero feels unique while staying on-brand">     <div class="hint">Angle: <span id="angle-val">155</span> deg. Brand default is 155.</div> +    <div id="sec-brand" style="display:none">+    <h3>Owner brand</h3>+    <div class="brandrow"><label>Accent</label><input type="color" id="brand-accent" value="#00e6dc" data-tip="Tints the subtitle + screenshot art. Non-adom pages bring their own accent"></div>+    <div class="brandrow"><label>Background</label><input type="color" id="brand-bg" value="#0d1117" data-tip="Base background color behind the theme art"></div>+    <button class="btn" id="brand-clear" data-tip="Back to the neutral studio palette">Use default palette</button>+    <div class="hint">Adom-org pages get the house brand (wordmark, teal, fonts) automatically. Everyone else brings their own colors and backgrounds; the placement stays fixed.</div>+    </div>+     <div class="mode-app">     <h3>Image-zone text</h3>     <label class="chk"><input type="checkbox" id="chk-logo"> Adom wordmark</label>@@ -527,7 +565,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh      <h3>Overlay content</h3>     <label class="chk"><input type="checkbox" id="chk-pill"> Type pill</label>-    <label class="chk"><input type="checkbox" id="chk-scores"> Trending + stars (dynamic)</label>+    <label class="chk"><input type="checkbox" id="chk-scores"> Live stats (replaced by the marquee)</label>     <label class="chk"><input type="checkbox" id="chk-author"> Author</label>     <label class="chk"><input type="checkbox" id="chk-slug"> Wiki slug, lower right</label>     <button class="btn btn-primary" id="btn-generate" data-tip="Render the current hero to a 2000x1250 PNG on the server, with provenance metadata">Generate hero PNG</button>@@ -590,11 +628,11 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh         </div>       </div> +      <div class="pill-tr" id="pill-tr"><span class="pill app" id="l-pill">APP</span></div>       <div class="lower">         <div class="lowerin">           <div class="lmain">             <div class="badges">-              <span class="pill app" id="l-pill">APP</span>               <span class="obadge" id="l-trend" data-tip="Composite trending score from the wiki, recomputed continuously">                 <svg viewBox="0 0 24 24"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>                 <b></b>@@ -609,6 +647,7 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh               </span>             </div>             <a class="l-title" id="l-title" target="_blank" href="#"></a>+            <div class="l-desc" id="l-desc"></div>             <div class="l-author" id="l-author"></div>           </div>           <div class="l-right">@@ -731,8 +770,22 @@ function render() {     : d.type + ' · score ' + d.score + ' · ' + d.stars + ' stars · ' + d.installs30d + ' installs/30d';   $('btn-guides').classList.toggle('on', !!state.guides); -  // Adom branding only on adom-owned pages; everyone else gets the placeholder-  document.querySelectorAll('#hero .wordmark').forEach(w => w.classList.toggle('noadom', d.owner !== 'adom'));+  // 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));+  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) {@@ -746,7 +799,7 @@ function render() {    // image zone   const showTitleInImage = state.show.title && state.titleMode !== 'overlay-only';-  $('el-logo').style.display = state.show.logo ? 'flex' : 'none';+  $('el-logo').style.display = (state.show.logo && !foreign) ? 'flex' : 'none';   $('el-title').style.display = showTitleInImage ? 'block' : 'none';   $('el-title').textContent = p.title;   $('el-sub').style.display = state.show.subtitle ? 'block' : 'none';@@ -770,6 +823,9 @@ function render() {   $('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 || '');@@ -829,7 +885,7 @@ function marqueeTile(p, isLive) {     (p.stars > 0 ? '<span class="ts-slide-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') +     '</div><h3 class="ts-slide-title">' + p.title + '</h3>' +     (p.brief ? '<p class="ts-slide-brief">' + p.brief + '</p>' : '') +-    '<span class="ts-slide-owner">' + p.owner + '</span></div></a>';+    '<span class="ts-slide-owner">' + p.owner + '/' + p.slug + '</span></div></a>'; }  function indexCard(p, isLive) {@@ -841,7 +897,7 @@ function indexCard(p, isLive) {     '<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 + '</span>' ++    '<div class="page-card-meta"><span>' + p.owner + '/' + p.slug + '</span>' +     (p.updated_at ? '<span class="dot-sep">&middot;</span><span>' + relTime(p.updated_at) + '</span>' : '') +     (p.stars > 0 ? '<span class="dot-sep">&middot;</span><span class="page-card-stars">' + STAR12 + ' ' + p.stars + '</span>' : '') +     '</div>' +@@ -990,6 +1046,11 @@ $('btn-reset').addEventListener('click', () => {   }); })(); +/* 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()),
server.js+23−2
@@ -17,7 +17,8 @@ let state = {   theme: 'midnight',        // midnight | pcb | onion | pill | gradient   show: { logo: true, title: true, subtitle: true, description: false },   titleMode: 'image-only', // overlay-only | image-only | both; image-only per John: the overlay is dynamic data, not a second title-  overlay: { pill: true, scores: true, author: true, slug: true },+  overlay: { pill: true, scores: false, author: true, slug: true }, // scores are live-replaced on the marquee; sim defaults them off+  brand: { accent: '', bg: '' },  // non-adom owner palette; empty = studio default   gradAngle: 155,   guides: true, };@@ -117,6 +118,26 @@ const server = http.createServer((req, res) => {       res.end(JSON.stringify({ ok: true }));     });   }+  if (p === '/api/pages' && req.method === 'POST') {+    return readBody(req, (err, body) => {+      if (err || !body || !body.owner || !body.slug) {+        res.writeHead(400, { 'Content-Type': 'application/json' });+        return res.end(JSON.stringify({ ok: false, error: 'owner and slug required' }));+      }+      const i = PAGES.findIndex(x => x.owner === body.owner && x.slug === body.slug);+      // empty/null body fields never clobber what the entry already has+      const clean = Object.fromEntries(Object.entries(body).filter(([, v]) => v !== '' && v != null && !(Array.isArray(v) && v.length === 0)));+      const entry = Object.assign(+        { type: 'app', title: body.slug, author: '', score: 0, stars: 0, installs30d: 0, brief: '', tags: [], updated_at: new Date().toISOString() },+        i >= 0 ? PAGES[i] : {}, clean,+      );+      if (i >= 0) PAGES[i] = entry; else PAGES.unshift(entry);+      try { fs.writeFileSync(path.join(__dirname, 'data', 'pages.json'), JSON.stringify(PAGES, null, 1)); } catch {}+      logLine('info', 'page upsert ' + body.owner + '/' + body.slug);+      res.writeHead(200, { 'Content-Type': 'application/json' });+      res.end(JSON.stringify({ ok: true, count: PAGES.length }));+    });+  }   if (p === '/api/pages') {     res.writeHead(200, { 'Content-Type': 'application/json' });     return res.end(JSON.stringify(PAGES));@@ -159,7 +180,7 @@ const server = http.createServer((req, res) => {         return finish({ ok: false, error: errBuf.trim().split('\n').pop() || 'render exited ' + code, hint: 'set CHROME and PUPPETEER_CORE env vars for the server process' });       }       // downscale to the hero standard 2000x1250 and stamp provenance metadata-      const meta = JSON.stringify({ generator: 'hero-studio', version: '0.3.0', ts, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle } });+      const meta = JSON.stringify({ generator: 'hero-studio', version: '0.5.0', ts, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand } });       const py = 'import sys\n' +         'from PIL import Image\n' +         'from PIL.PngImagePlugin import PngInfo\n' +