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.21.0
8 files changed
+301−36
SKILL.md+29@@ -493,6 +493,35 @@ 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. +### Generate confirms the guide (0.21.0)++From 0.21.0 the wiki REFUSES a studio hero (`400 BRANDING_POLICY`, on the+hero route and on publish) unless its stamp says `branding: <page owner>` and+`brand_status: confirmed`. Nobody should ever see that refusal, so the studio+makes it impossible to produce:++- **Missing guide:** on pose the studio asks the registry to draft a starter+ (`POST /api/owners/<owner>/brand/starter`) and waits for it, so the Brand+ step opens populated. Once per owner per session; the outcome rides on the+ guide as `starter` (`drafting`, `exists`, `refused`, `unreachable`).+- **Starter guide the human may edit:** Generate confirms it in the same+ click: brand.json goes out with `status: confirmed`, `confirmed_by` (the+ human's handle, from the registry) and `confirmed_at`, and the stamp is read+ from the registry AFTER that push, never from the pose. The Brand step says+ so, so the click is informed; the Confirm card stays for approving without+ generating.+- **Anything that cannot end confirmed** keeps Generate locked with the+ resolver's own words (`editable_by.text`): a guide this identity cannot+ edit, an invalid one, a refused draft, an unreachable registry. The linter+ shows these as hard failures. Never a fall back to Adom's or a neutral look:+ the wiki would refuse the result.+- Whose guide: the page's org tag (`org_name`), else the URL owner. An+ adom-org page under a user namespace wears `adom`, which is what the wiki+ checks against.++Stamps below 0.21.0 get a warning from the wiki, not a refusal, so an older+studio keeps working until it updates.+ ### The Brand step, and who may confirm When the guide is anything but `confirmed`, the wizard inserts a **Brand**
data/pages.json+14@@ -1,4 +1,18 @@ [+ {+ "type": "skill",+ "title": "test5 brand",+ "author": "",+ "score": 0,+ "stars": 0,+ "installs30d": 0,+ "brief": "",+ "tags": [],+ "updated_at": "2026-09-18T21:06:07.423Z",+ "aiThread": "t",+ "owner": "test5",+ "slug": "brand"+ }, { "type": "component", "title": "Nametag Blank Large LED",
package.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.20.11",+ "version": "0.21.0", "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is 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": [
page.json+1−1@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.20.11",+ "version": "0.21.0", "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is 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": [
public/js/app.js+26−21@@ -1102,32 +1102,37 @@ 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.+ // 6e. brand provenance: from 0.21.0 the wiki REFUSES a studio hero that is+ // not stamped with the page owner's confirmed guide, so Generate is locked+ // (level fail) whenever this click could not end that way. A starter guide+ // the human may edit is fine: Generate confirms it in the same click. The+ // drawn-pixel checks below read COMPUTED style, so a font that fell back or+ // a theme painting over the brand is caught here, not after publish. { const g = state.guide || null;- const owner = String(state.page || '').split('/')[0] || '';- const foreign = !!owner && owner !== 'adom';+ const owner = (g && g.owner) || String(state.page || '').split('/')[0] || ''; 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' });+ const who = (g && g.editable_by && g.editable_by.text) || ('only ' + owner + ' can edit ' + owner + '/brand');+ if (state.mode === 'app') {+ if (!g || g.status === 'unavailable') {+ items.push({ id: 'brand-unresolved', level: 'fail', el: null, msg: 'the brand guide for "' + owner + '" could not be resolved' + (g && g.error ? ' (' + g.error + ')' : '') + '. The wiki refuses a hero that does not wear the owner\'s confirmed guide, so Generate is locked: hero-studio doctor --owner ' + owner });+ } else if (g.status === 'missing') {+ const st = g.starter || {};+ items.push({ id: 'brand-missing', level: 'fail', el: null, msg: owner + ' has no brand guide' + (st.result === 'refused' ? ', and a starter could not be drafted for you: ' + (st.error || st.code) : st.result === 'drafting' || st.result === 'timeout' ? '; a starter is being drafted, re-pose in a moment' : '') + '. ' + who });+ } else if (g.status === 'invalid') {+ const fields = (g.errors || []).map((e) => (e && e.field) || '?').join(', ');+ items.push({ id: 'brand-invalid', level: 'fail', el: null, msg: owner + '/brand fails brand.json v1 validation' + (fields ? ' (' + fields + ')' : '') + '. The wiki refuses a hero on an unconfirmed guide, so Generate is locked until those fields are fixed at the Brand step. ' + who });+ } else if (g.status !== 'confirmed' && !g.can_edit) {+ items.push({ id: 'brand-locked', level: 'fail', el: null, msg: owner + '/brand is ' + g.status + ' and this identity cannot confirm it, so Generate is locked. ' + who });+ } else if (g.status === 'starter' && g.can_edit) {+ items.push({ id: 'brand-starter', level: 'info', el: null, msg: 'Generate also confirms ' + owner + '/brand in this click, recording you as the human who approved these colors and fonts. Check them on the hero first' }); }+ }+ if (brand && !applied && g && g.status !== 'invalid') {+ 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' });+ }+ if (brand && applied) { // the drawn pixels vs the guide: computed font family and title ink const titleEl = $('el-title'); if (titleEl && vis(titleEl)) {
public/js/wizard.js+29−5@@ -720,21 +720,45 @@ function brandStepBody(host) { // Confirm: the human action, and the ONLY way this step completes. It was a // muted card at the end of a row and read as decoration, so it says plainly // what it does and what happens if nobody clicks it.+ // 0.21.0: the wiki refuses a hero that does not wear the owner's CONFIRMED+ // guide, so the click that makes the hero is the click that confirms it.+ // Say so here, so the click is informed; the Confirm card stays as the+ // explicit path for someone who wants to approve without generating. if (g.status !== 'confirmed' && g.can_edit) { const call = document.createElement('div'); call.className = 'hint wiz-brand-call';- call.textContent = 'Nobody has approved this look yet. Check the hero above, then click Confirm: '- + 'that records you as the human who approved it and stamps it into every hero made from it. '- + 'Until then each hero carries a warning that these colors were never approved.';+ call.textContent = g.status === 'starter'+ ? 'Nobody has approved this look yet. Check it on the hero above and adjust it here. '+ + 'Generate confirms this guide in the same click, recording you as the human who approved it, '+ + 'and every hero made from it after that carries that approval. Confirm now if you would rather approve without generating.'+ : 'Fix the fields named above, then Generate: it confirms the guide in the same click.'; notes.appendChild(call); }+ if (g.status !== 'confirmed' && !g.can_edit) {+ const lock = document.createElement('div');+ lock.className = 'hint wiz-brand-call';+ lock.textContent = 'Generate is locked: the wiki refuses a hero that does not wear ' + (g.owner || 'this owner') + '\u2019s confirmed guide, and only someone who can edit it may confirm it. '+ + ((g.editable_by && g.editable_by.text) || '');+ notes.appendChild(lock);+ }+ if (g.status === 'missing' && g.starter) {+ const st = g.starter;+ const d = document.createElement('div');+ d.className = 'hint wiz-brand-call';+ d.textContent = st.result === 'refused'+ ? 'A starter guide could not be drafted for ' + (g.owner || 'this owner') + ': ' + (st.error || st.code) + (st.hint ? ' ' + st.hint : '')+ : st.result === 'unreachable'+ ? 'The registry could not be reached to draft a starter guide: ' + (st.error || '') + '. Pose again when it answers.'+ : 'A starter guide is being drafted for ' + (g.owner || 'this owner') + '; pose again in a moment and it opens populated.';+ notes.appendChild(d);+ } const confirmCard = wizCard({ v: 'confirm',- label: g.status === 'confirmed' ? 'Confirmed' : (g.can_edit ? 'Confirm this brand' : 'Cannot confirm'),+ label: g.status === 'confirmed' ? 'Confirmed' : (g.can_edit ? 'Confirm now' : 'Cannot confirm'), sub: g.status === 'confirmed' ? 'approved by ' + ((g.brand && g.brand.confirmed_by) || 'a human') : (g.can_edit- ? 'you are ' + (g.owner || 'this owner') + '; click to approve'+ ? 'approve without generating; Generate does this too' : ((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;
server.js+125−5@@ -395,7 +395,7 @@ async function refreshPageLive(ref) { const pg = pj.page || pj; const [owner, slug] = ref.split('/'); const i = PAGES.findIndex((x) => x.owner === owner && x.slug === slug);- const live = { owner, slug, title: pg.title, brief: pg.brief, type: pg.type === 'hardware' ? 'component' : pg.type, kind: pg.kind, live: true };+ const live = { owner, slug, title: pg.title, brief: pg.brief, type: pg.type === 'hardware' ? 'component' : pg.type, kind: pg.kind, org: pg.org_name || null, live: true }; const entry = Object.assign({ author: '', score: 0, stars: 0, installs30d: 0, tags: [] }, i >= 0 ? PAGES[i] : {}, Object.fromEntries(Object.entries(live).filter(([, v]) => v != null))); if (i >= 0) PAGES[i] = entry; else PAGES.unshift(entry); bumpPages();@@ -512,9 +512,90 @@ async function resolveBrandGuide(owner, { fresh = false } = {}) { } /// Pull the guide for the posed page into state and tell every open surface.+/// Whose guide a page wears. The wiki judges a hero against the page's ORG+/// tag, not its URL: an adom-org page under a user namespace must wear adom.+/// The live page row carries that tag as org_name; without one, the URL owner.+function brandOwnerFor(ref) {+ const [owner, slug] = String(ref || '').split('/');+ const row = PAGES.find((x) => x.owner === owner && x.slug === slug);+ return (row && row.org) || owner || '';+}++// Owners this session already asked the registry to draft a starter for, so+// a missing guide is drafted once, not on every re-render; and what came of+// it, so a refusal or a slow draft is explained on every render, including a+// ?fresh=1 read that bypasses the guide cache.+const starterTried = new Set();+const starterOutcome = new Map();++/// Draft the owner's starter guide on demand (0.21.0, brand-policy handoff):+/// POST .../brand/starter answers 202 drafting (poll the resolver until it is+/// there, a few seconds), 200 exists (idempotent), or 403 BRAND_EDITORS when+/// this identity may not edit that owner's guide. Never throws: the outcome+/// rides on the guide as `starter` so the Brand step can say what happened.+async function draftStarter(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 outcome;+ try {+ const r = await fetch(REG + '/api/owners/' + encodeURIComponent(owner) + '/brand/starter', { method: 'POST', headers, body: '{}' });+ const body = await r.json().catch(() => ({}));+ if (r.status === 202 || r.status === 200) {+ outcome = { attempted: true, result: r.status === 202 ? 'drafting' : 'exists' };+ logLine('info', 'brand starter for ' + owner + ': ' + outcome.result);+ // the draft takes a few seconds; the Brand step must open populated+ for (let i = 0; i < 15; i++) {+ const g = await resolveBrandGuide(owner, { fresh: true });+ if (g.status !== 'missing') return { guide: g, starter: outcome };+ if (r.status === 200 || brandOwnerFor(state.page) !== owner) break;+ await new Promise((res) => setTimeout(res, 1000));+ }+ outcome.result = 'timeout';+ } else {+ outcome = { attempted: true, result: 'refused', code: body.code || ('HTTP_' + r.status), error: body.error || ('HTTP ' + r.status), hint: body.hint || null };+ logLine('warn', 'brand starter for ' + owner + ' refused: ' + outcome.error);+ }+ } catch (e) {+ outcome = { attempted: true, result: 'unreachable', error: String(e && e.message || e).slice(0, 200) };+ }+ const g = await resolveBrandGuide(owner, { fresh: true });+ return { guide: g, starter: outcome };+}++// One in-flight refresh per owner: a pose fires refreshGuide from pageChanged+// AND from the wizard's GET /brand?fresh=1, and the second used to skip the+// starter draft (owner already tried) and answer `missing` while the first+// was still polling. Both callers now await the same work.+const guideInflight = new Map();+ async function refreshGuide({ fresh = false } = {}) {- const owner = String(state.page || '').split('/')[0];- const guide = await resolveBrandGuide(owner, { fresh });+ const owner = brandOwnerFor(state.page);+ let p = guideInflight.get(owner);+ if (!p) {+ p = (async () => {+ let guide = await resolveBrandGuide(owner, { fresh });+ // A missing guide is drafted, once, so the Brand step is never empty+ // and a 0.21.0 hero can always be made to wear a confirmed guide.+ if (guide && guide.status === 'missing' && state.mode === 'app' && owner && !starterTried.has(owner)) {+ starterTried.add(owner);+ const drafted = await draftStarter(owner);+ starterOutcome.set(owner, drafted.starter);+ guide = drafted.guide;+ }+ if (guide && starterOutcome.has(owner)) {+ guide = Object.assign({}, guide, { starter: starterOutcome.get(owner) });+ brandCache.set(owner, { at: Date.now(), guide });+ }+ return guide;+ })().finally(() => guideInflight.delete(owner));+ guideInflight.set(owner, p);+ }+ const guide = await p;+ // A draft can take seconds; if the human posed another page meanwhile,+ // this result is about the OLD owner and must not become the new page's+ // guide (it stamped one page's hero with another owner's branding).+ if (brandOwnerFor(state.page) !== owner) return guide; state.guide = guide; logLine('info', 'brand guide: ' + owner + ' -> ' + (guide ? guide.status : 'none') + (guide && guide.sha ? ' (' + String(guide.sha).slice(0, 8) + ')' : ''));@@ -1328,15 +1409,54 @@ function route(req, res, url, p) { attestation: prior.attestation || prior.approval || undefined, }; logLine('info', 'attested re-generate for ' + state.page + ' anchored to the published hero\'s provenance (' + prior.generator + ' ' + (prior.version || '?') + ')');- proceed();+ confirmThenProceed(); }) .catch((e) => refuse('could not read the published hero\'s provenance: ' + e.message, 'the anchor for a cross-session attest is the page\'s current hero; check the page ref and that its hero is reachable, or fall back to an in-session human Generate')); return; } }- proceed();+ confirmThenProceed(); return;+ // 0.21.0 (brand-policy handoff): the wiki refuses a hero from this version+ // on that is not stamped with the page owner's CONFIRMED guide. So a+ // refusal must be impossible to produce here: a starter guide the human+ // may edit is confirmed by this same click (what `adom-wiki brand confirm`+ // does), and anything else that cannot end confirmed keeps Generate+ // locked with the resolver's own words. The stamp is read AFTER the push.+ function confirmThenProceed() {+ if (state.mode !== 'app') return proceed();+ const g = state.guide;+ const owner = g && g.owner;+ if (!g || g.status === 'unavailable') {+ return refuse('the brand guide for ' + (owner || 'this page') + ' could not be resolved, and the wiki refuses a hero that does not wear the owner\'s confirmed guide',+ 'check the registry (hero-studio doctor --owner ' + (owner || '<owner>') + ') and pose again');+ }+ if (g.status === 'confirmed') return proceed();+ if (g.status === 'starter' && g.can_edit) {+ const human = fromGate ? '' : ((attest && attest.human) || '');+ return confirmBrandGuide(human, fromGate)+ .then((out) => {+ if (!out.ok) return refuse('confirming ' + owner + '/brand failed: ' + (out.error || out.errorCode), out.hint || 'fix the guide at the Brand step and try again');+ if (!state.guide || state.guide.status !== 'confirmed') {+ return refuse('the registry still reports ' + owner + '/brand as ' + ((state.guide && state.guide.status) || 'unknown') + ' after the confirm push', 'the hero would be refused by the wiki; re-read the guide and try again');+ }+ proceed();+ })+ .catch((e) => refuse('confirming ' + owner + '/brand failed: ' + String(e && e.message || e).slice(0, 200), 'try again, or confirm it with adom-wiki brand confirm ' + owner));+ }+ const who = (g.editable_by && g.editable_by.text) || ('only ' + owner + ' can edit ' + owner + '/brand');+ if (g.status === 'missing') {+ const st = g.starter || {};+ return refuse(owner + ' has no brand guide and one could not be drafted' + (st.error ? ' (' + st.error + ')' : ''),+ st.hint || (who + '; a hero cannot be made until the guide exists'));+ }+ if (g.status === 'invalid') {+ return refuse(owner + '/brand fails validation, and the wiki refuses a hero on an unconfirmed guide',+ 'fix the named fields (' + (g.errors || []).map((e) => e.field).join(', ') + ') at the Brand step; ' + who);+ }+ return refuse('Generate is locked: ' + owner + '/brand is ' + g.status + ' and this identity cannot confirm it', who);+ } function proceed() { generating = true; const slug = state.mode === 'component'
test/brand.mjs+76−3@@ -93,6 +93,10 @@ const GUIDES = { const pushes = []; let brandRouteBroken = false;+// starter route (0.21.0): who gets a draft, who is refused+const DRAFTED = new Set();+const STARTER_REFUSED = new Set(['nobodyelse']);+const starterCalls = []; const stub = http.createServer((req, res) => { const u = new URL(req.url, 'http://stub');@@ -100,10 +104,29 @@ const stub = http.createServer((req, res) => { 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])];+ const o = decodeURIComponent(m[1]);+ const g = GUIDES[o];+ if (!g && DRAFTED.has(o)) {+ // what the registry drafts: a starter the owner can edit+ return send(200, { status: 'starter', version: 1, sha: 'f'.repeat(40), can_edit: true, owner_kind: 'user', visibility: 'public',+ page: o + '/brand', url: 'http://stub/' + o + '/brand', editable_by: { rule: 'owner-only', owner_kind: 'user', who: [o], text: 'Only ' + o + ' can edit ' + o + '/brand.' },+ brand: { brand_manifest: '1.0', owner: o, name: o, status: 'starter', colors: { primary: '#394dd0', background: '#0c0d18', text: '#ffffff' },+ typography: { display: { family: 'Inter' }, body: { family: 'Inter' } }, logos: { mark: 'assets/mark.svg' } },+ assets: { mark: 'http://stub/' + o + '/brand/files/assets/mark.svg', fonts: [] }, hints: [], warnings: [], errors: [] });+ } 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\/owners\/([^/]+)\/brand\/starter$/);+ if (m && req.method === 'POST') {+ const o = decodeURIComponent(m[1]);+ starterCalls.push(o);+ if (STARTER_REFUSED.has(o)) return send(403, { error: 'Only ' + o + ' can edit ' + o + '/brand.', code: 'BRAND_EDITORS', hint: 'ask ' + o });+ if (GUIDES[o] || DRAFTED.has(o)) return send(200, { ok: true, owner: o, status: 'exists', drafting: false });+ // 202 now; the draft "lands" on the next resolver read+ setTimeout(() => DRAFTED.add(o), 300);+ return send(202, { ok: true, owner: o, status: 'drafting', drafting: true });+ } m = u.pathname.match(/^\/api\/pages\/([^/]+)\/brand\/files$/); if (m && req.method === 'POST') { let body = '';@@ -111,11 +134,19 @@ const stub = http.createServer((req, res) => { req.on('end', () => { let parsed = null; try { parsed = JSON.parse(body); } catch {}- pushes.push({ owner: decodeURIComponent(m[1]), body: parsed });+ const o = decodeURIComponent(m[1]);+ pushes.push({ owner: o, body: parsed });+ try {+ const bj = (parsed.files || []).find((f) => f.path === 'brand.json');+ const b = JSON.parse(bj.encoding === 'base64' ? Buffer.from(bj.content, 'base64').toString('utf8') : bj.content);+ if (b.status === 'confirmed' && GUIDES[o]) { GUIDES[o].status = 'confirmed'; GUIDES[o].brand = b; GUIDES[o].sha = 'e'.repeat(40); }+ } catch {} send(200, { ok: true, sha: 'e'.repeat(40) }); }); return; }+ // the human at the gate, as the registry names them+ if (u.pathname === '/auth/me') return send(200, { user: { username: 'starterco' } }); // 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' });@@ -168,7 +199,9 @@ async function main() { 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');+ // (nobodyelse: the registry refuses to draft for this identity, so the+ // 404 stays missing; nobodyco, drafted on pose, is covered in 4b)+ g = await pose('nobodyelse/brand'); eq('a 404 is missing, not an error', g.status, 'missing'); ok(' and never borrows Adom\'s brand body', !g.brand); @@ -229,6 +262,46 @@ async function main() { ok(' logos survive the confirm', !!(written.logos && written.logos.mark)); } + // 4b. 0.21.0: a missing guide is drafted on pose, once, so the Brand step+ // is never empty; a refused draft is carried on the guide with the reason.+ console.log('starter auto-draft:');+ g = await pose('nobodyco/brand');+ eq('a missing guide is drafted and resolves as starter', g.status, 'starter');+ eq(' the starter route was called once for that owner', starterCalls.filter((o) => o === 'nobodyco').length, 1);+ ok(' the outcome rides on the guide', g.starter && g.starter.result === 'drafting', JSON.stringify(g.starter));+ g = await pose('nobodyelse/brand');+ eq('a refused draft leaves the guide missing', g.status, 'missing');+ eq(' and was tried exactly once for that owner across both poses', starterCalls.filter((o) => o === 'nobodyelse').length, 1);+ ok(' and names the refusal', g.starter && g.starter.result === 'refused' && g.starter.code === 'BRAND_EDITORS', JSON.stringify(g.starter));+ r = await post('generate', { fromGate: true });+ ok(' Generate stays locked on it', r.ok === false && /no brand guide/.test(r.error || ''), JSON.stringify(r).slice(0, 200));++ // 4c. 0.21.0: Generate on a starter the human may edit confirms it in the+ // same click; the stamp is what the registry holds AFTER the push.+ console.log('confirm inside Generate:');+ GUIDES.starterco.status = 'starter'; GUIDES.starterco.brand.status = 'starter';+ const before = pushes.length;+ await pose('starterco/brand');+ let gen21 = await post('generate', { fromGate: true, renderer: 'local' });+ ok('Generate on an editable starter renders', gen21.ok === true, JSON.stringify(gen21).slice(0, 300));+ eq(' and pushed the confirm', pushes.length, before + 1);+ let lastPush = pushes[pushes.length - 1];+ let wb = null; try { const bj = lastPush.body.files.find((f) => f.path === 'brand.json'); wb = JSON.parse(bj.content); } catch {}+ ok(' brand.json went out confirmed, by the human', wb && wb.status === 'confirmed' && wb.confirmed_by === 'starterco' && !!wb.confirmed_at, JSON.stringify(wb).slice(0, 200));+ if (gen21.ok && gen21.file) {+ const meta = await readPngComment(path.join(ROOT, 'generated', gen21.file));+ eq(' the stamp says confirmed (post-push), not starter', meta && meta.brand_status, 'confirmed');+ eq(' branding is the owner', meta && meta.branding, 'starterco');+ eq(' and the stamp is versioned 0.21.0 so the wiki judges it', meta && meta.version, '0.21.0');+ try { fs.unlinkSync(path.join(ROOT, 'generated', gen21.file)); } catch {}+ }+ await pose('lockedco/brand');+ const lockedBefore = pushes.length;+ gen21 = await post('generate', { fromGate: true });+ ok('Generate on a starter this identity cannot edit is refused', gen21.ok === false && /locked/.test(gen21.error || ''), JSON.stringify(gen21).slice(0, 200));+ ok(' with the resolver\'s own editor rule', /Only lockedco can edit/.test(JSON.stringify(gen21)));+ eq(' and nothing was pushed', pushes.length, lockedBefore);+ // 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