← Commit history

Publish 0.16.1

Colby Knox ·03a4a9f3e0 ·2mo ago ·parent 0be70ee
6 files changed +107−5
SKILL.md+16
@@ -274,6 +274,22 @@ every overlay off, Generate BYPASSES the browser entirely (provenance `renderer: passthrough`): your pixels are decoded, normalized to 2000x1250, and stamped, with zero recompression drift (hero-studio#36). +## 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+every full-import thread rediscovered it the hard way (hero-studio#32):++- Hero TITLES are Familjen Grotesk 700. Body copy (subtitle, detail) is+  Satoshi. A full import with a different title face will read as off-family+  even when nobody can say why.+- Optical left alignment: the title's INK edge aligns with the text block,+  not its bounding box; glyph side bearing shifts pixels, and the linter+  measures it (canvas ink measurement) in composed mode. Imports must check+  it themselves; John's phone caught a 13px miss the box math called clean.+- The lower ~24% is the dynamic marquee band: keep it calm.+- The family preset (`hero-studio preset bridge-family`) carries the measured+  constants (12cqw title, bare right-side vitrine, overlays off).+ ## What the human controls  - Layout (vitrine bleeds, contained, mirror), theme, gradient angle.
bridge.mjsadded+56
@@ -0,0 +1,56 @@+#!/usr/bin/env node+// Desktop-bridge adapter (hero-studio#31): the Adom Bridge v2 rename replaced+// the `adom-desktop` binary with `adom-bridge-cli` and moved pup's verbs to a+// `pup_*` prefix -- and the relay now refuses raw HTTP posts entirely. This+// module is the ONE place that knows which binary and which verb names a box+// has: it prefers adom-bridge-cli, resolves the live verb names from+// bridge_list output (manifests have claimed prefixes the box does not+// answer, so never hardcode), and falls back to legacy adom-desktop.+import { execFile } from 'child_process';++const run = (bin, args, timeout, target) => new Promise((res) => {+  const a = target ? [...args, '--target', target] : args;+  execFile(bin, a, { timeout }, (err, stdout, stderr) => res({+    ok: !err,+    out: String(stdout || ''),+    errOut: String(stderr || ''),+    fail: err ? (err.killed ? 'timed out after ' + timeout + 'ms' : 'exit ' + (err.code ?? '?')) : '',+  }));+});++const which = (bin) => new Promise((res) => {+  execFile('sh', ['-c', 'command -v ' + bin], { timeout: 4000 }, (err) => res(!err));+});++// The verbs the studio uses, by suffix; the adapter resolves each suffix to+// whatever full name the box actually answers on (pup_*, browser_*, ...).+const SUFFIXES = ['open_window', 'navigate', 'screenshot', 'eval', 'status', 'alert_window'];++export async function makeBridge(target) {+  const hasNew = await which('adom-bridge-cli');+  const bin = hasNew ? 'adom-bridge-cli' : 'adom-desktop';+  const verbs = {};+  if (hasNew) {+    // Resolve live verb names from bridge_list; a suffix seen there wins.+    const bl = await run(bin, ['bridge_list'], 15000, target);+    for (const sfx of SUFFIXES) {+      const m = (bl.out + bl.errOut).match(new RegExp('\\b([a-z][a-z0-9]*)_' + sfx + '\\b'));+      if (m) verbs[sfx] = m[0];+    }+    // pup_* is the known-good prefix on renamed boxes; fill unresolved+    // suffixes with it rather than the legacy browser_* names.+    for (const sfx of SUFFIXES) if (!verbs[sfx]) verbs[sfx] = 'pup_' + sfx;+  } else {+    for (const sfx of SUFFIXES) verbs[sfx] = 'browser_' + sfx;+  }+  return {+    bin,+    generation: hasNew ? 'adom-bridge-cli' : 'adom-desktop (legacy)',+    verbs,+    /// call('open_window', {...}) -> {ok, out, errOut, fail}; args JSON-encoded+    call: (suffix, args, timeout = 20000) =>+      run(bin, [verbs[suffix], JSON.stringify(args ?? {})], timeout, target),+    /// plain verb (no JSON args), e.g. bridge_list itself+    raw: (verb, timeout = 15000) => run(bin, [verb], timeout, target),+  };+}
package.json+2−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.16.0",+  "version": "0.16.1",   "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": [@@ -80,6 +80,7 @@     "pose.mjs",     "drive.mjs",     "doctor.mjs",+    "bridge.mjs",     "bin/hero-studio",     "public/**",     "data/**",
page.json+2−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.16.0",+  "version": "0.16.1",   "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": [@@ -81,6 +81,7 @@     "pose.mjs",     "drive.mjs",     "doctor.mjs",+    "bridge.mjs",     "bin/hero-studio",     "public/**",     "data/**",
public/index.html+1−1
@@ -1616,7 +1616,7 @@ function runLint() {   const vit2 = state.vitrine || {};   if (vit2.full && vit2.src) {     scanImportBand(vit2.src);-    items.push({ id: 'import-unlinted', level: 'warn', el: 'fullimport-img', msg: 'full-hero import: composed-layout checks cannot inspect imported pixels; verify the lower band and margins by eye before ratifying' });+    items.push({ id: 'import-unlinted', level: 'warn', el: 'fullimport-img', msg: 'full-hero import: composed-layout checks cannot inspect imported pixels; verify by eye before ratifying. Doctrine still applies (#32): title set in Familjen Grotesk 700, body copy in Satoshi, optical left alignment on the ink edge, calm lower band' });     if (bandScan.status === 'done' && bandScan.texty) {       items.push({ id: 'import-band-text', level: 'warn', el: 'fullimport-img', msg: 'the imported image appears to carry text or graphics in the lower dynamic band (heuristic edge scan): the homepage overlays its own info there, keep the band calm' });     }
server.js+30−2
@@ -55,6 +55,7 @@ function noteChange(bucket, field, from, to) {   const orig = cur ? cur.from : from;   if (j(orig) === j(to)) delete changes[bucket][field];   else changes[bucket][field] = { from: orig, to };+  saveSnapshot(); } const POSE_TRACKED = [   'mode', 'theme', 'layout', 'gradAngle', 'titleMode',@@ -113,6 +114,7 @@ let generating = false; function broadcast(event, data) {   const msg = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n';   for (const res of clients) res.write(msg);+  if (event === 'state') saveSnapshot(); }  function logLine(level, text) {@@ -138,6 +140,27 @@ function readBody(req, cb) {   }); } +// Snapshot persistence (hero-studio#32.9): a cache-refresh restart used to+// wipe the reviewer's ticked checklist, staged imports, and the change ledger.+// Every state change debounce-writes a snapshot; boot restores it when fresh.+const SNAP = path.join(__dirname, 'generated', 'state-snapshot.json');+try { fs.mkdirSync(path.join(__dirname, 'generated'), { recursive: true }); } catch {}+try {+  const snap = JSON.parse(fs.readFileSync(SNAP, 'utf8'));+  if (snap && snap.state && Date.now() - (snap.ts || 0) < 24 * 3600 * 1000) {+    merge(state, snap.state);+    if (snap.changes) changes = snap.changes;+    logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ')');+  }+} catch {}+let snapTimer = null;+function saveSnapshot() {+  clearTimeout(snapTimer);+  snapTimer = setTimeout(() => {+    try { fs.writeFileSync(SNAP, JSON.stringify({ ts: Date.now(), state, changes })); } catch {}+  }, 400);+}+ const MIME = {   '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',   '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',@@ -150,7 +173,11 @@ const server = http.createServer((req, res) => {    if (p === '/state' && req.method === 'GET') {     res.writeHead(200, { 'Content-Type': 'application/json' });-    return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS })));+    return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION })));+  }+  if (p === '/version') {+    res.writeHead(200, { 'Content-Type': 'application/json' });+    return res.end(JSON.stringify({ version: PKG_VERSION, port: PORT }));   }   if (p === '/state' && req.method === 'POST') {     return readBody(req, (err, body) => {@@ -579,7 +606,8 @@ const server = http.createServer((req, res) => {         'from PIL.PngImagePlugin import PngInfo\n' +         'f, meta = sys.argv[1], sys.argv[2]\n' +         'im = Image.open(f)\n' +-        'im = im.resize((2000, round(2000 * im.size[1] / im.size[0])), Image.LANCZOS)\n' ++        'if im.size[0] != 2000:\n' ++        '    im = im.resize((2000, round(2000 * im.size[1] / im.size[0])), Image.LANCZOS)\n' +         'info = PngInfo(); info.add_text("comment", meta)\n' +         'im.save(f, pnginfo=info)\n';       execFile('python3', ['-c', py, outFile, meta], (err) => {