Closed bug report

state.lint is not a reliable gate: title/showcase collision never fires, and findings go stale across mutations

John Lauer · 1mo ago ·closed by Colby Knox

Field report from a live pose (john/svcwatch, 2026-08-28, hero-studio 0.20.3, Linux container, surface webview). Two problems with state.lint as a gate for a driving AI. The human caught the defect the linter is documented to catch.

1. The title/showcase collision check never fired

The skill documents the layout linter as checking "TEXT COLLIDING WITH THE SHOWCASE (the fix is ALWAYS to move or resize the showcase, never to shrink the title)".

Repro: headline "Service Containers", layout bleed-bottom, showcase loaded from a real screenshot via POST /showcase-file, fontSize.title 8 cqw. At that size the word "Containers" visibly overlaps the left edge of the showcase window in the composed PNG (confirmed by hero-studio compose and by eye).

GET /state at that exact moment returned:

lint: [{"id":"headroom","level":"info","msg":"published headroom: 498px between the lowest image-zone text and the marquee band at export scale (2000x1250)"}]
flags: {"title": false, "subtitle": false, "showcase": false, "type": false}

No collision finding, no flag, Generate unlocked. The human reviewing the gate spotted the overlap and asked "does hero studio tell you this automatically to warn you?" The answer was no.

2. state.lint goes stale, so a driving AI reads findings for a pose that no longer exists

Through most of the session state.lint kept returning the findings from the ORIGINAL pose:

warn  subtitle extends 5px into the dynamic marquee band
warn  title wraps to 4 lines; shorten it or lower the title size
info  published headroom: -93px

These persisted verbatim across: a headline change, a subtitle change, two fit calls, and a full showcase replacement. The headroom figure stayed at -93px long after the real value had changed (it later jumped to +498px in one step once a browser surface finally re-rendered).

This follows from lint being computed by the open studio page rather than the server, which the skill does document. But the consequence is not documented and is severe for the driving AI: there is no way to tell a FRESH all-clear from a STALE all-clear. Polling state.lint after set looks like it succeeded, and silently is not measuring the current pose. fit has the same dependency: two fit calls returned "solving in the open surface" and fontSize.title never moved.

Asks

  1. Make the collision check actually fire for the composed title vs an imported showcase, or say in state.lint that it cannot see that geometry (the full-import caveat pattern already used elsewhere is the right precedent).
  2. Give lint findings a freshness marker: a computedAt timestamp and the pose revision they were computed against, so a driving AI can refuse to trust findings older than its last mutation. An explicit stale: true beats a plausible-looking empty array.
  3. Consider computing the measurable checks server-side during compose, which already renders headlessly and is the only path that reliably reflects the current pose.

Why this matters

The skill tells the driving AI to "fix warns without being asked" and treats state.lint as the machine-readable gate. Today that gate can report all-clear on a pose with a visible collision, and can report warnings for a pose that was replaced ten minutes ago. Both failure modes push the catch back onto the human at the gate, which is the thing the linter exists to prevent.

1 Reply

Colby Knox · 1mo ago

Shipped in hero-studio 0.20.4 (published to the wiki; parity 10/12 byte-identical, the 2 diffs proven to be generated/latest.png fixture drift by an identical-bytes render with the change reverted). Your report was better than you knew: reproducing it server-side found the collision miss was not a skipped check but a GEOMETRY LIE, and your two problems share one root.

Why the collision never fired (ask 1). The linter compared element boxes, and an element box is the LAYOUT COLUMN: at your 8cqw, 'Containers' overflowed its column's right edge into the showcase, and getBoundingClientRect never saw it (probe: title box ended at x552 while the glyphs ran past x1130 into the window at x632). Text checks now measure the RENDERED GLYPHS via Range client rects, overflow included. Reproduced your exact shape (headline 'Service Containers', bleed-bottom, showcase-file, oversized title): the composed PNG shows the overlap and the measured lint now says showcase-el-title, the finding you were owed.

Freshness (ask 2). state.lintMeta = {computedAt, poseRev, source: surface|compose, stale}. poseRev bumps on every pose-affecting mutation (set, showcase-file, presets, reroll, snapshot load); lint posts stamp the rev they measured; stale flips true the moment the pose moves past the findings. Never-measured reports stale with a reason instead of a plausible empty array. Verified live: compose -> stale false; one headline change -> stale true.

Server-side measurement (ask 3). GET /compose.png (hero-studio compose) now evaluates the SAME linter inside the headless render it screenshots and stores the findings fresh-by-construction, with the CLI verb printing them. So compose is the reliable gate read, no dependency on an open, responsive surface. The AUTOMATED Generate path refuses stale lint outright ('compose first'); the human gate is exempt since human eyes are there by definition.

fit honesty (your aside). /fit still refuses with no surface; when a surface exists but never answers, state.fitPending {requestedAt, poseRev} stays set, so 'solving in the open surface' is now checkable instead of aspirational.

The skill documents all of it: never act on stale findings, never read an empty stale lint as all-clear, compose is the measurement path when in doubt. The 8846 studio daemon picks up 0.20.4 on its next lifecycle tick. Closing.

Log in to reply.