← Commit history
SKILL.md+21
@@ -112,6 +112,27 @@ and auto-pulls the new package's real screenshot, so per sibling only the vitrine content and the baked slug change. Pose sibling A, tune, Generate; pose sibling B, confirm, Generate. +## Page fields, Apply, and the change ledger++The human at the gate can edit the posed page's fields (title, brief, tags)+directly in the panel; edits preview live in the hero and marquee contexts.+Everything the session changes, page fields AND pose deltas, accumulates in a+server-side change ledger with first-seen originals:++```bash+hero-studio changes [--port N]    # the ledger: {ref, page:{field:{from,to}}, pose:{...}, applied}+```++`pose` resets the ledger per session and its final JSON includes `changes`, so+AFTER A POSE RETURNS, READ `.changes` AND SYNC THE LOCAL PACKAGE (page.json /+package.json / README): the human's gate-time edits otherwise silently revert+on your next publish. The human's "Apply changes to wiki" writes field edits+through the authenticated path when the wiki supports it, and otherwise+STASHES them (generated/page-edits-<slug>.json) for the next publish; "Copy+Changelog Prompt" puts a paste-ready summary on their clipboard for the AI+session that owns the working copy. The PNG provenance comment also records+the session's changes.+ ## Needs-fixing verdicts and the layout linter  Every tour step and checklist item has a "Needs fixing" verdict, not just
bin/hero-studio+1−1
@@ -7,6 +7,6 @@ DIR="$(cd "$(dirname "$SELF")/.." && pwd)" case "$1" in   pose) shift; exec node "$DIR/pose.mjs" "$@" ;;   doctor) shift; exec node "$DIR/doctor.mjs" "$@" ;;-  vitrine|set|state|toast|reroll) exec node "$DIR/drive.mjs" "$@" ;;+  vitrine|set|state|toast|reroll|changes) exec node "$DIR/drive.mjs" "$@" ;;   *) exec node "$DIR/server.js" "$@" ;; esac
data/pages.json+1−1
@@ -229,7 +229,7 @@   "score": 539,   "stars": 0,   "installs30d": 82,-  "brief": "adom-lbr , the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON , symbol + footp",+  "brief": "adom-lbr , the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON , symbol + footp",   "tags": [    "eda",    "altium",
drive.mjs+2
@@ -64,6 +64,8 @@ try {     const out = await jf('state', patch);     console.log(JSON.stringify({ ok: true, applied: patch }));     void out;+  } else if (verb === 'changes') {+    console.log(JSON.stringify(await jf('changes'), null, 2));   } else if (verb === 'state') {     console.log(JSON.stringify(await jf('state'), null, 2));   } else if (verb === 'toast') {
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.12.1",+  "version": "0.13.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.12.1",+  "version": "0.13.0",   "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Adom-org pages get the house wordmark and palette automatically; every other owner brings their own accent and background colors, with placement fixed for all. The floating app window is the vitrine: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, vitrine, type match) and a Generate that unlocks only fully confirmed. Includes vitrine bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.",   "license": "MIT",   "tags": [
pose.mjs+19−2
@@ -147,7 +147,17 @@ await jf('state', {   }), }); -// 2b. best-effort: pre-load a real screenshot from the package's wiki page+// 2b. fresh change ledger for THIS review session (#20): everything the human+// edits at the gate (page fields + pose deltas) accumulates from here and+// rides back in this command's final JSON as `changes`.+try {+  await jf('changes/reset', {+    method: 'POST', headers: { 'Content-Type': 'application/json' },+    body: JSON.stringify({ ref }),+  });+} catch {}++// 2c. best-effort: pre-load a real screenshot from the package's wiki page // into the vitrine, so the mock only shows when the page has no images try { await jf('vitrine-from-page?page=' + ref, { method: 'POST' }); } catch {} @@ -253,7 +263,14 @@ while (Date.now() < deadline) {   const g = st._gen;   if (g && g.ok && g.seq > baseline) {     const abs = path.join(DIR, 'generated', g.file);-    console.log(JSON.stringify({ ok: true, ref, port, file: g.file, path: fs.existsSync(abs) ? abs : path.join(DIR, g.url), latest: path.join(DIR, 'generated', 'latest.png') }));+    let sessionChanges = null;+    try { sessionChanges = await jf('changes'); } catch {}+    console.log(JSON.stringify({ ok: true, ref, port, file: g.file, path: fs.existsSync(abs) ? abs : path.join(DIR, g.url), latest: path.join(DIR, 'generated', 'latest.png'), changes: sessionChanges }));+    if (sessionChanges && (Object.keys(sessionChanges.page || {}).length || Object.keys(sessionChanges.pose || {}).length)) {+      console.error('NOTE: the human made changes at the gate (see .changes in the JSON above).');+      console.error('Sync the local package (page.json / package.json / README) to match, or the');+      console.error('next publish silently reverts what the human decided.' + (sessionChanges.applied === 'wiki' ? ' (Already applied to the wiki; the LOCAL checkout still needs it.)' : ''));+    }     process.exit(0);   } }
public/index.html+87−1
@@ -466,6 +466,9 @@ body[data-mode=component] .mode-app { display:none; } #tourpop .tp-flag { background:rgba(248,81,73,.12); border:1px solid rgba(248,81,73,.6); color:#f85149; } #tourpop .tp-head { cursor:grab; user-select:none; } #tourpop.dragging .tp-head { cursor:grabbing; }+/* page-field editors (#20) */+.pf-in { width:100%; box-sizing:border-box; background:#0d1117; color:var(--text); border:1px solid #30363d; border-radius:6px; padding:6px 8px; font:inherit; font-size:12.5px; margin:2px 0 8px; }+.pf-in:focus { border-color:var(--accent); outline:none; } /* zen focus (#12): the hero gets the whole surface */ body.zen #panel { display:none; } body.zen main { max-width:none; }@@ -639,6 +642,18 @@ body[data-render] #hero { width:1600px; max-width:none; aspect-ratio:auto; heigh     <label class="chk"><input type="checkbox" id="chk-mqpreview"> Show the homepage-marquee overlay over the hero (judge composition and title redundancy; never exported)</label>     <button class="btn" id="vitrine-pull">Pull real screenshot from the page</button>     <button class="btn" id="vitrine-clear">Clear import</button>++    <h3>Page fields</h3>+    <div class="hint">The human at the gate is best placed to catch page-level issues (hero-studio#20). Edits preview live; Apply writes them to the wiki (or stashes them for the first publish), and Copy Changelog Prompt hands the full session's changes back to the AI that owns the package working copy.</div>+    <div class="hint">Title</div>+    <input type="text" id="pf-title" class="pf-in">+    <div class="hint">Brief (the subtitle bakes from its first sentence)</div>+    <textarea id="pf-brief" class="pf-in" rows="3"></textarea>+    <div class="hint">Tags (comma separated)</div>+    <input type="text" id="pf-tags" class="pf-in">+    <button class="btn" id="pf-apply">Apply changes to wiki</button>+    <button class="btn" id="pf-prompt">Copy Changelog Prompt</button>+    <div class="hint" id="pf-status"></div>     <div class="hint">Import a real screenshot into the vitrine instead of the CSS mock (apps show their real UI). Full-hero mode is the re-stamp path: bring in an existing hero image, ratify it here as the human, and the export carries the studio provenance stamp. The type pill and slug still bake on top.</div>     </div> @@ -981,9 +996,10 @@ function render() {   }   $('verifybar').style.display = state.verify && !approvedHere ? 'block' : 'none'; -  // walkthrough + procedural background + measured layout lint+  // walkthrough + procedural background + measured layout lint + page fields   showTourStep();   scheduleLint();+  renderPageFields();   if (state.theme === 'circuitry') requestAnimationFrame(drawCircuitry);   $('btn-reroll').style.display = state.theme === 'circuitry' ? '' : 'none'; @@ -1586,6 +1602,76 @@ $('tp-skip').addEventListener('click', () => { clearTourGlow(); $('tourpop').sty   document.addEventListener('pointercancel', () => hero.classList.remove('mq-hold')); })(); +/* ---- page fields + Apply + Copy Changelog Prompt (#20) ----+   The human edits page-level fields at the gate; the change ledger on the+   server records everything (page fields AND pose deltas), Apply writes to+   the wiki or stashes for the first publish, and the prompt hands the whole+   session back to the AI that owns the package working copy. */+let pfEditing = false;+function renderPageFields() {+  if (pfEditing || !state) return;+  const d = pageByKey(state.page);+  if (!d) return;+  $('pf-title').value = d.title || '';+  $('pf-brief').value = d.brief || '';+  $('pf-tags').value = (d.tags || []).join(', ');+}+async function refreshChangesBadge() {+  try {+    const c = await (await fetch('changes')).json();+    const n = Object.keys(c.page || {}).length;+    $('pf-apply').textContent = 'Apply changes to wiki' + (n && !c.applied ? ' (' + n + ' unapplied)' : '');+    $('pf-apply').disabled = !n || !!c.applied;+    if (c.applied) $('pf-status').textContent = c.applied === 'wiki'+      ? 'Applied to the wiki. Still run Copy Changelog Prompt: the local package checkout needs syncing too.'+      : 'Stashed for the next publish (the page or edit route is not on the wiki yet); the changelog prompt carries the edits.';+  } catch {}+}+function pushPageField() {+  const [owner, slug] = (state.page || '/').split('/');+  const body = { owner, slug, title: $('pf-title').value.trim(), brief: $('pf-brief').value.trim(),+    tags: $('pf-tags').value.split(',').map(t => t.trim()).filter(Boolean) };+  fetch('api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })+    .then(() => fetch('api/pages')).then(r => r.json()).then(rows => { pages = rows; render(); refreshChangesBadge(); })+    .catch(() => {});+}+for (const id of ['pf-title', 'pf-brief', 'pf-tags']) {+  $(id).addEventListener('focus', () => { pfEditing = true; });+  $(id).addEventListener('blur', () => { pfEditing = false; pushPageField(); });+}+$('pf-apply').addEventListener('click', async () => {+  const r = await (await fetch('apply-page', { method: 'POST' })).json().catch(() => ({}));+  if (!r.ok) { handleToast({ text: r.error || 'nothing to apply', kind: 'warn', seq: Date.now() }); return; }+  handleToast({+    text: r.applied === 'wiki' ? 'Page edits applied to the wiki (' + r.fields.join(', ') + ')'+      : 'Edits stashed for the next publish' + (r.http ? ' (wiki said HTTP ' + r.http + ')' : ''),+    kind: r.applied === 'wiki' ? 'success' : 'info', seq: Date.now(),+  });+  refreshChangesBadge();+});+$('pf-prompt').addEventListener('click', async () => {+  const c = await (await fetch('changes')).json().catch(() => null);+  if (!c) return;+  const lines = ['While reviewing the hero in Hero Studio I made these changes to ' + (c.ref || state.page) + ':'];+  const fmt = (v) => typeof v === 'string' ? '"' + v + '"' : JSON.stringify(v);+  for (const [f, ch] of Object.entries(c.page || {})) lines.push('- ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));+  for (const [f, ch] of Object.entries(c.pose || {})) lines.push('- pose ' + f + ': ' + fmt(ch.from) + ' -> ' + fmt(ch.to));+  if (lines.length === 1) lines.push('- (no changes recorded this session)');+  lines.push('- Applied to wiki: ' + (c.applied === 'wiki' ? 'yes' : c.applied === 'stashed' ? 'no, stashed: please include in the next publish' : 'no: please include in the next publish'));+  lines.push('Please update the local package (page.json / package.json / README) to match.');+  const text = lines.join('\n');+  try {+    await navigator.clipboard.writeText(text);+    handleToast({ text: 'Changelog prompt copied: paste it into the AI session that owns the package', kind: 'success', seq: Date.now() });+  } catch {+    const ta = document.createElement('textarea');+    ta.value = text; document.body.appendChild(ta); ta.select();+    document.execCommand('copy'); ta.remove();+    handleToast({ text: 'Changelog prompt copied (fallback path)', kind: 'success', seq: Date.now() });+  }+});+setInterval(refreshChangesBadge, 3000);+ /* zen focus (#12): the hero gets the whole surface */ $('btn-zen').addEventListener('click', () => {   const on = document.body.classList.toggle('zen');
server.js+103−2
@@ -41,6 +41,40 @@ let state = {   guides: true, }; +// Change ledger (hero-studio#20): everything THIS review session changed, page+// fields and pose state, with first-seen originals (slider spam coalesces to+// one entry; a change reverted to its original drops out). The human's Copy+// Changelog Prompt and the agent's GET /changes both read from here, so+// studio-side edits can never silently diverge from the package working copy.+let changes = { ref: state.page, page: {}, pose: {}, applied: null };+function noteChange(bucket, field, from, to) {+  const j = (v) => JSON.stringify(v);+  const cur = changes[bucket][field];+  const orig = cur ? cur.from : from;+  if (j(orig) === j(to)) delete changes[bucket][field];+  else changes[bucket][field] = { from: orig, to };+}+const POSE_TRACKED = [+  'mode', 'theme', 'layout', 'gradAngle', 'titleMode',+  'show.logo', 'show.title', 'show.subtitle', 'show.description',+  'overlay.pill', 'overlay.scores', 'overlay.author', 'overlay.slug',+  'brand.accent', 'brand.bg', 'fontSize.title', 'fontSize.sub', 'fontSize.desc',+  'vitrine.chrome', 'vitrine.full', 'vitrine.cover',+];+const getPath = (o, p) => p.split('.').reduce((a, k) => (a == null ? a : a[k]), o);+function snapshotPose() {+  const s = {};+  for (const p of POSE_TRACKED) s[p] = getPath(state, p);+  s['vitrine.src'] = (state.vitrine && state.vitrine.src) ? Math.round(state.vitrine.src.length / 1024) + 'kB screenshot' : '(mock)';+  return s;+}+function notePoseDiff(before) {+  const after = snapshotPose();+  for (const p of Object.keys(after)) {+    if (JSON.stringify(before[p]) !== JSON.stringify(after[p])) noteChange('pose', p, before[p], after[p]);+  }+}+ const consoleBuf = []; const clients = new Set(); // seq counters seed from the clock so a server restart never replays or masks events@@ -95,13 +129,71 @@ const server = http.createServer((req, res) => {   if (p === '/state' && req.method === 'POST') {     return readBody(req, (err, body) => {       if (err) { res.writeHead(200); return res.end(JSON.stringify({ ok: false, error: 'bad json' })); }+      const before = snapshotPose();       merge(state, body);-      logLine('info', 'state <- ' + JSON.stringify(body));+      notePoseDiff(before);+      logLine('info', 'state <- ' + JSON.stringify(body).slice(0, 500));       broadcast('state', state);       res.writeHead(200, { 'Content-Type': 'application/json' });       res.end(JSON.stringify({ ok: true, state }));     });   }+  if (p === '/changes' && req.method === 'GET') {+    const dirty = Object.keys(changes.page).length;+    res.writeHead(200, { 'Content-Type': 'application/json' });+    return res.end(JSON.stringify(Object.assign({}, changes, { dirty: changes.applied ? 0 : dirty })));+  }+  if (p === '/changes/reset' && req.method === 'POST') {+    return readBody(req, (err, body) => {+      changes = { ref: (body && body.ref) || state.page, page: {}, pose: {}, applied: null };+      logLine('info', 'changes ledger reset for ' + changes.ref);+      res.writeHead(200, { 'Content-Type': 'application/json' });+      res.end(JSON.stringify({ ok: true }));+    });+  }+  if (p === '/apply-page' && req.method === 'POST') {+    // Apply the accumulated PAGE-FIELD edits to the wiki (hero-studio#20).+    // Tries the authenticated metadata route; when the page does not exist yet+    // (first-publish pose) or the route is absent, the edits are STASHED next+    // to the generated hero so the agent's next publish carries them, and the+    // changelog prompt says so honestly.+    return (async () => {+      const finish = (payload) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); };+      const fields = Object.keys(changes.page);+      if (!fields.length) return finish({ ok: false, error: 'no page-field edits to apply' });+      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 {}+      const body = { changelog: 'Edited in Hero Studio: ' + fields.join(', ') };+      for (const f of fields) body[f] = changes.page[f].to;+      const stash = () => {+        const slug = (changes.ref || state.page).split('/')[1] || 'page';+        const file = path.join(__dirname, 'generated', 'page-edits-' + slug + '.json');+        fs.mkdirSync(path.join(__dirname, 'generated'), { recursive: true });+        fs.writeFileSync(file, JSON.stringify({ ref: changes.ref, edits: changes.page, ts: Date.now() }, null, 2));+        return file;+      };+      try {+        const r = await fetch(REG + '/api/pages/' + (changes.ref || state.page), { method: 'PATCH', headers, body: JSON.stringify(body) });+        const rb = await r.json().catch(() => ({}));+        if (r.ok) {+          changes.applied = 'wiki';+          logLine('info', 'page edits applied to wiki: ' + fields.join(', '));+          broadcast('state', state);+          return finish({ ok: true, applied: 'wiki', fields });+        }+        const file = stash();+        changes.applied = 'stashed';+        logLine('warn', 'wiki did not accept the page edit (HTTP ' + r.status + '); stashed to ' + file);+        broadcast('state', state);+        return finish({ ok: true, applied: 'stashed', file, http: r.status, server: rb.error || rb.hint || '' });+      } catch (e) {+        const file = stash();+        changes.applied = 'stashed';+        return finish({ ok: true, applied: 'stashed', file, error: e.message });+      }+    })();+  }   if (p === '/events') {     res.writeHead(200, {       'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive',@@ -207,6 +299,15 @@ const server = http.createServer((req, res) => {       const i = PAGES.findIndex(x => x.owner === body.owner && x.slug === body.slug);       // empty/null body fields never clobber what the entry already has       const clean = Object.fromEntries(Object.entries(body).filter(([, v]) => v !== '' && v != null && !(Array.isArray(v) && v.length === 0)));+      // page-field edits on the POSED page land in the change ledger (#20)+      const ref = body.owner + '/' + body.slug;+      if (i >= 0 && ref === state.page) {+        for (const f of ['title', 'brief', 'tags', 'type', 'types']) {+          if (f in clean && JSON.stringify(clean[f]) !== JSON.stringify(PAGES[i][f])) {+            noteChange('page', f, PAGES[i][f], clean[f]);+          }+        }+      }       const entry = Object.assign(         { type: 'app', title: body.slug, author: '', score: 0, stars: 0, installs30d: 0, brief: '', tags: [], updated_at: new Date().toISOString() },         i >= 0 ? PAGES[i] : {}, clean,@@ -311,7 +412,7 @@ const server = http.createServer((req, res) => {         return finish({ ok: false, error: 'could not read the rendered PNG: ' + e.message, hint: "run 'hero-studio doctor'" });       }       // downscale to the hero standard 2000x1250 and stamp provenance metadata-      const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined } });+      const meta = JSON.stringify({ generator: 'hero-studio', version: PKG_VERSION, ts, renderer: renderedVia, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle, brand: state.brand, fontSize: state.fontSize, verify: state.verify, confirm: state.confirm, vitrine_used: !!(state.vitrine && state.vitrine.src), vitrine_full: !!(state.vitrine && state.vitrine.full), circuitSeed: state.theme === 'circuitry' ? state.circuitSeed : undefined }, changes: (Object.keys(changes.page).length || Object.keys(changes.pose).length) ? { page: changes.page, pose: changes.pose, applied: changes.applied } : undefined });       const py = 'import sys\n' +         'from PIL import Image\n' +         'from PIL.PngImagePlugin import PngInfo\n' +