← Commit history

Publish 0.20.4

Colby Knox ·3e6a1bcbd4 ·1mo ago ·parent 1e8bb3a
7 files changed +118−20
SKILL.md+14−3
@@ -262,6 +262,15 @@ off the box edge), and an informational published-headroom line (export px between the lowest text and the marquee band). Findings render in the gate and publish to `state.lint` ([{id, level: warn|fail, msg}]); the AI should fix warns without being asked.+`state.lintMeta` says whether those findings are TRUSTWORTHY (hero-studio#51):+{computedAt, poseRev, source: surface|compose, stale}. stale:true means the+pose changed after the findings were measured (or nothing measured them yet);+never act on stale findings, and never read an empty stale lint as all-clear.+`hero-studio compose` (GET /compose.png) re-measures lint SERVER-SIDE in the+same headless render, fresh by construction, with no dependency on an open+surface; the automated Generate path refuses stale lint outright. Text+geometry is measured from the RENDERED GLYPHS (Range rects), not element+boxes, so a long word overflowing its column into the showcase is caught. FULL-HERO IMPORTS are never reported all-clear: the linter discloses that composed checks cannot see imported pixels, and an edge-density scan of the lower band flags text-like structure there (heuristic, hero-studio#22).@@ -350,9 +359,11 @@ cannot be ratified.  Invalid enum values are REJECTED with the valid list (400 from /state); an ok:true means the value really landed. state.lint is computed by the OPEN-studio page, so it is empty until a browser surface has rendered the pose;-once one is open, every gate finding (layout linter, full-import caveats,-band scan) mirrors there for the driving AI to fix before review.+studio page OR by a headless compose (whichever ran last; state.lintMeta.source+says which, and .stale says whether the pose has moved since). A surface can+be open yet unresponsive: state.fitPending stays set when a /fit request got+no answer, and lintMeta.stale exposes a surface that stopped re-rendering.+When in doubt, `hero-studio compose` is the reliable measurement path.  The showcase's FAKE titlebar is auto-off whenever an imported screenshot is present: a real window shot carries its own chrome, and stacking the fake bar
drive.mjs+13−1
@@ -280,8 +280,20 @@ try {     if (!r.ok) die('ERROR: compose render failed (' + r.status + '): ' + (await r.text()).slice(0, 300));     const buf = Buffer.from(await r.arrayBuffer());     fsm.writeFileSync(outFile, buf);-    console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length }));+    // hero-studio#51: compose measures lint server-side in the same render;+    // print the fresh findings so this verb IS the reliable gate read.+    let lint = null, lintMeta = null;+    try {+      const st = await (await fetch(BASE + 'state')).json();+      lint = st.lint || [];+      lintMeta = st.lintMeta || null;+    } catch {}+    console.log(JSON.stringify({ ok: true, file: outFile, bytes: buf.length, lint, lintMeta }));     console.error('composed preview written (no provenance stamp; the pose gate + Generate remain the only publish path)');+    if (lint) {+      for (const l of lint) console.error('lint ' + l.level + ' [' + l.id + '] ' + l.msg);+      if (lintMeta && !lintMeta.stale) console.error('lint measured fresh by this compose (poseRev ' + lintMeta.poseRev + ')');+    }   } else {     die('verbs: pose | showcase | set | preset | fit | style | batch | state | toast | reroll | changes | bg | compose | ls | instances | stop');   }
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.20.3",+  "version": "0.20.4",   "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": [
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "hero-studio",   "title": "Hero Studio",   "type": "app",-  "version": "0.20.3",+  "version": "0.20.4",   "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": [
public/js/app.js+34−9
@@ -728,13 +728,43 @@ let lastLintKey = ''; function scheduleLint() { clearTimeout(lintTimer); lintTimer = setTimeout(runLint, 450); } function runLint() {   if (!state) return;+  const items = computeLint();+  if (!items) return;+  return items;+}+window.__computeLint = () => (computeLint() || []).map(({ id, level, msg }) => ({ id, level, msg }));+/* The pure measurement, shared with the server's headless compose render+   (hero-studio#51): the server evaluates window.__computeLint in the same+   page code it screenshots, so measured lint reflects the EXACT composed+   pose with no dependency on an open, responsive surface. */+function computeLint() {+  if (!state) return null;   const hero = $('hero');   const H = hero.getBoundingClientRect();-  if (H.width < 60) return;+  if (H.width < 60) return null;   const items = [];   const rel = (r) => ({ left: r.left - H.left, top: r.top - H.top, right: r.right - H.left, bottom: r.bottom - H.top, width: r.width, height: r.height });   const vis = (el) => el && el.offsetParent && el.getBoundingClientRect().width > 2;   const boxOf = (el) => rel(el.getBoundingClientRect());+  // hero-studio#51: an element box is the LAYOUT column; glyphs overflow it+  // (a long word at a big size escapes right) and box-vs-box overlap misses+  // exactly the collision a human sees. Range client rects measure the+  // rendered text lines themselves, overflow included.+  const textBoxOf = (el) => {+    try {+      const range = document.createRange();+      range.selectNodeContents(el);+      const rects = Array.from(range.getClientRects()).filter((r) => r.width > 1 && r.height > 1);+      if (!rects.length) return boxOf(el);+      const u = {+        left: Math.min.apply(null, rects.map((r) => r.left)),+        top: Math.min.apply(null, rects.map((r) => r.top)),+        right: Math.max.apply(null, rects.map((r) => r.right)),+        bottom: Math.max.apply(null, rects.map((r) => r.bottom)),+      };+      return rel({ left: u.left, top: u.top, right: u.right, bottom: u.bottom, width: u.right - u.left, height: u.bottom - u.top });+    } catch { return boxOf(el); }+  };   const overlapPx = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));    const ptrEl = $('pill-tr');@@ -743,7 +773,7 @@ function runLint() {   const showcase = (winEl && winEl.offsetParent && state.mode !== 'component') ? boxOf(winEl) : null;   const texts = [['title', 'el-title'], ['subtitle', 'el-sub'], ['detail line', 'el-desc']]     .filter(([, id]) => vis($(id)))-    .map(([name, id]) => ({ name, id, box: boxOf($(id)) }));+    .map(([name, id]) => ({ name, id, box: textBoxOf($(id)) }));    // 1. pill vs showcase / pill vs baked text (#17). Pill placement is   // contract-fixed top right, so the in-contract fix is a backdrop scrim@@ -889,14 +919,9 @@ function runLint() {     items.push({ id: 'headroom', level: 'info', msg: 'published headroom: ' + px + 'px between the lowest image-zone text and the marquee band at export scale (2000x1250)' });   } -  renderLintBox(items);-  // publish to the server (deduped) so the driving AI sees measured findings-  const key = JSON.stringify(items.map(i => i.id + i.level));-  if (key !== lastLintKey) {-    lastLintKey = key;-    send({ lint: items.map(({ id, level, msg }) => ({ id, level, msg })) });-  }+  return items; }+window.__computeLint = () => (computeLint() || []).map(({ id, level, msg }) => ({ id, level, msg })); function renderLintBox(items) {   const box = $('lintbox');   if (!state.verify) { box.innerHTML = ''; return; }
render-hero.mjs+8
@@ -20,6 +20,14 @@ try {   const hero = await page.$('#hero');   const box = await hero.boundingBox();   await page.screenshot({ path: out, clip: box });+  // Measured lint from the SAME page code the surface runs (hero-studio#51):+  // the server parses this line and stores it as fresh-by-construction.+  try {+    const lint = await page.evaluate(() => (window.__computeLint ? window.__computeLint() : null));+    if (lint) console.log('LINT_JSON ' + JSON.stringify(lint));+  } catch (e) {+    console.log('LINT_JSON_ERR ' + String(e && e.message || e));+  }   console.log('OK ' + out); } finally {   await browser.close();
server.js+47−5
@@ -150,6 +150,13 @@ let generating = false; // an ETag so pollers 304 instead of re-downloading multi-MB bodies; the // serialized payload is cached per rev and variant. let stateRev = 1;+// hero-studio#51: lint freshness. poseRev bumps on every pose-affecting+// mutation; lintMeta records which poseRev the stored lint measured, so+// state can say STALE instead of serving findings for a pose that no+// longer exists. fitPending exposes a fit request no surface answered.+let poseRev = 1;+let lintMeta = null;+let fitPending = null; const stateBodyCache = {};  // Lean variant: staged imports are multi-MB base64 data URLs inside state.@@ -220,6 +227,7 @@ try { fs.mkdirSync(path.join(INSTANCES_DIR, 'snapshots'), { recursive: true }); try {   const snap = JSON.parse(fs.readFileSync(SNAP, 'utf8'));   if (snap && snap.state && Date.now() - (snap.ts || 0) < 24 * 3600 * 1000) {+    poseRev += 1;     merge(state, snap.state);     if (snap.changes) changes = snap.changes;     logLine('info', 'restored session snapshot (' + (snap.state.page || '?') + ')');@@ -402,17 +410,21 @@ function route(req, res, url, p) {   if (p === '/state' && req.method === 'GET') {     const lean = url.searchParams.get('src') === 'ref';     const variant = lean ? 'lean' : 'full';-    const etag = '"' + stateRev + '-' + variant + '"';+    const etag = '"' + stateRev + '-' + poseRev + '-' + (lintMeta ? lintMeta.poseRev : 0) + '-' + (fitPending ? 1 : 0) + '-' + variant + '"';     if (req.headers['if-none-match'] === etag) {       res.writeHead(304, { ETag: etag });       return res.end();     }     const cached = stateBodyCache[variant];     let body;-    if (cached && cached.rev === stateRev) body = cached.body;+    if (cached && cached.rev === etag) body = cached.body;     else {-      body = JSON.stringify(Object.assign({}, lean ? leanState() : state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD, gatesDone: readGates(), seasoned: isSeasoned(), resolved: resolvedText() }));-      stateBodyCache[variant] = { rev: stateRev, body };+      body = JSON.stringify(Object.assign({}, lean ? leanState() : state, { _toast: lastToast, _gen: lastGen, enums: ENUMS, version: PKG_VERSION, aiThread: AI_THREAD, gatesDone: readGates(), seasoned: isSeasoned(), resolved: resolvedText(),+        lintMeta: lintMeta+          ? Object.assign({}, lintMeta, { stale: lintMeta.poseRev !== poseRev })+          : { stale: true, reason: 'no lint measured yet this session; GET /compose.png (hero-studio compose) measures it server-side' },+        fitPending }));+      stateBodyCache[variant] = { rev: etag, body };     }     res.writeHead(200, { 'Content-Type': 'application/json', ETag: etag, 'Cache-Control': 'no-cache' });     return res.end(body);@@ -502,6 +514,11 @@ function route(req, res, url, p) {       }       const before = snapshotPose();       merge(state, body);+      if (Object.keys(body).some((k) => k !== 'lint')) poseRev += 1;+      if ('lint' in body) {+        lintMeta = { computedAt: new Date().toISOString(), poseRev, source: 'surface' };+      }+      if (body.fontSize && typeof body.fontSize.title === 'number') fitPending = null;       if (pageChanged) {         refreshPageLive(state.page)           .then(() => { stateRev += 1; broadcast('state', state); })@@ -615,6 +632,7 @@ function route(req, res, url, p) {       if (buf.length > 12 * 1024 * 1024) return finish(200, { ok: false, error: 'image too large (12 MB cap)' });       const low = body.path.toLowerCase();       const mime = low.endsWith('.webp') ? 'image/webp' : low.match(/\.jpe?g$/) ? 'image/jpeg' : 'image/png';+      poseRev += 1;       state.showcase = { src: 'data:' + mime + ';base64,' + buf.toString('base64'), full: !!body.full, cover: !!body.cover, chrome: body.chrome || (state.showcase && state.showcase.chrome) || '' };       logLine('info', 'showcase <- ' + body.path + ' (' + Math.round(buf.length / 1024) + ' KB, cli)');       broadcast('state', state);@@ -648,6 +666,7 @@ function route(req, res, url, p) {         return res.end(JSON.stringify({ ok: false, error: 'unknown preset', valid: Object.keys(PRESETS) }));       }       const before = snapshotPose();+      poseRev += 1;       merge(state, PRESETS[name]);       notePoseDiff(before);       logLine('info', 'preset applied: ' + name);@@ -694,6 +713,8 @@ function route(req, res, url, p) {     fs.mkdirSync(path.dirname(tmp), { recursive: true });     const child = spawn(process.execPath, [path.join(__dirname, 'render-hero.mjs'), 'http://localhost:' + PORT + '/?render=1', tmp], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });     let eb = '';+    let ob = '';+    child.stdout.on('data', (c) => { ob += c; });     child.stderr.on('data', (c) => { eb += c; });     const t = setTimeout(() => child.kill(), 60000);     child.on('close', (code) => {@@ -702,6 +723,18 @@ function route(req, res, url, p) {         res.writeHead(500, { 'Content-Type': 'application/json' });         return res.end(JSON.stringify({ ok: false, error: eb.trim().split('\n').pop() || 'compose render failed' }));       }+      // hero-studio#51: the headless render evaluated the SAME linter the+      // surface runs; store its findings as fresh-by-construction, so the+      // measured gate no longer depends on an open, responsive surface.+      const lm = ob.split('\n').find((l) => l.startsWith('LINT_JSON '));+      if (lm) {+        try {+          state.lint = JSON.parse(lm.slice('LINT_JSON '.length));+          lintMeta = { computedAt: new Date().toISOString(), poseRev, source: 'compose' };+          logLine('info', 'compose measured lint: ' + state.lint.length + ' finding(s) at poseRev ' + poseRev);+          broadcast('state', state);+        } catch {}+      }       res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });       res.end(fs.readFileSync(tmp));     });@@ -713,10 +746,12 @@ function route(req, res, url, p) {       return res.end(JSON.stringify({ ok: false, error: 'no open studio surface to measure in', hint: 'fit solves with real text metrics, so it needs the studio page open (pose opens one); the result lands in state.fontSize.title within a second' }));     }     broadcast('fit', { max: (req._body && req._body.max) || null });+    fitPending = { requestedAt: new Date().toISOString(), poseRev };     res.writeHead(200, { 'Content-Type': 'application/json' });-    return res.end(JSON.stringify({ ok: true, hint: 'solving in the open surface; read state.fontSize.title (and the fit toast) in about a second' }));+    return res.end(JSON.stringify({ ok: true, hint: 'solving in the open surface; read state.fontSize.title (and the fit toast) in about a second. state.fitPending stays set until a surface answers: if it is still there after a few seconds, no surface is actually responding' }));   }   if (p === '/reroll' && req.method === 'POST') {+    poseRev += 1;     state.circuitSeed = (state.circuitSeed * 48271 + Date.now()) % 2147483647;     broadcast('state', state);     res.writeHead(200, { 'Content-Type': 'application/json' });@@ -834,6 +869,13 @@ function route(req, res, url, p) {       return refuse('hard-fail layout findings lock Generate',         'fix the failing items in state.lint first; they lock the human gate and automated mode alike');     }+    // hero-studio#51: the AUTOMATED path must not trust findings measured+    // against a pose that no longer exists. The human gate is exempt: human+    // eyes are at that gate by definition.+    if (!fromGate && (!lintMeta || lintMeta.poseRev !== poseRev)) {+      return refuse('state.lint is stale: the pose changed after it was measured (or it was never measured)',+        'GET /compose.png (hero-studio compose) re-measures lint server-side against the exact current pose, then retry');+    }     if (!fromGate) {       // Automated mode (#45, decided by Colby 2026-08-13): SAME-SESSION       // REPEATS ONLY. The first Generate of a page is always human-clicked;