app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
← Commit history
Publish 0.20.9
11 files changed
+1106−42
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" ]-}+}
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); });