← Commit history
SKILL.md+67
@@ -461,6 +461,73 @@ measurable parts: showcase coverage above ~72% of the hero warns, and a full-import whose zones read as several competing text blocks warns (a 4x3 edge-density grid; heuristic and says so). +## A hero wears the PAGE OWNER's brand (0.20.9)++The doctrine below is ADOM's brand. It is not every owner's. On every pose+and every page change the studio resolves the page owner's brand guide:++    GET /api/owners/<owner>/brand   ->  status, brand, assets, editable_by, hints++and renders the hero from it: colors, display/body/mono faces (a font file+from the guide's `fonts/` is loaded as an @font-face, a bare family name is+assumed to be on Google Fonts), and the owner's own logo in the wordmark+slot. Adom's wordmark and palette appear on adom-owned pages only. The+statuses and what each means for the hero:++| status | the hero |+|---|---|+| `confirmed` | drawn from the guide; a human approved this look |+| `starter` | drawn from the guide, with a lint warning: nobody approved it yet |+| `missing` | unbranded, plus a lint warning naming the owner |+| `invalid` | unbranded; the failing brand.json fields are named |+| `private` | unbranded; the guide is not readable with this identity |+| `unavailable` | unbranded; the registry did not answer |++An unresolved guide NEVER silently becomes "use Adom's look". A wrong brand+published under someone else's name is worse than a hero that holds, so the+studio says what happened and the linter warns before Generate.++The guide's logo and font files are fetched SERVER-side and streamed to the+surface (`GET /brand/asset?role=`, `GET /brand/font?role=`): the browser+cannot always reach them itself (a private guide needs the token, and+headless Chrome refuses a self-signed registry outright). Only roles the+resolved guide names are served, so it is not an open proxy.++### The Brand step, and who may confirm++When the guide is anything but `confirmed`, the wizard inserts a **Brand**+step ahead of everything else, showing the resolved colors, type and logo,+per-field errors and warnings, the server's own sentence about who may edit+the guide (relayed verbatim, never paraphrased), and a Confirm card.++Confirm is a HUMAN act. It pushes brand.json with `status: "confirmed"`,+`confirmed_by` and `confirmed_at`, and it is refused for an identity the+server says cannot edit that guide. A thread does not confirm an owner's+look on their behalf, in the studio or from the CLI.++### What the stamp records++Every generated PNG's provenance comment carries, at the top level:++    branding, brand_sha, brand_version, brand_status++so a consumer reading a published hero can tell which guide, and which+revision of it, produced those pixels.++### Checking it before you pose++    hero-studio doctor --owner <handle>      # or --page <owner/slug>++reports the brand gate the way it reports the renderer: named findings, each+with a fix, plus the resolver's own hints. It is ADVISORY and never changes+the exit code: a guide awaiting a human is not a broken install. `hero-studio+pose` prints the same status, hints and editor rule before the gate opens.++Tests: `npm run test:brand` (node test/brand.mjs) runs every status, the+can_edit refusal, the confirm push body, and the four provenance fields+against a stub registry, so nothing here is verified by writing to a real+owner's guide.+ ## Brand doctrine (what makes a hero look like the family)  Written here because it used to live only in the studio's internal CSS and
doctor.mjs+112−1
@@ -178,6 +178,103 @@ export async function runChecks() {   return { ok: pilOk && !!renderer, renderer, checks }; } +// ---- brand gate ----++/// Resolve an owner's brand guide and report it the way the renderer checks+/// report: named findings with an exact fix. Advisory by design: a starter or+/// missing guide is what the wizard's Brand step exists for, so it must never+/// lock the pose gate the way a dead renderer does.+export async function runBrandChecks(owner) {+  const checks = [];+  const fail = (name, detail, fix) => checks.push({ name, ok: false, detail, fix });+  const pass = (name, detail) => checks.push({ name, ok: true, detail });+  const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';+  const headers = {};+  try { headers.Authorization = 'Bearer ' + readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}++  let guide = null;+  try {+    const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand', { headers });+    const body = await r.json().catch(() => ({}));+    if (r.status === 404) {+      guide = { owner, status: 'missing', hints: body.hints || [] };+    } else if (!r.ok) {+      fail('brand guide', 'the registry answered HTTP ' + r.status + ' for ' + owner,+        'check ADOM_WIKI_REGISTRY (' + REG + ') and this container\'s API key; until it answers, the hero holds rather than wearing Adom\'s look');+      return { ok: false, owner, status: 'unavailable', guide: null, checks };+    } else {+      guide = body;+    }+  } catch (e) {+    fail('brand guide', 'could not reach ' + REG + ': ' + String(e && e.message || e).split('\n')[0],+      'the guide is resolved per pose; a hero cannot be branded until the registry answers');+    return { ok: false, owner, status: 'unavailable', guide: null, checks };+  }++  const status = guide.status || 'missing';+  const who = (guide.editable_by && guide.editable_by.text) || null;+  // editable_by.text is the server's own sentence about who may edit this+  // guide. It is relayed verbatim, never paraphrased.+  const whoFix = who || ('this identity cannot edit ' + owner + '/brand');++  if (status === 'confirmed') {+    pass('brand guide', owner + '/brand is confirmed (v' + (guide.version || '?') + (guide.sha ? ' ' + String(guide.sha).slice(0, 7) : '') + ')');+  } else if (status === 'starter') {+    fail('brand guide', owner + '/brand is a STARTER guide: no human has confirmed it',+      'pose the page and confirm it at the wizard\'s Brand step; a human confirms, never a thread. ' + whoFix);+  } else if (status === 'missing') {+    fail('brand guide', owner + '/brand does not exist yet',+      'the wizard\'s Brand step writes a starter guide for a human to confirm: adom-wiki brand show --owner ' + owner + '. ' + whoFix);+  } else if (status === 'invalid') {+    const errs = (guide.errors || []).map((e) => (e && e.field ? e.field + ': ' : '') + (e && e.message || e));+    fail('brand guide', owner + '/brand fails brand.json v1 validation' + (errs.length ? ': ' + errs.join('; ') : ''),+      'fix the named fields, then confirm at the Brand step. ' + whoFix);+  } else if (status === 'private') {+    fail('brand guide', owner + '/brand is not readable with this identity',+      'the hero cannot be branded from a guide it cannot read. ' + whoFix);+  } else {+    fail('brand guide', 'unknown guide status "' + status + '" for ' + owner, 'the resolver contract names: confirmed, starter, missing, invalid, private');+  }++  if (guide.can_edit === false && status !== 'confirmed') {+    fail('brand confirm', 'this identity cannot edit ' + owner + '/brand, so the Brand step cannot confirm it here', whoFix);+  }+  const marks = (guide.assets && guide.assets.mark) || null;+  if (status !== 'missing' && !marks) {+    fail('brand logo', owner + '/brand resolves no logo mark, so the hero falls back to a wordmark-free header',+      'add logos.mark to brand.json (svg/png/webp/jpg) and push it with the guide');+  } else if (marks) {+    pass('brand logo', 'mark resolves (' + String(marks).split('/').pop() + ')');+  }+  for (const w of (guide.warnings || [])) checks.push({ name: 'brand warning', ok: false, detail: w, fix: 'a warning does not block confirm; fix it in brand.json when you next edit the guide' });++  return { ok: checks.every((c) => c.ok), owner, status, guide, checks };+}++/// One line for a resolver hint. The contract's shape is+/// {level, code, message, data}; a bare string is accepted too.+export function hintLine(h) {+  if (!h || typeof h === 'string') return String(h || '');+  const code = h.code ? '[' + h.code + '] ' : '';+  return code + (h.message || h.text || JSON.stringify(h));+}++export function printBrandReport(report, out = console.error) {+  out('brand guide for ' + report.owner + ': ' + report.status);+  if (report.guide && report.guide.url) out('  guide: ' + report.guide.url);+  for (const c of report.checks) {+    out((c.ok ? '  ok   ' : '  FAIL ') + c.name + ': ' + c.detail);+    if (!c.ok && c.fix) out('    fix: ' + c.fix);+  }+  for (const h of ((report.guide && report.guide.hints) || [])) out('  hint: ' + hintLine(h));+  if (!report.ok) {+    out('');+    out('The hero wears the PAGE OWNER\'s brand, so an unconfirmed guide is a decision');+    out('waiting for a human, not something this tool routes around. Pose the page and');+    out('walk the wizard\'s Brand step; Confirm needs a human at the terminal.');+  }+}+ export function printReport(report, out = console.error) {   for (const c of report.checks) {     // a failed check that the chosen render path does not need is a miss, not@@ -207,6 +304,20 @@ export function printReport(report, out = console.error) { if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {   const report = await runChecks();   printReport(report);-  console.log(JSON.stringify({ ok: report.ok, renderer: report.renderer, checks: report.checks }));+  // `doctor --owner <handle>` (or --page <owner/slug>) also reports the brand+  // gate. Advisory: it prints and rides the JSON, but only the renderer+  // decides the exit code, because a guide awaiting a human is not a broken+  // install.+  const argv = process.argv.slice(2);+  const argOf = (n) => { const i = argv.indexOf('--' + n); return i >= 0 ? argv[i + 1] : null; };+  const owner = argOf('owner') || String(argOf('page') || '').split('/')[0] || null;+  let brand = null;+  if (owner) {+    console.error('');+    brand = await runBrandChecks(owner);+    printBrandReport(brand);+  }+  console.log(JSON.stringify({ ok: report.ok, renderer: report.renderer, checks: report.checks,+    ...(brand ? { brand: { ok: brand.ok, owner: brand.owner, status: brand.status, checks: brand.checks } } : {}) }));   process.exit(report.ok ? 0 : 1); }
package.json+5−3
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.20.8",+  "version": "0.20.9",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the showcase: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [@@ -63,7 +63,8 @@   ],   "scripts": {     "install": "./install.sh",-    "uninstall": "./uninstall.sh"+    "uninstall": "./uninstall.sh",+    "test:brand": "node test/brand.mjs"   },   "needs_sudo": false,   "hero": {@@ -92,6 +93,7 @@     "docs/component-mode.png",     "render-hero.mjs",     "render-pup.mjs",+    "test/brand.mjs",     "screenshots/hero.png"   ]-}\ No newline at end of file+}
page.json+4−2
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.20.8",+  "version": "0.20.9",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the showcase: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [@@ -63,7 +63,8 @@   ],   "scripts": {     "install": "./install.sh",-    "uninstall": "./uninstall.sh"+    "uninstall": "./uninstall.sh",+    "test:brand": "node test/brand.mjs"   },   "needs_sudo": false,   "hero": {@@ -93,6 +94,7 @@     "docs/component-mode.png",     "render-hero.mjs",     "render-pup.mjs",+    "test/brand.mjs",     "screenshots/hero.png"   ],   "org": "adom",
pose.mjs+38−2
@@ -14,7 +14,7 @@ import { execFile, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import path from 'path'; import fs from 'fs';-import { runChecks, printReport } from './doctor.mjs';+import { runChecks, printReport, hintLine } from './doctor.mjs'; import { makeBridge } from './bridge.mjs';  const DIR = path.dirname(fileURLToPath(import.meta.url));@@ -275,6 +275,41 @@ if (localPkg) {     ).filter(([, v]) => v))),   }); }+// 2a-brand. Resolve the PAGE OWNER's brand guide and say where it stands+// before the human reaches the gate. A hero wears the brand of the owner whose+// page it is, so a starter, missing, invalid or unreachable guide is something+// the human decides about at the Brand step, not something this command+// silently papers over with Adom's look.+try {+  const br = await jf('brand?fresh=1');+  const g = (br && br.guide) || {};+  const label = {+    confirmed: 'confirmed',+    starter: 'STARTER (not yet confirmed by a human)',+    missing: 'MISSING',+    invalid: 'INVALID',+    private: 'PRIVATE (not readable with this identity)',+    unavailable: 'UNAVAILABLE (the registry did not answer)',+  }[g.status] || String(g.status || 'unknown');+  console.error('brand guide for ' + owner + ': ' + label + (g.version ? ' (v' + g.version + (g.sha ? ' ' + String(g.sha).slice(0, 7) : '') + ')' : ''));+  if (g.url) console.error('  guide: ' + g.url);+  for (const e of (g.errors || []).slice(0, 6)) {+    console.error('  error: ' + (e && e.field ? e.field + ': ' : '') + (e && e.message || e));+  }+  for (const w of (g.warnings || []).slice(0, 6)) console.error('  warning: ' + w);+  for (const h of (g.hints || []).slice(0, 6)) console.error('  hint: ' + hintLine(h));+  if (g.status !== 'confirmed') {+    // editable_by.text is the server's sentence about who may edit this+    // guide. Relay it verbatim: this thread does not get to paraphrase the+    // rule, and the human at the gate is the one it applies to.+    if (g.editable_by && g.editable_by.text) console.error('  who may edit: ' + g.editable_by.text);+    else if (!g.can_edit) console.error('  this identity cannot edit ' + owner + '/brand');+    console.error('  the wizard opens on a Brand step; the guide is confirmed by a human there, never by a thread');+  }+} catch (e) {+  console.error('brand guide for ' + owner + ': could not be resolved (' + (e && e.message || e) + '); the hero will hold rather than fall back to Adom\'s look');+}+ // 2a-showcase. Stage the best local image as the showcase (unless staged work // is being preserved on a same-page re-pose, or the caller staged their own). if (localImages.length && !(samePageRepose && !('fresh-showcase' in flags))) {@@ -290,7 +325,8 @@ if (localImages.length && !(samePageRepose && !('fresh-showcase' in flags))) { // Test hook (#52 verification): everything above ran; skip the human gate. if ('seed-only' in flags) {   const st = await jf('state');-  console.log(JSON.stringify({ ok: true, seedOnly: true, page: st.page, resolved: st.resolved, showcaseBytes: (st.showcase && st.showcase.src || '').length, localImages: localImages.map((f) => path.relative(process.cwd(), f)) }));+  const sg = st.guide || {};+  console.log(JSON.stringify({ ok: true, seedOnly: true, page: st.page, resolved: st.resolved, showcaseBytes: (st.showcase && st.showcase.src || '').length, localImages: localImages.map((f) => path.relative(process.cwd(), f)), brand: { status: sg.status || 'missing', owner: sg.owner || null, sha: sg.sha || null, version: sg.version || null, can_edit: !!sg.can_edit } }));   process.exit(0); } 
public/hero.css+47−8
@@ -569,17 +569,56 @@ body[data-mode=component] .mode-app { display:none; } .hero.fullimport.stamp-logo .fi-logo { display:block; }  -/* owner brand: non-adom accent + background */+/* The page owner's brand guide, applied to the whole hero (adom/wiki brand+   guides). Every value falls back to the studio's Adom defaults, so a page+   with no usable guide renders exactly as before; a page WITH one wears its+   owner's colors, type and logo end to end. */ #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)); }+/* The theme layer paints OVER the hero's own background, so a branded hero+   sets its ground there too: the owner's background, with a soft wash of+   their primary for depth instead of Adom's teal and purple glows. */+#hero.branded .bg {+  background:+    radial-gradient(1200px 800px at 88% -10%, color-mix(in srgb, var(--brand-primary, #00b8b1) 18%, transparent), transparent 60%),+    radial-gradient(900px 700px at -5% 110%, color-mix(in srgb, var(--brand-accent, #8c6bf7) 14%, transparent), transparent 55%),+    var(--brand-bg, #0d1117);+}+#hero.branded .bg::after { opacity:.35; } -#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; }+#hero.branded .h-title { font-family: var(--brand-display, 'Familjen Grotesk'); color: var(--brand-text, #e6edf3); }+#hero.branded .h-sub    { font-family: var(--brand-body, 'Satoshi'); color: var(--brand-text, #e8eef4); }+#hero.branded .h-desc   { font-family: var(--brand-body, 'Satoshi'); color: var(--brand-text-2, #8b949e); }+#hero.branded .h-sub b  { color: var(--brand-text, #e6edf3); }++#hero.branded .bar { background: linear-gradient(180deg,+  color-mix(in srgb, var(--brand-primary, var(--brand-accent, #00e6dc)) 55%, transparent),+  color-mix(in srgb, var(--brand-primary, var(--brand-accent, #00b8b0)) 12%, transparent)); }++#hero.branded .h-sub .accent { background: var(--brand-grad, linear-gradient(90deg,+  var(--brand-primary, #00e6dc),+  color-mix(in srgb, var(--brand-primary, #00e6dc) 45%, #fff)));+  -webkit-background-clip:text; background-clip:text; color:transparent; }++/* the owner's own logo replaces the Adom wordmark, at the same anchor and+   height so the title's ink edge does not move */+/* The owner's own logo replaces the Adom wordmark. The slot's height grows+   with it (the container clips at the Adom wordmark's 1.72cqw otherwise), and+   the title's ink edge still starts at the same anchor below it. */+/* An owner's own logo takes the wordmark slot at the SAME height the Adom+   wordmark uses (#37: one standard size on every hero). The box is left-+   aligned and `contain`, so the logo's own aspect sets how wide it actually+   draws and nothing is stretched: Adom's 4.55:1 wordmark lands at exactly+   the standard 7.8cqw, and a square mark simply draws narrower. A fixed+   width here instead drew every owner's logo at double the standard. */+#hero.branded .wordmark .wmark.owner-logo {+  background: var(--owner-logo) no-repeat left center / contain;+  -webkit-mask: none; mask: none;+  width: 16cqw; height: 1.72cqw; max-width: 16cqw;+}+#hero.branded .pill { color: var(--brand-primary, #00e6dc);+  border-color: color-mix(in srgb, var(--brand-primary, #00e6dc) 45%, transparent);+  background: color-mix(in srgb, var(--brand-primary, #00b8b0) 10%, transparent); }  /* new background families so heroes stop looking identical */ .hero[data-theme=aurora] {
public/js/app.js+161−16
@@ -61,6 +61,96 @@ function accentize(text) {   return '<span class="accent">' + words.slice(0, 2).join(' ') + '</span> ' + words.slice(2).join(' '); } +/* The owner's brand guide, applied to the live hero: colors as CSS custom+   properties, the display and body families (loaded from the guide's font+   URLs), the logo in the wordmark slot, and the badge text. Adom's palette+   and wordmark stay the fallback ONLY for adom pages; for anyone else an+   unresolved guide leaves the hero unbranded rather than borrowing Adom's.+   (adom/wiki brand guides) */+function guideBrand() {+  const g = state.guide;+  return (g && g.brand) || null;+}++/* The logo URL the hero should wear: the guide's wordmark for a dark ground,+   else its mark. Null means "no owner logo", and the Adom wordmark shows only+   on adom pages. */+function guideLogo(g) {+  const a = (g && g.assets) || null;+  if (!a) return null;+  // served by the studio, not fetched from the registry: a private guide needs+  // the token, and a self-signed registry is refused by the render browser+  const role = a.wordmark_light ? 'wordmark_light' : (a.wordmark_dark ? 'wordmark_dark' : (a.mark ? 'mark' : null));+  return role ? 'brand/asset?role=' + role + '&v=' + encodeURIComponent(String((g && g.sha) || '')) : null;+}++const loadedFontUrls = new Set();+function loadGuideFonts(g) {+  const fonts = (g && g.assets && g.assets.fonts) || [];+  for (const f of fonts) {+    if (!f || !f.url || loadedFontUrls.has(f.url)) continue;+    loadedFontUrls.add(f.url);+    if (f.source === 'google' || /fonts\.googleapis\.com/.test(f.url)) {+      const link = document.createElement('link');+      link.rel = 'stylesheet';+      link.href = f.url;+      document.head.appendChild(link);+    } else {+      // a font FILE served by the guide's page, proxied by the studio for the+      // same reason the logo is+      const face = new FontFace(f.family, "url(brand/font?role=" + encodeURIComponent(f.role) + ")");+      face.load().then((ff) => document.fonts.add(ff)).catch(() => {+        try {+          fetch('console', { method: 'POST', headers: { 'Content-Type': 'application/json' },+            body: JSON.stringify({ level: 'warn', text: 'brand font failed to load: ' + f.family }) });+        } catch {}+      });+    }+  }+}++function applyGuide(hero, g, foreign) {+  const brand = (g && g.brand) || null;+  const usable = !!brand && g.status !== 'unavailable';+  loadGuideFonts(g);+  const st = hero.style;+  const set = (k, v) => { if (v) st.setProperty(k, v); else st.removeProperty(k); };+  const c = (brand && brand.colors) || {};+  // A guide always wins, adom included: adom/brand IS Adom's look once it+  // exists, and until then the stylesheet's Adom defaults stand.+  set('--brand-primary', usable ? c.primary : null);+  set('--brand-accent', usable ? (c.accent || c.primary) : null);+  set('--brand-bg', usable ? c.background : null);+  set('--brand-surface', usable ? c.surface : null);+  set('--brand-text', usable ? c.text : null);+  set('--brand-text-2', usable ? c.text_secondary : null);+  set('--brand-border', usable ? c.border : null);+  const grad = usable && Array.isArray(c.gradient) && c.gradient.length >= 2+    ? 'linear-gradient(100deg,' + c.gradient.join(',') + ')' : null;+  set('--brand-grad', grad);+  const ty = (brand && brand.typography) || {};+  set('--brand-display', usable && ty.display && ty.display.family ? "'" + ty.display.family + "'" : null);+  set('--brand-body', usable && ty.body && ty.body.family ? "'" + ty.body.family + "'" : null);+  set('--brand-mono', usable && ty.mono && ty.mono.family ? "'" + ty.mono.family + "'" : null);+  hero.classList.toggle('branded', usable);+  // the owner's logo in the wordmark slot+  const logo = guideLogo(g);+  const slot = hero.querySelector('.wordmark .wmark');+  if (slot) {+    if (usable && logo) {+      slot.style.setProperty('--owner-logo', 'url("' + logo + '")');+      slot.classList.add('owner-logo');+    } else {+      slot.style.removeProperty('--owner-logo');+      slot.classList.remove('owner-logo');+    }+  }+  const badge = brand && brand.hero && brand.hero.badge_text;+  const pill = document.getElementById('l-pill');+  if (pill && badge && foreign) pill.dataset.badge = badge;+  return usable;+}+ /* Size the app window to the showcase image's aspect inside the layout's    slot, so `object-fit: contain` never has bars to paint (the black margins    Colby saw on a wide table screenshot). Cover fits, full-hero imports and@@ -290,28 +380,17 @@ function render() {   if (fimg.getAttribute('src') !== fwant) { if (fwant) fimg.src = fwant; else fimg.removeAttribute('src'); }   $('showcase-full').checked = !!vit.full; -  // Adom branding only on adom-owned pages; everyone else: no mark, own palette.-  // Every preview wordmark (hero AND the in-context mock cards) follows.+  // The hero wears the PAGE OWNER's brand guide: its colors, its type, its+  // logo. Adom's look appears only on adom pages (adom/wiki brand guides).   const foreign = d.owner !== 'adom';-  document.querySelectorAll('.wordmark').forEach(w => w.classList.toggle('noadom', foreign));+  applyGuide(hero, state.guide, foreign);+  document.querySelectorAll('.wordmark').forEach(w => w.classList.toggle('noadom', foreign && !guideLogo(state.guide)));   // #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) {@@ -325,7 +404,9 @@ function render() {    // image zone   const showTitleInImage = state.show.title && state.titleMode !== 'overlay-only';-  $('el-logo').style.display = (state.show.logo && !foreign) ? 'flex' : 'none';+  // The logo slot shows the OWNER's logo when their guide has one; Adom's+  // wordmark still only on adom pages (adom/wiki brand guides).+  $('el-logo').style.display = (state.show.logo && (!foreign || !!guideLogo(state.guide))) ? 'flex' : 'none';   $('el-title').style.display = showTitleInImage ? 'block' : 'none';   $('el-title').textContent = state.headline || p.title;   const hlIn = $('headline-input');@@ -967,6 +1048,70 @@ function computeLint() {     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' });   } +  // 6e. brand provenance: a hero wears the PAGE OWNER's brand, so the colors+  // and fonts on screen must trace to that owner's resolved guide. This reads+  // the COMPUTED style off the live title, not the state we intended to set,+  // so a var that failed to apply (missing font file, unparsed color, a theme+  // layer painting over the brand) is caught here rather than after publish.+  {+    const g = state.guide || null;+    const owner = String(state.page || '').split('/')[0] || '';+    const foreign = !!owner && owner !== 'adom';+    const brand = (g && g.brand) || null;+    const applied = hero.classList.contains('branded');+    if (!g || g.status === 'unavailable') {+      if (foreign) items.push({ id: 'brand-unresolved', level: 'warn', el: null, msg: 'the brand guide for "' + owner + '" could not be resolved' + (g && g.error ? ' (' + g.error + ')' : '') + ', so this hero is wearing Adom\'s colors and fonts on a page that is not Adom\'s. Resolve ' + owner + '/brand before publishing: hero-studio doctor --owner ' + owner });+    } else if (g.status === 'missing' && foreign) {+      items.push({ id: 'brand-missing', level: 'warn', el: null, msg: owner + ' has no brand guide, so this hero wears Adom\'s look on ' + owner + '\'s page. The wizard\'s Brand step writes a starter guide for ' + owner + ' to confirm' });+    } else if (g.status === 'private' && foreign) {+      items.push({ id: 'brand-private', level: 'warn', el: null, msg: owner + '/brand is not readable with this identity, so its colors and fonts cannot be applied and this hero wears Adom\'s look' });+    } else if (g.status === 'invalid') {+      const fields = (g.errors || []).map((e) => (e && e.field) || '?').join(', ');+      items.push({ id: 'brand-invalid', level: 'warn', el: null, msg: owner + '/brand fails brand.json v1 validation' + (fields ? ' (' + fields + ')' : '') + ', so none of it is applied and this hero wears Adom\'s look. Fix those fields at the Brand step' });+    } else if (brand && !applied) {+      items.push({ id: 'brand-not-applied', level: 'warn', el: null, msg: owner + '/brand resolved (' + g.status + ') but the hero is not rendering branded, so these colors and fonts are not the ones this page will publish with' });+    } else if (brand && applied) {+      if (g.status === 'starter') {+        items.push({ id: 'brand-starter', level: 'warn', el: null, msg: owner + '/brand is a STARTER guide nobody has confirmed: this hero is being drawn from colors and fonts ' + owner + ' has not approved. Confirm it at the Brand step (a human confirms, never a thread) before this hero is published' });+      }+      // the drawn pixels vs the guide: computed font family and title ink+      const titleEl = $('el-title');+      if (titleEl && vis(titleEl)) {+        const cs = getComputedStyle(titleEl);+        const want = ((brand.typography || {}).display || {}).family || '';+        const got = (cs.fontFamily || '').replace(/["']/g, '');+        if (want && got && got.toLowerCase().indexOf(want.toLowerCase()) !== 0) {+          items.push({ id: 'brand-font-drift', level: 'warn', el: 'el-title', msg: 'the title is drawn in "' + got.split(',')[0] + '" but ' + owner + '/brand names "' + want + '" as its display face. A font file that did not load falls back silently, so the published hero would not be in the owner\'s type' });+        }+        // an @font-face that never loaded leaves the family unavailable even+        // though the computed value still reports it+        const ff = ((brand.typography || {}).display || {}).family;+        try {+          if (ff && document.fonts && document.fonts.check && !document.fonts.check('700 40px "' + ff + '"')) {+            items.push({ id: 'brand-font-missing', level: 'warn', el: 'el-title', msg: '"' + ff + '" is not available to this renderer, so the title falls back to a system face. Give ' + owner + '/brand a font file under fonts/ (woff2) or a Google Fonts family name' });+          }+        } catch {}+      }+      const groundWant = (brand.colors || {}).background || '';+      const bgEl = hero.querySelector('.bg');+      if (groundWant && bgEl) {+        const lit = getComputedStyle(bgEl).backgroundColor;+        const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(lit || '');+        const hexWant = groundWant.length === 4+          ? [1, 2, 3].map((i) => parseInt(groundWant[i] + groundWant[i], 16))+          : [1, 3, 5].map((i) => parseInt(groundWant.slice(i, i + 2), 16));+        if (m && hexWant.every((v) => !isNaN(v))) {+          const d = Math.max(Math.abs(+m[1] - hexWant[0]), Math.abs(+m[2] - hexWant[1]), Math.abs(+m[3] - hexWant[2]));+          // a wash or gradient over the ground is by design; a wholly+          // different ground is the Adom-theme-layer bug+          if (d > 60) {+            items.push({ id: 'brand-ground-drift', level: 'warn', el: null, msg: 'the hero ground renders rgb(' + m[1] + ',' + m[2] + ',' + m[3] + ') but ' + owner + '/brand names ' + groundWant + '. A theme layer painting over the brand ground is the usual cause' });+          }+        }+      }+    }+  }+   // 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') {
public/js/wizard.js+150−5
@@ -124,6 +124,26 @@ const WIZ_STEPS = [       host.appendChild(wrap);     },   },+  {+    id: 'brand', title: 'Brand',+    // First step whenever the owner's guide is not settled: a hero must wear+    // the PAGE OWNER's look, so the guide gets approved before anything is+    // designed on top of it (adom/wiki brand guides).+    when: () => brandStepNeeded(),+    hint: () => {+      const g = state.guide || {};+      if (g.status === 'missing') return 'This owner has no brand guide yet. A hero must not borrow another owner\u2019s look, so make one here.';+      if (g.status === 'invalid') return 'This owner\u2019s brand.json does not validate. Fix the fields below, then confirm.';+      if (g.status === 'private') return 'This owner\u2019s guide is private and this session cannot read it.';+      if (g.status === 'unavailable') return 'The registry did not answer for this owner\u2019s guide; the hero stays unbranded until it does.';+      return 'Nobody has approved this owner\u2019s look yet. Check it on the hero above, adjust it, and confirm.';+    },+    summary: () => {+      const g = state.guide || {};+      return (g.owner || '?') + ': ' + (g.status || 'unknown');+    },+    custom: (host) => brandStepBody(host),+  },   {     id: 'showcase', title: 'Showcase image',     hint: () => (canonType(wp().type) === 'component'@@ -307,8 +327,19 @@ const WIZ_STEPS = [   }, ]; -const wizStepIndex = (id) => WIZ_STEPS.findIndex(s => s.id === id);-const wizActive = () => WIZ_STEPS[wizStepIndex(wizStep)] || WIZ_STEPS[0];+/* A step with `when` only appears while that predicate holds: the Brand step+   drops out of the flow the moment the guide is confirmed. */+const wizSteps = () => WIZ_STEPS.filter(s => typeof s.when !== 'function' || s.when());+const wizStepIndex = (id) => wizSteps().findIndex(s => s.id === id);+const wizActive = () => wizSteps()[wizStepIndex(wizStep)] || wizSteps()[0];++/* The guide is settled only when a human confirmed it. Everything else+   (starter, missing, invalid, private, unreachable) puts Brand first. */+function brandStepNeeded() {+  const g = state.guide;+  if (!g) return false;              // not resolved yet: do not flash the step+  return g.status !== 'confirmed';+} /* Seasoned reviewers (4+ gates) get the four checks pre-ticked. This must run    BEFORE the Generate button's disabled state is derived in wizardRender,    not inside the review step's custom renderer (which runs after): the old@@ -344,7 +375,7 @@ function wizGoTo(id) { function wizRenderRail() {   const rail = $('wsteprail');   rail.textContent = '';-  WIZ_STEPS.forEach((s, i) => {+  wizSteps().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' : '');@@ -592,6 +623,120 @@ function wizConfetti() { window.wizardOnGenerated = () => wizConfetti();  /* ---------- dock render ---------- */+/* The Brand step's body: the guide as it stands, editable by anyone who can+   edit the page, and a Confirm that is a human action like Generate. Errors+   from the wiki's v1 validation are shown beside the field they name. */+function brandStepBody(host) {+  const g = state.guide || {};+  // The step is a column: the sentences about this guide stack, and the+  // things the human acts on (swatches, Confirm) sit in one row under them.+  // A single flex row for all of it spread the copy across the whole dock.+  const wrap = document.createElement('div');+  wrap.className = 'wiz-brand';+  const notes = document.createElement('div');+  notes.className = 'wiz-brand-notes';+  const controls = document.createElement('div');+  controls.className = 'wiz-finish';+  wrap.append(notes, controls);++  const line = (text, cls) => {+    const d = document.createElement('div');+    d.className = 'hint' + (cls ? ' ' + cls : '');+    d.textContent = text;+    notes.appendChild(d);+    return d;+  };++  // Who may edit, in the server's own words.+  if (g.editable_by && g.editable_by.text) line(g.editable_by.text + (g.can_edit ? ' You can.' : ' You cannot, so this step is read-only.'));++  if (g.status === 'unavailable') {+    line('The registry did not answer: ' + (g.error || 'unknown error') + '. The hero stays unbranded rather than borrowing another owner\u2019s look.');+    const retry = wizCard({ v: 'retry', label: 'Try again', sub: 'ask the registry for this owner\u2019s guide' }, false, () => {+      fetch('brand?fresh=1').then(() => wizardRender());+    });+    controls.appendChild(retry);+    host.appendChild(wrap);+    return;+  }++  if (g.status === 'missing') {+    line('No guide exists for ' + g.owner + ' yet. Creating one here starts it from this owner\u2019s page, never from Adom\u2019s look.');+  }++  // v1 errors, field by field.+  (g.errors || []).forEach((e) => {+    line((e.field || '?') + ': ' + (e.message || ''), 'lint-fail');+  });+  (g.warnings || []).forEach((w) => line(typeof w === 'string' ? w : JSON.stringify(w), 'lint-warn'));++  const brand = g.brand || null;+  if (brand) {+    const colors = brand.colors || {};+    const row = document.createElement('div');+    row.className = 'wiz-swatches';+    [['primary', 'Primary'], ['accent', 'Accent'], ['background', 'Background'], ['text', 'Text']].forEach(([key, label]) => {+      const cur = (state.brandEdits && state.brandEdits['colors.' + key]) || colors[key] || '';+      const card = document.createElement('label');+      card.className = 'card wide';+      const sw = document.createElement('span');+      sw.className = 'swx';+      sw.style.background = cur || 'transparent';+      const inp = document.createElement('input');+      inp.type = 'color';+      inp.value = /^#[0-9a-fA-F]{6}$/.test(cur) ? cur : '#000000';+      inp.style.width = '0'; inp.style.height = '0'; inp.style.opacity = '0'; inp.style.position = 'absolute';+      inp.disabled = !g.can_edit;+      inp.addEventListener('change', () => {+        fetch('brand/edit', {+          method: 'POST', headers: { 'Content-Type': 'application/json' },+          body: JSON.stringify({ aiThread: 'wizard', edits: { ['colors.' + key]: inp.value } }),+        }).then(() => wizardRender());+      });+      const l = document.createElement('div');+      l.className = 'card-label'; l.textContent = label;+      const sub = document.createElement('div');+      sub.className = 'card-sub'; sub.textContent = cur || 'unset';+      card.append(sw, l, sub, inp);+      row.appendChild(card);+    });+    controls.appendChild(row);++    const ty = brand.typography || {};+    line('Type: ' + [(ty.display || {}).family, (ty.body || {}).family].filter(Boolean).join(' / ') + (g.assets && g.assets.fonts ? '' : ''));+    if (g.assets && g.assets.mark) line('Logo: ' + String(g.assets.mark).split('/').pop());+  }++  // Confirm: the human action. Disabled for anyone who cannot edit the page.+  const confirmCard = wizCard({+    v: 'confirm',+    label: g.can_edit ? 'Confirm this brand' : 'Cannot confirm',+    sub: g.can_edit+      ? 'records you as the human who approved ' + (g.owner || 'this owner') + '\u2019s look'+      : ((g.editable_by && g.editable_by.who) ? 'only ' + g.editable_by.who + ' can' : 'you do not have edit rights'),+  }, false, () => {+    if (!g.can_edit) return;+    fetch('brand/confirm', {+      method: 'POST', headers: { 'Content-Type': 'application/json' },+      body: JSON.stringify({ aiThread: 'wizard', fromGate: true }),+    })+      .then(r => r.json())+      .then((out) => {+        if (!out.ok) {+          handleToast({ text: out.error || 'confirm failed', kind: 'warn', seq: Date.now() });+          (out.errors || []).forEach(e => handleToast({ text: (e.field || '?') + ': ' + (e.message || ''), kind: 'warn', seq: Date.now() + Math.random() }));+          return;+        }+        handleToast({ text: 'Confirmed by ' + out.confirmed_by, kind: 'success', seq: Date.now() });+        wizardRender();+      })+      .catch(e => handleToast({ text: String(e), kind: 'warn', seq: Date.now() }));+  });+  if (!g.can_edit) confirmCard.style.opacity = '.55';+  controls.appendChild(confirmCard);+  host.appendChild(wrap);+}+ function wizCard(o, on, onPick) {   const c = document.createElement('div');   c.className = 'card' + (on ? ' on' : '') + (o.sub && o.sub.length > 24 ? ' wide' : '');@@ -642,7 +787,7 @@ function wizardRender() {   $('wintro-ref').textContent = state.page;   $('wd-title').textContent = (wizStepIndex(wizStep) + 1) + '. ' + s.title;   $('wd-hint').textContent = typeof s.hint === 'function' ? s.hint() : s.hint;-  const next = WIZ_STEPS[wizStepIndex(wizStep) + 1];+  const next = wizSteps()[wizStepIndex(wizStep) + 1];   $('wbtn-next').textContent = next ? 'Next: ' + next.title : 'Done';   $('wbtn-next').style.display = next && !s.review ? '' : 'none';   const gbtn = $('wbtn-generate');@@ -686,7 +831,7 @@ function wizCycle(dir) { $('warr-l').addEventListener('click', () => wizCycle(-1)); $('warr-r').addEventListener('click', () => wizCycle(1)); $('wbtn-next').addEventListener('click', () => {-  const next = WIZ_STEPS[wizStepIndex(wizStep) + 1];+  const next = wizSteps()[wizStepIndex(wizStep) + 1];   if (next) wizGoTo(next.id); }); $('wbtn-generate').addEventListener('click', () => {
public/studio.css+14
@@ -440,3 +440,17 @@ body.wizard.show-ctx #contexts { display:flex; }   scrollbar-width:thin; scrollbar-color:var(--border) transparent; } #wcardsrow:has(> #wcards.custom) { align-items:stretch; } #wcards.custom::-webkit-scrollbar { display:block; width:7px; }++/* Brand step: the sentences about this guide read as a compact wrapping+   line above the things the human acts on. A column of full-width lines+   overflowed the dock and clipped both the first note and the cards. */+.wiz-brand { flex:1; display:flex; flex-direction:column; gap:8px; align-items:stretch; justify-content:center; min-height:0; }+.wiz-brand-notes { display:flex; flex-direction:row; flex-wrap:wrap; gap:4px 16px; align-items:baseline; }+.wiz-brand-notes .hint { margin:0; }+.wiz-brand .wiz-finish { flex:0 0 auto; justify-content:flex-start; gap:8px; }+.wiz-swatches { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }+/* #wcards .card:has(.swx) sets width:150px and the swatch a 16/10 box, both+   too tall for this step's row; match that specificity to override. */+#wcards .wiz-swatches .card, #wcards .wiz-swatches .card:has(.swx) { width:112px; min-height:64px; justify-content:center; padding:8px 6px; }+#wcards .wiz-swatches .card .swx { width:100%; height:24px; aspect-ratio:auto; border-radius:6px; }+#wcards .wiz-brand .wiz-finish > .card { min-height:64px; }
server.js+224−5
@@ -45,7 +45,12 @@ let state = {   show: { logo: true, title: true, subtitle: true, description: false },   titleMode: 'image-only', // overlay-only | image-only | both; image-only per John: the overlay is dynamic data, not a second title   overlay: { pill: true, scores: false, author: true, slug: false, logo: false }, // logo = the standard-wordmark stamp over full imports (#37, adom pages only); slug defaults OFF (#29: the baked slug doubled with the marquee's live slug and was the top rejection cause); scores are live-replaced on the marquee; sim defaults them off-  brand: { accent: '', bg: '' },  // non-adom owner palette; empty = studio default+  // The page owner's brand guide (adom/wiki brand guides): resolved from+  // GET /api/owners/<owner>/brand on every pose and page change. A hero wears+  // the PAGE OWNER's colors, type and logo, never Adom's for someone else, so+  // this replaces the two free-floating accent/bg fields it used to carry.+  guide: null,   // { owner, status, sha, version, brand, assets, can_edit, editable_by, url }+  brandEdits: {},  // staged Brand-step edits, dot path -> value, until a human confirms   verify: false,  // the pose gate turns this on: verification chrome + checklist   confirm: { title: false, subtitle: false, showcase: false, type: false },   flags: { title: false, subtitle: false, showcase: false, type: false },  // needs-fixing verdicts (#14): hold Generate, visible to the driving AI@@ -83,7 +88,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', 'subtitle', 'accentWords',+  'fontSize.title', 'fontSize.sub', 'fontSize.desc', 'fontSize.leading', 'fontSize.gap', 'headline', 'subtitle', 'accentWords',   'showcase.chrome', 'showcase.full', 'showcase.cover', ]; const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);@@ -116,7 +121,6 @@ const PRESETS = {     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,     showcase: { chrome: '', cover: false },@@ -381,6 +385,125 @@ async function refreshPageLive(ref) {   return pg; } +/// brand.json with the Brand step's staged edits applied, ready to push.+function brandWithEdits() {+  const g = state.guide;+  if (!g || !g.brand) return null;+  const out = JSON.parse(JSON.stringify(g.brand));+  for (const [path, value] of Object.entries(state.brandEdits || {})) {+    const parts = String(path).split('.').filter(Boolean);+    let cur = out;+    for (let i = 0; i < parts.length - 1; i += 1) {+      if (typeof cur[parts[i]] !== 'object' || cur[parts[i]] === null) cur[parts[i]] = {};+      cur = cur[parts[i]];+    }+    if (parts.length) cur[parts[parts.length - 1]] = value;+  }+  return out;+}++/// Push the confirmed guide: status, who approved it and when, through the+/// page's files API. The wiki validates v1 and answers BRAND_JSON_INVALID+/// with the field names, which the Brand step shows beside the field.+async function confirmBrandGuide(human, fromGate) {+  const g = state.guide;+  const owner = g.owner;+  const REG = process.env.ADOM_WIKI_REGISTRY || 'https://wiki.adom.inc';+  const headers = { 'Content-Type': 'application/json' };+  try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+  let handle = String(human || '').trim();+  if (!handle) {+    try {+      const r = await fetch(REG + '/auth/me', { headers });+      if (r.ok) {+        const me = await r.json();+        handle = (me.user && me.user.username) || me.username || '';+      }+    } catch {}+  }+  if (!handle) return { ok: false, errorCode: 'NO_HANDLE', error: 'confirmed_by needs a handle and the registry did not name one' };+  const brand = brandWithEdits();+  brand.status = 'confirmed';+  brand.confirmed_by = handle;+  brand.confirmed_at = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');+  const files = [{ path: 'brand.json', content: JSON.stringify(brand, null, 2) + '\n' }];+  const r = await fetch(REG + '/api/pages/' + owner + '/brand/files', {+    method: 'POST', headers,+    body: JSON.stringify({ files, message: 'brand: confirmed by ' + handle }),+  });+  const body = await r.json().catch(() => ({}));+  if (!r.ok) {+    return { ok: false, errorCode: body.code || 'BRAND_PUSH_FAILED', error: body.error || ('HTTP ' + r.status),+             errors: (body.data && body.data.errors) || body.errors || [], hint: body.hint || null };+  }+  state.brandEdits = {};+  const fresh = await refreshGuide({ fresh: true });+  logLine('info', 'brand guide confirmed by ' + handle + ' (' + owner + ')' + (fromGate ? ' at the gate' : ' by attestation'));+  broadcast('toast', { text: owner + "'s brand guide confirmed", kind: 'success', seq: ++toastSeq });+  return { ok: true, owner, confirmed_by: handle, confirmed_at: brand.confirmed_at, guide: fresh };+}++/// The page owner's brand guide, from the wiki's resolver. Cached per owner+/// for a short while: the wizard re-renders constantly and the guide changes+/// only when somebody edits it. `status` is confirmed | starter | missing |+/// invalid | private; every one of them is a legitimate answer the UI shows.+const brandCache = new Map();   // owner -> { at, guide }+const BRAND_TTL_MS = 60_000;++async function resolveBrandGuide(owner, { fresh = false } = {}) {+  if (!owner) return null;+  const hit = brandCache.get(owner);+  if (!fresh && hit && Date.now() - hit.at < BRAND_TTL_MS) return hit.guide;+  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 {}+  let guide;+  try {+    const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand', { headers });+    const body = await r.json().catch(() => ({}));+    if (r.status === 404) {+      guide = { owner, status: 'missing', reachable: true, hints: body.hints || [], error: body.error || null };+    } else if (!r.ok) {+      // An unreachable or unhappy registry must not silently become "use+      // Adom's look": say so and let the UI hold.+      guide = { owner, status: 'unavailable', reachable: false, error: body.error || ('HTTP ' + r.status) };+    } else {+      guide = {+        owner,+        status: body.status || 'missing',+        reachable: true,+        page: body.page || null,+        url: body.url || null,+        sha: body.sha || null,+        version: body.version || null,+        brand: body.brand || null,+        assets: body.assets || null,+        can_edit: !!body.can_edit,+        editable_by: body.editable_by || null,+        visibility: body.visibility || null,+        errors: body.errors || [],+        warnings: body.warnings || [],+        hints: body.hints || [],+      };+    }+  } catch (e) {+    guide = { owner, status: 'unavailable', reachable: false, error: String(e && e.message || e).slice(0, 200) };+  }+  brandCache.set(owner, { at: Date.now(), guide });+  return guide;+}++/// Pull the guide for the posed page into state and tell every open surface.+async function refreshGuide({ fresh = false } = {}) {+  const owner = String(state.page || '').split('/')[0];+  const guide = await resolveBrandGuide(owner, { fresh });+  state.guide = guide;+  logLine('info', 'brand guide: ' + owner + ' -> ' + (guide ? guide.status : 'none')+    + (guide && guide.sha ? ' (' + String(guide.sha).slice(0, 8) + ')' : ''));+  broadcast('state', state);+  return guide;+}+ /// Everything this page could show in its showcase, ranked, each with the /// command that produces it. Sources: images already in the page repo, a /// dock manifest (the app has a real UI to launch and shoot), the tools a@@ -614,7 +737,7 @@ function route(req, res, url, p) {     const style = {       layout: state.layout, theme: state.theme, gradAngle: state.gradAngle,       titleMode: state.titleMode, fontSize: state.fontSize, overlay: state.overlay,-      brand: state.brand, show: state.show, guides: false,+      show: state.show, guides: false,       showcase: { chrome: (state.showcase || {}).chrome || '', cover: !!(state.showcase || {}).cover },       mode: state.mode, comp: state.mode === 'component' ? { imgSrc: state.comp.imgSrc, layout: state.comp.layout, mpn: state.comp.mpn } : undefined,       circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined,@@ -681,6 +804,7 @@ function route(req, res, url, p) {       }       if (body.fontSize && typeof body.fontSize.title === 'number') fitPending = null;       if (pageChanged) {+        refreshGuide().catch(() => {});         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)'));@@ -918,6 +1042,94 @@ function route(req, res, url, p) {     res.writeHead(200, { 'Content-Type': 'application/json' });     return res.end(JSON.stringify({ ok: true, seed: state.circuitSeed }));   }+  if (p === '/brand' && req.method === 'GET') {+    const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };+    refreshGuide({ fresh: url.searchParams.get('fresh') === '1' })+      .then((g) => finish(200, { ok: true, guide: g }))+      .catch((e) => finish(200, { ok: false, error: String(e).slice(0, 200) }));+    return;+  }+  if ((p === '/brand/asset' || p === '/brand/font') && req.method === 'GET') {+    // The guide's logo and font files, fetched SERVER-side and streamed to the+    // surface. The browser cannot always reach them itself: a private guide+    // needs the token, and a self-signed registry (staging) is refused by+    // headless Chrome outright. Only roles named by the resolved guide are+    // served, so this is not an open proxy.+    const role = url.searchParams.get('role') || 'mark';+    const g = state.guide || {};+    const assets = g.assets || {};+    let target = null;+    if (p === '/brand/asset') {+      if (['mark', 'wordmark_light', 'wordmark_dark', 'favicon'].includes(role)) target = assets[role] || null;+    } else {+      const f = (assets.fonts || []).find((x) => x && x.role === role);+      if (f && f.url && f.source !== 'google') target = f.url;+    }+    if (!target) { res.writeHead(404); return res.end('no such brand asset'); }+    const headers = {};+    try { headers.Authorization = 'Bearer ' + fs.readFileSync('/var/run/adom/api-key', 'utf8').trim(); } catch {}+    fetch(target, { headers })+      .then(async (r) => {+        if (!r.ok) { res.writeHead(r.status); return res.end('brand asset ' + r.status); }+        const buf = Buffer.from(await r.arrayBuffer());+        res.writeHead(200, {+          'Content-Type': r.headers.get('content-type') || 'application/octet-stream',+          'Cache-Control': 'no-cache',+        });+        res.end(buf);+      })+      .catch((e) => { res.writeHead(502); res.end('brand asset fetch failed: ' + String(e).slice(0, 120)); });+    return;+  }+  if (p === '/brand/edit' && req.method === 'POST') {+    // Staged Brand-step edits: dot path -> value, held in state until a human+    // confirms. Nothing reaches the wiki here.+    return readBody(req, (err, body) => {+      if (err || !body || typeof body.edits !== 'object') {+        res.writeHead(400, { 'Content-Type': 'application/json' });+        return res.end(JSON.stringify({ ok: false, error: 'edits object required' }));+      }+      state.brandEdits = Object.assign({}, state.brandEdits, body.edits);+      for (const k of Object.keys(body.edits)) if (body.edits[k] === null) delete state.brandEdits[k];+      logLine('info', 'brand edits staged: ' + Object.keys(body.edits).join(', '));+      broadcast('state', state);+      res.writeHead(200, { 'Content-Type': 'application/json' });+      res.end(JSON.stringify({ ok: true, edits: state.brandEdits, brand: brandWithEdits() }));+    });+  }+  if (p === '/brand/confirm' && req.method === 'POST') {+    // A HUMAN approving how this owner looks, exactly like Generate: the+    // wizard's Confirm button sends fromGate, and an AI thread must instead+    // attest who approved it and quote their words.+    return readBody(req, (err, body) => {+      const finish = (code, payload) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };+      if (err) return finish(400, { ok: false, error: 'bad json' });+      const fromGate = body && body.fromGate === true;+      const attest = (body && body.attest) || null;+      const g = state.guide;+      if (!g || !g.brand) {+        return finish(200, { ok: false, errorCode: 'BRAND_GUIDE_MISSING',+          error: 'there is no brand.json to confirm for ' + (g ? g.owner : '?'),+          hint: 'create the guide first: the Brand step offers it to anyone who can edit the page' });+      }+      if (!g.can_edit) {+        return finish(200, { ok: false, errorCode: 'BRAND_NOT_EDITABLE',+          error: (g.editable_by && g.editable_by.text) || 'you cannot edit this guide',+          hint: 'anyone who can edit the page can confirm it; ask one of them' });+      }+      if (!fromGate) {+        if (!attest || !attest.human || !String(attest.quote || '').trim()) {+          return finish(200, { ok: false, errorCode: 'HUMAN_GATE',+            error: 'confirming a brand guide is human-gated: the human clicks Confirm in the Brand step',+            hint: 'to record a human\'s approval from a thread, POST with attest: {human: "<who approved>", quote: "<their actual words>"}' });+        }+      }+      const who = (attest && attest.human) || (body && body.human) || '';+      confirmBrandGuide(who, fromGate)+        .then((out) => finish(200, out))+        .catch((e) => finish(200, { ok: false, error: String(e && e.message || e).slice(0, 300) }));+    });+  }   if (p === '/showcase-candidates' && req.method === 'GET') {     // What this page could put in its showcase, ranked, with the exact     // command for each (hero-studio#57, Colby: the AI should decide what@@ -1231,7 +1443,14 @@ function route(req, res, url, p) {         }       }       // 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, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), showcase: showcaseSaved ? { path: showcaseSaved.path, sha256: showcaseSaved.sha256, w: showcaseSaved.w, h: showcaseSaved.h, chrome: showcaseSaved.chrome, full: showcaseSaved.full, cover: showcaseSaved.cover } : undefined, circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined, prior: req._priorProvenance || undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });+      // adom/wiki brand guides: whose look this hero wears, and which+      // revision of that guide. The wiki's enforcement reads exactly these+      // four names, so they sit at the top level of the stamp.+      const g = state.guide || {};+      const branding = g.owner || String(state.page || '').split('/')[0] || null;+      const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia,+        branding, brand_sha: g.sha || null, brand_version: g.version || null, brand_status: g.status || 'missing',+        state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, showcase_used: !!(state.showcase && state.showcase.src), showcase_full: !!(state.showcase && state.showcase.full), showcase: showcaseSaved ? { path: showcaseSaved.path, sha256: showcaseSaved.sha256, w: showcaseSaved.w, h: showcaseSaved.h, chrome: showcaseSaved.chrome, full: showcaseSaved.full, cover: showcaseSaved.cover } : undefined, circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, attestation: attest ? { human: attest.human, quote: String(attest.quote).slice(0, 300), aiThread: req.aiThread || '', ts, style: attest.style || undefined, styleGate: req._styleGate ? req._styleGate.gate : undefined, prior: req._priorProvenance || undefined } : undefined, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });       const py = 'import sys\n' +         'from PIL import Image\n' +         'from PIL.PngImagePlugin import PngInfo\n' +
test/brand.mjsadded+284
@@ -0,0 +1,284 @@+#!/usr/bin/env node+// Brand-guide tests: node test/brand.mjs+//+// The guide resolver is a network contract, so every case here runs against a+// STUB registry rather than staging. The stub is the contract from the+// handoff: GET /api/owners/:owner/brand answers the resolved guide, POST+// /api/pages/:owner/brand/files takes the confirm push. That lets each status+// (confirmed, starter, missing, invalid, private, unreachable) be exercised+// deterministically, and lets the confirm push body be inspected byte for+// byte without writing to any real owner's guide.+import http from 'http';+import { spawn, execFile } from 'child_process';+import fs from 'fs';+import path from 'path';+import { fileURLToPath } from 'url';++const DIR = path.dirname(fileURLToPath(import.meta.url));+const ROOT = path.join(DIR, '..');++let pass = 0;+const fails = [];+function ok(name, cond, detail) {+  if (cond) { pass++; console.log('  ok   ' + name); }+  else { fails.push(name + (detail ? ': ' + detail : '')); console.log('  FAIL ' + name + (detail ? ': ' + detail : '')); }+}+const eq = (name, got, want) => ok(name, got === want, 'got ' + JSON.stringify(got) + ', want ' + JSON.stringify(want));++// ---- the stub registry ----++const GUIDES = {+  goodco: {+    status: 'confirmed', version: 3, sha: 'a'.repeat(40), can_edit: true,+    owner_kind: 'org', visibility: 'public',+    page: 'goodco/brand', url: 'http://stub/goodco/brand',+    editable_by: { rule: 'org-members', owner_kind: 'org', who: ['goodco'], text: 'Any member of goodco can edit goodco/brand.' },+    brand: {+      brand_manifest: '1.0', owner: 'goodco', name: 'GoodCo', status: 'confirmed',+      confirmed_by: 'someone', confirmed_at: '2026-01-01T00:00:00Z',+      colors: { primary: '#ff0000', background: '#101010', text: '#ffffff' },+      typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },+      logos: { mark: 'assets/mark.svg' },+    },+    assets: { mark: 'http://stub/goodco/brand/files/assets/mark.svg', fonts: [] },+    hints: [], warnings: [], errors: [],+  },+  starterco: {+    status: 'starter', version: 1, sha: 'b'.repeat(40), can_edit: true,+    owner_kind: 'user', visibility: 'public',+    page: 'starterco/brand', url: 'http://stub/starterco/brand',+    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['starterco'], text: 'Only starterco can edit starterco/brand.' },+    brand: {+      brand_manifest: '1.0', owner: 'starterco', name: 'Starterco', status: 'starter',+      colors: { primary: '#00ff00', background: '#181818', text: '#ffffff' },+      typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },+      logos: { mark: 'assets/mark.png' },+    },+    assets: { mark: 'http://stub/starterco/brand/files/assets/mark.png', fonts: [] },+    hints: [{ level: 'warning', code: 'BRAND_GUIDE_STARTER', message: 'starterco/brand is a starter guide nobody has approved yet.' }],+    warnings: [], errors: [],+  },+  // can_edit false: a thread looking at someone else's guide+  lockedco: {+    status: 'starter', version: 1, sha: 'c'.repeat(40), can_edit: false,+    owner_kind: 'user', visibility: 'public',+    page: 'lockedco/brand', url: 'http://stub/lockedco/brand',+    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['lockedco'], text: 'Only lockedco can edit lockedco/brand.' },+    brand: {+      brand_manifest: '1.0', owner: 'lockedco', name: 'Lockedco', status: 'starter',+      colors: { primary: '#0000ff', background: '#181818', text: '#ffffff' },+      typography: { display: { family: 'Inter' }, body: { family: 'Inter' } },+      logos: { mark: 'assets/mark.png' },+    },+    assets: { mark: 'http://stub/lockedco/brand/files/assets/mark.png', fonts: [] },+    hints: [], warnings: [], errors: [],+  },+  invalidco: {+    status: 'invalid', version: 1, sha: 'd'.repeat(40), can_edit: true,+    owner_kind: 'user', visibility: 'public',+    page: 'invalidco/brand', url: 'http://stub/invalidco/brand',+    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['invalidco'], text: 'Only invalidco can edit invalidco/brand.' },+    brand: null, assets: null,+    errors: [{ field: 'colors.primary', message: 'not a hex color' }],+    warnings: [], hints: [],+  },+  privateco: {+    status: 'private', version: null, sha: null, can_edit: false,+    owner_kind: 'user', visibility: 'private',+    page: 'privateco/brand', url: null,+    editable_by: { rule: 'owner-only', owner_kind: 'user', who: ['privateco'], text: 'Only privateco can edit privateco/brand.' },+    brand: null, assets: null, errors: [], warnings: [], hints: [],+  },+};++const pushes = [];+let brandRouteBroken = false;++const stub = http.createServer((req, res) => {+  const u = new URL(req.url, 'http://stub');+  const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };+  let m = u.pathname.match(/^\/api\/owners\/([^/]+)\/brand$/);+  if (m && req.method === 'GET') {+    if (brandRouteBroken) return send(500, { error: 'stub is down' });+    const g = GUIDES[decodeURIComponent(m[1])];+    if (!g) return send(404, { error: 'no brand guide', hints: [{ level: 'warning', code: 'BRAND_GUIDE_STARTER', message: 'no guide yet' }] });+    return send(200, g);+  }+  m = u.pathname.match(/^\/api\/pages\/([^/]+)\/brand\/files$/);+  if (m && req.method === 'POST') {+    let body = '';+    req.on('data', (c) => { body += c; });+    req.on('end', () => {+      let parsed = null;+      try { parsed = JSON.parse(body); } catch {}+      pushes.push({ owner: decodeURIComponent(m[1]), body: parsed });+      send(200, { ok: true, sha: 'e'.repeat(40) });+    });+    return;+  }+  // the page rows the studio pulls for titles: answer empty so nothing hangs+  if (u.pathname.startsWith('/api/pages')) return send(404, { error: 'not found' });+  send(404, { error: 'not found' });+});++// ---- harness ----++const listen = (srv) => new Promise((r) => srv.listen(0, '127.0.0.1', () => r(srv.address().port)));++async function main() {+  const stubPort = await listen(stub);+  const REG = 'http://127.0.0.1:' + stubPort;++  const srv = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--ai-thread', 'brand test', '--ttl', '3'], {+    cwd: ROOT,+    env: { ...process.env, ADOM_WIKI_REGISTRY: REG, HERO_STUDIO_PORT: 'auto', HERO_RENDERER: 'local' },+    stdio: ['ignore', 'pipe', 'pipe'],+  });+  let port = 0;+  let log = '';+  srv.stdout.on('data', (d) => { log += d; const m = /hero-studio on (\d+)/.exec(String(d)); if (m && !port) port = +m[1]; });+  srv.stderr.on('data', (d) => { log += d; const m = /hero-studio on (\d+)/.exec(String(d)); if (m && !port) port = +m[1]; });+  for (let i = 0; i < 100 && !port; i++) await new Promise((r) => setTimeout(r, 100));+  if (!port) { console.error('server never printed its port. log:\n' + log); srv.kill('SIGTERM'); process.exit(1); }+  const BASE = 'http://127.0.0.1:' + port + '/';+  // the server answers only once it is actually listening+  for (let i = 0; i < 100; i++) {+    try { await fetch(BASE + 'version'); break; } catch { await new Promise((r) => setTimeout(r, 100)); }+  }++  const get = async (p) => (await fetch(BASE + p)).json();+  const post = async (p, body) => (await fetch(BASE + p, {+    method: 'POST', headers: { 'Content-Type': 'application/json' },+    body: JSON.stringify({ aiThread: 'brand test', ...(body || {}) }),+  })).json();+  const pose = async (ref) => { await post('state', { mode: 'app', page: ref, comp: { id: null } }); return (await get('brand?fresh=1')).guide; };++  try {+    // 1. every status the contract names resolves to itself, and a guide is+    // only usable when the server says so.+    console.log('the resolver, per status:');+    let g = await pose('goodco/brand');+    eq('confirmed resolves', g.status, 'confirmed');+    eq('  carries the sha', g.sha, 'a'.repeat(40));+    eq('  carries the version', g.version, 3);+    ok('  carries the brand body', !!(g.brand && g.brand.colors.primary === '#ff0000'));++    g = await pose('starterco/brand');+    eq('starter resolves', g.status, 'starter');+    ok('  relays the server hints', (g.hints || []).some((h) => h.code === 'BRAND_GUIDE_STARTER'));+    ok('  relays editable_by.text verbatim', (g.editable_by || {}).text === 'Only starterco can edit starterco/brand.');++    g = await pose('nobodyco/brand');+    eq('a 404 is missing, not an error', g.status, 'missing');+    ok('  and never borrows Adom\'s brand body', !g.brand);++    g = await pose('invalidco/brand');+    eq('invalid resolves', g.status, 'invalid');+    ok('  and names the bad fields', (g.errors || []).some((e) => e.field === 'colors.primary'));+    ok('  and applies nothing', !g.brand);++    g = await pose('privateco/brand');+    eq('private resolves', g.status, 'private');+    ok('  and applies nothing', !g.brand);++    // 2. an unhappy registry must say so, NOT silently become "use Adom's+    // look": a wrong brand published under someone else's name is worse than+    // a hero that holds.+    brandRouteBroken = true;+    g = await pose('goodco/brand');+    eq('a 500 from the registry is unavailable', g.status, 'unavailable');+    eq('  and is marked unreachable', g.reachable, false);+    ok('  and applies nothing', !g.brand);+    brandRouteBroken = false;++    // 3. confirm is a human act, and only for a guide this identity may edit.+    console.log('confirm:');+    await pose('lockedco/brand');+    let r = await post('brand/confirm', { fromGate: true, human: 'someone' });+    ok('can_edit:false cannot confirm', r.ok === false, JSON.stringify(r).slice(0, 160));+    ok('  and the refusal names who may', /Only lockedco can edit/.test(JSON.stringify(r)), JSON.stringify(r).slice(0, 160));+    eq('  and nothing was pushed', pushes.length, 0);++    r = await post('brand/confirm', {});+    ok('an unattested confirm is refused', r.ok === false, JSON.stringify(r).slice(0, 160));+    eq('  and nothing was pushed', pushes.length, 0);++    // 4. the confirm push body is brand.json v1, with the human recorded.+    await pose('starterco/brand');+    r = await post('brand/confirm', { fromGate: true, human: 'starterco' });+    ok('a confirm on an editable starter guide pushes', r.ok === true, JSON.stringify(r).slice(0, 200));+    eq('  pushed once', pushes.length, 1);+    const push = pushes[0] || { body: {} };+    eq('  to the right owner', push.owner, 'starterco');+    const files = (push.body && push.body.files) || [];+    const bj = files.find((f) => f.path === 'brand.json');+    ok('  the push writes brand.json', !!bj, JSON.stringify(files.map((f) => f.path)));+    ok('  with a commit message', !!(push.body && push.body.message));+    let written = null;+    try { written = JSON.parse(bj.encoding === 'base64' ? Buffer.from(bj.content, 'base64').toString('utf8') : bj.content); } catch {}+    ok('  the written brand.json parses', !!written);+    if (written) {+      eq('  brand_manifest is 1.0', written.brand_manifest, '1.0');+      eq('  owner matches the page owner', written.owner, 'starterco');+      eq('  status becomes confirmed', written.status, 'confirmed');+      eq('  confirmed_by names the human', written.confirmed_by, 'starterco');+      ok('  confirmed_at is an ISO instant', typeof written.confirmed_at === 'string' && !isNaN(Date.parse(written.confirmed_at)), String(written.confirmed_at));+      // v1 required keys survive the confirm: a confirm must not drop fields+      ok('  colors survive the confirm', !!(written.colors && written.colors.primary === '#00ff00'));+      ok('  typography survives the confirm', !!(written.typography && written.typography.display.family === 'Inter'));+      ok('  logos survive the confirm', !!(written.logos && written.logos.mark));+    }++    // 5. provenance names the brand the hero was drawn with, under exactly+    // the four keys the handoff fixes: branding, brand_sha, brand_version,+    // brand_status. A consumer reading a published hero can tell which guide+    // (and which revision of it) produced those pixels.+    console.log('provenance:');+    await pose('goodco/brand');+    const gen = await post('generate', { fromGate: true });+    ok('a gate generate renders', gen.ok === true, JSON.stringify(gen).slice(0, 300));+    if (gen.ok && gen.file) {+      const png = path.join(ROOT, 'generated', gen.file);+      const meta = await readPngComment(png);+      ok('  the PNG carries a provenance comment', !!meta, String(meta).slice(0, 120));+      if (meta) {+        eq('  branding names the page owner', meta.branding, 'goodco');+        eq('  brand_sha is the resolved sha', meta.brand_sha, 'a'.repeat(40));+        eq('  brand_version is the resolved version', meta.brand_version, 3);+        eq('  brand_status is the resolved status', meta.brand_status, 'confirmed');+        ok('  the four keys sit at the top level of the meta', ['branding', 'brand_sha', 'brand_version', 'brand_status'].every((k) => k in meta));+      }+      try { fs.unlinkSync(png); } catch {}+    }+  } finally {+    try { await post('shutdown', {}); } catch {}+    setTimeout(() => srv.kill('SIGKILL'), 3000);+    srv.kill('SIGTERM');+    stub.close();+  }++  console.log('');+  if (fails.length) {+    console.log(pass + ' passed, ' + fails.length + ' FAILED:');+    for (const f of fails) console.log('  - ' + f);+    process.exit(1);+  }+  console.log(pass + ' passed');+  process.exit(0);+}++/// The provenance comment out of a PNG's tEXt chunk, parsed.+function readPngComment(file) {+  return new Promise((resolve) => {+    const py = 'import sys, json\n'+      + 'from PIL import Image\n'+      + 'im = Image.open(sys.argv[1])\n'+      + 'print(im.info.get("comment") or im.text.get("comment") or "")\n';+    execFile('python3', ['-c', py, file], (err, stdout) => {+      if (err) return resolve(null);+      try { resolve(JSON.parse(String(stdout).trim())); } catch { resolve(null); }+    });+  });+}++main().catch((e) => { console.error(e); process.exit(1); });