visual-architect (v1)
Status: WP-8 of S028 ecosystem-keystone. Design-phase freeze skill for UI designs.
Design document: /path/to/project/docs/plans/2026-04-23-ecosystem-keystone-design.md §2.2, §2.3, §2.5, §5.7, D2.
What this skill does
Turns a draft skeleton (from skeleton-extractor) + an approved HTML mockup + user edits into two signed, frozen artifacts:
.design-ledger/skeletons/index.yaml— shared root (tokens, components, breakpoints, screen manifest,design-skeleton-index.v1schema).design-ledger/skeletons/<screen>.yaml— per-screen skeleton (elements, bboxes, interactions,design-skeleton.v1schema)
Plus a ledger transition request (skeleton_frozen event) that bob consumes via claims.apply_request_idempotent (bob is sole ledger writer — CB4).
When to invoke
Forge Step 2.5 (see design §5.7) when ALL the following are true:
- Design output includes an HTML mockup, CSS file, new user-facing screen, or new interactive element
ui_scopeis NOTnonein the briefskeleton-extractorhas already produced a draft skeleton on disk
Runs inline in forge's design-phase context; parallel with component-contract-mapping (Step 8a). NOT invoked by bob (visual-arbiter is bob's verification side).
Invocation
python3 ~/.claude/skills/visual-architect/scripts/freeze.py freeze \
--draft <path to draft skeleton YAML> \
--mockup <path to approved HTML mockup> \
--user-edits <path to user-edits YAML or JSON> \
--out-index <path to write index.yaml> \
--out-screen <path to write <screen>.yaml> \
--forge-session-key <path to .forge/session.key> \
[--claim-uuid <claim_uuid>] \
[--project-root <project root dir>]
Exit codes:
| Code | Meaning |
|---|---|
0 |
Freeze succeeded. Both files signed + written atomically. Transition request emitted to .ledger/requests/. |
1 |
Freeze rejected — unresolved binds_to URI (challenge filed via claims.file_challenge with reason=functional_requirement_conflict). |
2 |
Freeze rejected — unresolved token without explicit user approval (D2 strict; no challenge filed because it is a user-input gap, not a functional conflict). |
3 |
Environmental / usage error (missing argv, unreadable input, malformed draft). |
Library entry point
from visual_architect.scripts.freeze import freeze_skeleton
result = freeze_skeleton(
draft_path=Path(...),
mockup_path=Path(...),
user_edits={...}, # parsed YAML/JSON dict
out_index_path=Path(...),
out_screen_path=Path(...),
session_key_path=Path(...),
project_root=Path(...), # for URI resolution + challenge filing + request emission
claim_uuid=str, # for transition request
)
# returns dict with: status ("frozen" | "rejected_binds_to" | "rejected_tokens"),
# index_path, screen_path, signature, request_path, challenges_filed.
Inputs
--draft — draft skeleton YAML
Output of skeleton-extractor (.design-ledger/skeletons/<screen>.draft.yaml). Unsigned; may contain:
interactions[].binds_to: null— unwired; user MUST supply acapability://...URI (or markvisual_only: true)unresolved_tokens: [{value: "#ac3b3b", seen_at: [selectors]}]— hardcoded CSS values the extractor could not back-resolve. D2 strict: each MUST be approved or added totokensbefore freeze.
--mockup — approved HTML mockup
Pinned into design_doc_hash (sha256 of bytes). Used for provenance; not parsed here.
--user-edits — user review output (YAML or JSON)
Schema (see §2.5 of design doc):
binds_to_assignments:
# map element_id#event -> capability URI (or visual_only sentinel)
"step_card.1#click": "capability://journey_controller.advance_step"
"step_card.1#hover": visual_only
bbox_confirmations:
# map element_id -> {breakpoint: confirmed_bbox}
# (if absent, extractor's bbox is kept)
tokens_approved:
# list of {value, add_as: "path.to.token"} pairs to ADD to index.yaml tokens
- {value: "#ac3b3b", add_as: "color.accent.brick"}
tokens_rejected:
# list of values the user explicitly rejects — freeze FAILS if any unresolved
# token is in this list (signals user wants to rewrite mockup, not accept)
- "#ff0000"
Freeze flow
- Load draft skeleton from
--draft(YAML). - Apply user edits:
- For each
interactions[].binds_to: null, look up theelement_id#eventkey inuser_edits.binds_to_assignments. Missing → error (user must handle every null). - For each
unresolved_tokensentry, look upvalueintokens_approved. If present, add to draft'stokensblock. If intokens_rejected, fail with exit code 2. If neither — D2 strict — fail with exit code 2.
- For each
- Validate every
binds_to: capability://...URI resolves viauri.exists(uri, project_root). First failure:- Call
claims.file_challenge(project_root, skeleton_ref=..., reason="functional_requirement_conflict", details=...)— auto-emits observation via fail-openclaude_observe. - Exit code 1.
- Call
- Compute:
design_doc_hash= sha256 of mockup bytesindex_hash= sha256 of canonical-JSON(index_body excludingindex_hash+signature)- Per-screen
skeleton_hash= sha256 of canonical-JSON(screen_body excludingsignature)
- HMAC-sign (see "Signature pattern" below).
- Build YAML text for both files (index.yaml +
<screen>.yaml). - Call
trusted_runner.bundle_write([(out_index, bytes1), (out_screen, bytes2)])— atomic two-file commit with pre-image rollback. - Emit transition request at
.ledger/requests/<request_id>.request.yamlwithevent: skeleton_frozen, referencing both frozen paths + their hashes. - Return structured result.
Signature pattern (exactly matches S025)
import hashlib, hmac, json
# session.key bytes INCLUDING trailing newline — S024/S025 invariant
key = session_key_path.read_bytes() # NOT $(cat file)
payload = {
"skeleton_hash": index_hash, # for index.yaml, the index_hash
"skeleton_version": "1.0",
"design_doc_hash": design_doc_hash,
"created_at": iso_now,
}
msg = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
digest = hmac.new(key, msg, hashlib.sha256).hexdigest()
signature = {
"algorithm": "HMAC-SHA256",
"key_id": ".forge/session.key",
"signed_fields": ["skeleton_hash", "skeleton_version", "design_doc_hash", "created_at"],
"signed_at": iso_now,
"digest": digest,
}
The canonical JSON (sort_keys=True, compact separators, UTF-8) is identical to trusted_runner.canonical_bundle_bytes.
Hard rules
- Reads Foundation primitives, never modifies them —
uri.exists,claims.file_challenge,trusted_runner.bundle_write,claude_observe. - D2 strict token binding — no silent skip. Every unresolved token requires explicit user approval (adds to tokens block) or explicit rejection (fails freeze).
binds_tovalidated viauri.existsBEFORE freeze — rejection viaclaims.file_challenge(with reasonfunctional_requirement_conflict); never a half-signed artifact.- Atomic two-file commit —
trusted_runner.bundle_write; hand-rolled tmp+rename would leak partial state on failure. - CB4 preserved — this skill writes to
.design-ledger/skeletons/*.yaml(its own output domain) +.ledger/requests/<uuid>.request.yaml(transition request bob consumes). It writes NOTHING toprogress/integration-ledger.mdor.ledger/claims/. - HMAC signature uses
session.keyfile bytes including trailing newline..read_bytes()is required, not shell-captured string.
Scope boundaries
| In scope | Out of scope |
|---|---|
| Applying user edits to draft | Invoking skeleton-extractor (runtime integration only; caller passes draft path) |
Validating binds_to URIs |
Validating built-product HTML against skeleton (that's visual-arbiter) |
| Emitting transition request | Applying transition request to ledger (bob owns; CB4) |
| Filing challenges for unresolved binds_to | Resolving challenges (claims.resolve_challenge — bob owns) |
| Writing frozen skeleton files | Writing progress/integration-ledger.md or .ledger/claims/ (bob owns; CB4) |
References
- Design doc: §2.2 index schema, §2.3 per-screen schema, §2.5 freeze flow, §5.7 forge Step 2.5 wiring, D2 strict token binding
- Contract map component:
visual-architect(WP-8, TS-VA-01..03) - Foundation primitives:
claims.file_challenge(WP-5),uri.exists(WP-1),trusted_runner.bundle_write+atomic_write_bytes(WP-2),claude_observe(WP-3) - Mirror pattern (conceptually): S027 test-architect Phase 3 (not yet built)