界面已本地化;技能介绍与说明文档按所选语言由 AI 自动翻译,代码、命令、路径和专业术语保留原文,以原作者文档为准。
探索技能
设计与创意 · SKILL.md

visual-arbiter

visual-arbiter

【设计与创意】Use when bob needs a mechanical verdict on whether a built UI product satisfies a frozen design-skeleton as part of the UI-INTEGRATED → UI-VERIFIED transition. Invoked as a subprocess (not a Claude Code Agent). Renders the built product with puppeteer at each required breakpoint, measures bbox / computed styles / interaction wiring against the skelet…

独立 GitHub 快照 · 自动分类 · 以原作者文档为准

技能说明

GitHub

name: visual-arbiter description: "Use when bob needs a mechanical verdict on whether a built UI product satisfies a frozen design-skeleton as part of the UI-INTEGRATED → UI-VERIFIED transition. Invoked as a subprocess (not a Claude Code Agent). Renders the built product with puppeteer at each required breakpoint, measures bbox / computed styles / interaction wiring against the skeleton, and emits ONE JSON visual-verdict.v1 object on stdout. Pure-Python decision path — NO LLM subprocess (CB4: bob is sole writer of .design-ledger/visual-verdicts/)." family: visual disambiguation: Mechanical built-UI-vs-frozen-skeleton verdict for the UI-VERIFIED transition. Evidence-bundle scoring is verification-arbiter; classifying a reject as drift is design-drift-arbiter.

visual-arbiter

Mechanical visual verifier invoked by bob at the UI-INTEGRATED → UI-VERIFIED gate (ecosystem-keystone §2.6, §2.9, §5.5, §5.6). Mirrors the CLI shape of verification-arbiter (S027) exactly, but the decision path is pure Python — puppeteer is used only for DOM measurement, never for judgment.

When to invoke

From bob at the UI-INTEGRATED → UI-VERIFIED transition (ecosystem-keystone HARD-RULE 6). Runs in sequence: visual-arbiter first; if it rejects with micro-drift only, bob may then invoke design-drift-arbiter for auto-approval. Either arm rejecting blocks UI-VERIFIED.

Prerequisites:

  • Frozen design-skeleton.v1 (signed index.yaml + per-screen yamls in .design-ledger/skeletons/)
  • Built product reachable via file:// or http(s):// URL
  • claims.open_visual_verification_request(...) must have opened a request for the produced tuple BEFORE invoking the arbiter

Invocation

Subprocess, not an Agent. Bob spawns it as:

python3 ~/.claude/skills/_meta/visual_arbiter_spawn.py \
  <skeleton_path> <skeleton_hash> <request_id> <attempt_id> \
  <prior_state_version> <built_product_url> <product_hash> \
  <inventory_hash> <runner_version> <rubric_version>

All 10 arguments are positional and required. Bob captures stdout; the arbiter writes nothing to disk.

Input tuple (8 fields echoed back verbatim — §2.9)

FieldFormatPurpose
request_id32-hexCausal linkage to the open visual-verification request
attempt_idnon-empty stringDistinguishes retries against the same component
prior_state_versionnon-empty stringPins verdict to ledger state at request-open time
skeleton_hash64-hex (sha256)Content hash of the frozen skeleton bundle
product_hash64-hex (sha256)Built product two-layer hash (source dir + rendered HTML)
inventory_hash64-hex (sha256)env-adoption inventory at verification time
runner_versionnon-empty stringtrusted_runner version that produced the bundle
rubric_versionnon-empty stringArbiter's evaluation rubric version (see below)

Tuple mismatch on any field → bob's claims.consume_visual_verdict returns rejected_tuple_mismatch and discards the verdict.

Output (stdout only)

One JSON object matching visual-verdict.v1 (design §2.9). Top-level verdict:

  • pass — every element at every required breakpoint verified; no fail-status element verdicts; no blocker concerns
  • warn — every element that WAS measured verified, but ≥1 warning-severity concern (e.g. fonts.ready >2s, or a partial measurement — see measurement.outcome)
  • reject — ≥1 element_verdict has status: fail (missing_from_dom, bbox_drift beyond tolerance, token_mismatch, or dead_handler)
  • AUDIT_UNAVAILABLE — reserved for the spawn script on environmental failure (chrome crash, schema violation). Bob MUST escalate to user; NEVER auto-approve.

measurement block (v1.1.0). Every verdict now carries measurement: {outcome, breakpoints_expected, breakpoints_measured, errors[]}, with outcome one of MEASURED / PARTIAL / INCONCLUSIVE. Read it before trusting coverage: coverage counts DECLARED elements, measurement states how much of the page was actually reachable. A pass over a PARTIAL measurement is the one claim this block exists to prevent, and the arbiter will not emit it.

Element verdict fields (§2.6):

  • missing_from_dom — selector resolves to nothing at the given breakpoint
  • bbox_drift_px: {x, y, w, h} — per-dim signed drift; compared against tolerance_for(element) = min(must_satisfy.tolerance_px, spacing.unit_px / 2)
  • token_mismatch: {field, computed, expected} — hardcoded hex/rgb found in inline style (or computed value fails var(--...) indirection)
  • dead_handler: [{event, binds_to, reason}] — interaction declared but no listener fires (and uri.resolve confirms target unreachable if binds_to is set)
  • interactions_ok: [{event, binds_to, status}] — per-interaction success

Overall:

  • pass iff every element verdict is pass AND no blocker concerns AND measurement.outcome == MEASURED
  • reject iff any element verdict is fail — a real failure outranks an incomplete measurement
  • warn iff all pass but warnings exist, including a partial_measurement concern

Exit codes

CodeMeaning
0Valid schema-compliant verdict emitted on stdout; all 8 tuple fields echoed correctly
3Environmental / usage error (wrong argv count, hash not 64-hex, unreadable skeleton)
4AUDIT_UNAVAILABLE — measurement subprocess crashed, timed out, chrome unreachable, or produced unparseable output

Design §2.6 / §5.6: arbiter never writes .design-ledger/visual-verdicts/. Bob is the sole ledger writer (CB4 preserved).

Decision rubric (v1.1.0 — pure-Python, no LLM)

Source of truth: this SKILL.md plus visual_arbiter_spawn.py, both hashed. Bump the rubric version (semver) when changes affect verdict semantics.

v1.1.0 (S074, #218) — partial measurement can no longer read as clean. visual_arbiter_measure.mjs now emits outcome / breakpoints_expected / breakpoints_measured; the arbiter carries them into verdict.measurement and, when the outcome is not MEASURED, adds a partial_measurement warning concern that downgrades pass to warn. Previously the degradation was recorded only as telemetry, so a verdict built from 1 of 4 breakpoints was byte-identical to one built on all 4. The measure script's exit code is deliberately unchanged — visual_arbiter_spawn.py maps any non-zero to AUDIT_UNAVAILABLE, which would flatten "chrome crashed" and "3 of 4 breakpoints measured" into one outcome. The payload is the right lever; the exit code is not.

v1.0.0 rules:

  1. Per-element at every required breakpoint. For each element in each screen's elements[], iterate must_satisfy.required_breakpoints. If the element declares null at a breakpoint, it is correctly hidden there — no verification needed. Otherwise:

  2. bbox check. Measured getBoundingClientRect() compared to declared bbox.<breakpoint>. Per-dim drift = measured - declared. Element passes bbox iff every abs(drift[dim]) <= tolerance_px where tolerance_px = min(must_satisfy.tolerance_px, spacing.unit_px // 2) — taken directly from the skeleton's own declarations; NEVER a hardcoded constant.

  3. Token back-resolution. For each tokens_used mapping, the element's computed styles must go through a var(--<token-name>) reference (e.g. var(--accent-sun) for $color.accent.sun) OR at minimum NOT contain a hardcoded hex/rgb in its inline style attribute. Hardcoded hex in inline style → token_mismatch fail with {field, expected, computed}.

  4. Interaction wiring. For each declared interactions[].event:

    • visual_only: true — no binding required; pass automatic
    • binds_to == null and NOT visual_only → schema violation → dead_handler
    • binds_to set → arbiter fires the event via dispatchEvent, then checks (a) inline on* attribute, (b) data-arbiter-wired="true" opt-in flag, OR (c) an el.__arbiter_handler_ran flag the handler itself set. None of those → dead_handler fail
    • If uri module available: binds_to URI resolved via uri.resolve(binds_to, project_root); UriError → dead_handler with reason binds_to unresolvable
  5. Font-load concern. fonts.ready + 300ms wait is mandatory. Wait >2000ms → warning concern (does not block pass but downgrades to warn). Also emits claude_observe('external_tool_slow', ...) fail-open.

  6. Chrome crash handling. Measurement subprocess non-zero exit or timeout → emit external_tool_fail / external_tool_slow observation (fail-open) and exit with code 4 + AUDIT_UNAVAILABLE.

  7. Tuple echo discipline. All 8 tuple fields echoed verbatim — bob's claims.consume_visual_verdict rejects on any mismatch.

Env vars

VariableDefaultPurpose
VISUAL_ARBITER_CHROME_PATH/bin/google-chromeChrome/Chromium binary (must match what puppeteer-core can drive)
VISUAL_ARBITER_NODE_BINnodeNode binary for the measurement subprocess
VISUAL_ARBITER_TIMEOUT_S180Measurement subprocess timeout (chrome launch + per-bp measurement)

Relationship to other verifiers

ArbiterScopeDecision engine
verification-arbiter (S027)Functional — test coverage against frozen planCold Claude + coverage scoring
visual-arbiter (this)Visual — bbox + tokens + interactions against frozen skeletonPure Python; puppeteer = measurement only
design-drift-arbiter (WP-10)Micro-drift auto-approval post-rejectionPure Python profile rules

Visual-arbiter runs before design-drift-arbiter. bob invokes drift-arbiter only if visual-arbiter rejected with micro-drift-only reasons (bbox within expanded profile tolerance OR token swap within same family). See ecosystem-keystone §5.6 / HARD-RULE 6.

Scope boundaries

In scopeOut of scope
Measuring bbox/tokens/interactions at each breakpointCreating/amending the skeleton (visual-architect owns)
Mechanical comparison against declared tolerancesJudgment calls about what a user "really wants" — v2 deferral (§1.3)
8-field tuple echoConsuming the verdict or writing visual-verdicts/ (bob owns via claims.consume_visual_verdict)
Emitting AUDIT_UNAVAILABLE via exit 4Model-emitted AUDIT_UNAVAILABLE (there is no model in the verdict path)
fail-open claude_observe for chrome slow/failBeing authoritative for observability — that's observability skill (S026)

References

  • Design doc: docs/plans/2026-04-23-ecosystem-keystone-design.md (§2.6 pure-Python arbiter, §2.9 visual-verdict.v1 schema, §5.5 claims.consume_visual_verdict, §5.6 HARD-RULE 6, §5.9 external-wrapper observations)
  • Contract map: progress/contract-map.yaml — component visual-arbiter (TS-VAR-01..05)
  • Sibling subprocess (S027): ~/.claude/skills/_meta/verification_arbiter_spawn.py
  • Binary: ~/.claude/skills/_meta/visual_arbiter_spawn.py
  • Measurement driver: ~/.claude/skills/_meta/visual_arbiter_measure.mjs
  • Tests: ~/.claude/skills/visual-arbiter/tests/test_visual_arbiter.py (TS-VAR-01..05)
我的收藏