/pixel-loop loop — 디자인이랑 같아질 때까지 자동 수정
화면이 Figma 상태 프레임과 같아질 때까지 고치기 → 다시 그리기 → 스크립트로 픽셀 비교를 반복하고, 반복마다 점수 · 남은 차이 영역 · 고칠 자리 판정을 남깁니다. 새 화면을 시안대로 만들거나 어긋난 화면을 다시 맞출 때 쓰세요 — 고치지 않고 판정만 하려면 /cc-pixel-loop:verify.
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-pixel-loop:loop |
| 분류 | Flutter |
| 난이도 | ●●● 높음 |
| MCP 서버 | figma, marionette, dart |
한마디로#
Figma 디자인과 앱 화면을 같은 크기 · 같은 배율로 찍어 프로그램이 비교하고, 같아질 때까지 코드를 고치는 반복 작업입니다. "시안 보고 → 화면 고치고 → 다시 찍어서 비교" 를 사람이 손으로 수십 번 하던 일을 AI 가 돌립니다. 같은지 다른지는 계산이 정하고, AI 는 다른 곳을 어디서 고칠지 가리고 고치는 일을 합니다.
누가·언제 쓰나요#
- 새 화면을 Figma 디자인 그대로 만들어야 할 때 (
create모드) - 이미 만든 화면이 시간이 지나며 디자인과 어긋났을 때 (
repair모드) - "디자인이랑 똑같이 맞춰주세요" 같은 요청이 있을 때
⚠️ 주의: 이 명령은 실제 소스 코드를 고칩니다. 변경사항이 없는 깨끗한 상태이거나 별도 작업 브랜치에서 돌리세요.
무엇을 해주나요#
같아질 때까지 반복한 뒤 .claude/docs/pixel-loop/<run_id>/ 폴더에 결과를 남깁니다:
-
compare/ — 비교 실행 폴더(
cc-pixel-loop:visual-verify와 같은 모양): Figma 이미지 · 앱 화면 · 비교 이미지 · 보고서 - diff/0N/ — 반복마다의 비교 보고서와 점수
- judgement — 차이마다 "어디서 고치나(앱 코드 · 스타일 · CoUI 버전 차이 · 알려진 차이)" 판정
- summary.md — 최종 요약(못 맞춘 차이가 남았다면 그 내용도)
어떻게 쓰나요#
# 새 화면을 디자인대로 — 섹션의 상태 프레임 전부를 라이트 · 다크로 (기본 원천: 테스트 렌더)
/pixel-loop loop https://www.figma.com/design/AbCd/App?node-id=12-340 --app <앱 경로>
# 어긋난 화면 다시 맞추기
/pixel-loop loop <섹션 url> --mode=repair --app <앱 경로>
# 실행 중인 앱으로(기기) — 동작까지 보며 맞출 때. 완료 판정의 근거로는 쓰지 않는다
/pixel-loop loop <프레임 url> --app <앱 경로> --source=device --route=/home --target=ios
주요 옵션:
figma-url(필수) — 섹션(그 안의 상태 프레임 전부) 또는 상태 프레임 하나의 주소-
--mode—create(새 화면) 또는repair(어긋난 화면 보정). 기본create --source—render(기본 — 기기 없이 테스트로 그린다) 또는device(실행 중인 앱)--app— 앱 경로(서체 · CoUI 판 · 리터럴 덧대기 검사가 읽는다)--exclude-theme— 내지 않는 밝기를 이유와 함께 빼기(예:"dark:라이트 전용 제품"). 기본은 라이트 · 다크 둘-
--iteration-cap— 최대 반복 횟수. 기본6(정식 이름은 이 하나입니다. 허용 오차 안에iteration_cap=8처럼 끼워 적은 형태도 같은 값으로 읽어 줍니다)
안에서 무슨 일이 벌어지나요#
다섯 단계를 한 바퀴로 묶어 같아질 때까지 반복합니다.
- 읽기(Read) — Figma 에서 상태 프레임과 그 크기 · 테마 · 서체를 읽고, 같은 배율의 사진을 내려받습니다. Figma 를 읽지 못하면 짐작하지 않고 그 자리에서 실패로 끝냅니다.
- 고치기(Write) — 고치기 전에 차이마다 어디서 고칠지 먼저 가립니다. CoUI 쪽 차이(판이 달라서 · 이미 알려져서)는 앱에서 고치지 않고, 앱 코드 · 스타일 차이만 CoUI 부품과 토큰으로 고칩니다. 숫자를 덧대 맞추는 수정은 고친 것으로 세지 않습니다.
- 다시 그리기(Reload) — 테스트 렌더면 화면을 다시 그리고, 실행 중인 앱이면 핫 리로드합니다(실패하면 앱을 다시 띄우되 한 실행에 통틀어 2번까지).
- 찍기(Capture) — 같은 크기 · 같은 배율로 앱 화면을 찍습니다.
- 비교(Compare) — 프로그램이 두 사진을 비교해 남은 차이를 점수 하나로 적습니다. 통과가 아니면 2번으로 돌아갑니다.
언제 멈추나요?
- Figma 를 읽지 못하면 → 아무것도 고치지 않고 그 사실을
summary.md에 적고 종료 - 비교가 통과하면(CoUI 쪽 차이는 근거와 함께 면제) → 완료
- 최대 반복 횟수에 도달하면 → 남은 차이를
summary.md에 적고 종료 - 고쳐도 나아지지 않으면 → 종료. 점수가 2번 연속 줄지 않았거나, 최근 3번 안에 나왔던 "차이 조합"이 또 나온 경우(색 고치면 여백이 깨지고 여백 고치면 색이 깨지는 제자리 왕복)
- 실행 중인 앱을 2번 다시 띄워도 화면을 못 잡으면 → 그 사실을
summary.md에 적고 종료
어떤 경우든 답을 기다리며 멈춰 서 있지 않습니다. 결과는 항상 summary.md 에 기계가 읽을 수 있는 상태값으로 먼저 적고 종료하며, 사람이 직접 부른 경우에만 그 뒤에 추가로 물어봅니다.
중간에 끊긴 실행을 같은 run_id 로 다시 부르면 이미 끝난 반복 다음 번호부터 이어가고, 써 버린 반복 횟수도 그대로 이어서
셉니다.
repair 모드에서는 고치기 전 비교를 한 번 더 남겨, 요약에서 수정 전후를 볼 수 있게 합니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
비교 · 판정 · 기본값의 정본은 cc-pixel-loop:visual-verify 다 — 이 커맨드는 그 비교를 반복마다 돌리고, 반복 사이에 코드를 고친다.
Parameters#
| Parameter | Required | Description | Default |
|---|---|---|---|
figma-url | ✅ | 섹션 또는 상태 프레임 URL (node-id) | — |
--mode | ❌ | create (new screen) or repair (drift fix) | create |
--source | ❌ | render — 위젯북 use_case 상태를 프레임 크기 장치로 테스트 캡처(기기 불필요, Flutter Web 포함) · device — 실행 중인 앱의 Marionette 스크린샷 | render |
--app | ✅ | 앱(워크스페이스) 루트 | — |
--test | conditional | render 원천의 캡처 테스트 파일(visual-verify 3절). 없으면 Phase 1 이 cc-blueprint:widgetbook-wiring 4b 의 모양으로 만든다(같은 mock 시나리오) | — |
--exclude-theme | ❌ | '<theme>:<이유>' — 밝기는 늘 라이트 · 다크 둘이고, 빼는 것은 이유와 함께만 | — |
--ignore-frame · --mask-node | ❌ | visual-verify 의 plan 옵션 그대로(화면이 아닌 프레임 · 계속 바뀌는 노드, 이유와 함께) | — |
--route | conditional | device 원천의 앱 안 경로 (e.g. /home) | — |
--target | ❌ | device 원천의 기기: ios, android, macos, linux, windows, auto | auto |
--tolerance | ❌ | pixel=N,color_delta_e=N,text_pixel=N,text_delta_e=N | visual-verify 기본값 |
--budget | ❌ | ratio=N,region=N | visual-verify 기본값 |
--iteration-cap | ❌ | Max iterations before bailing out. This spelling is canonical. | 6 |
--run-id | ❌ | Reuse an existing .claude/docs/pixel-loop/<run_id>/ folder — resumes at max(N)+1 with the consumed budget carried forward | new YYYYMMDD-HHMM |
--non-interactive | ❌ | Never ask anything: write summary.md and return. auto = on whenever another command or a dispatched worker invoked this one (e.g. run.md Step 7.3) | auto |
Iteration cap — one spelling, one parser. A cap written inside the --tolerance block
(--tolerance pixel=2,color_delta_e=3,iteration_cap=8) is a key the comparison does not accept; a
parser that discards it silently makes the loop unbounded. Rules, in order:
--iteration-cap=Nis the canonical flag.iteration_cap=Nappearing inside--toleranceis accepted as an alias, normalized to--iteration-cap=N, and removed before the tolerance reachesfigma_compare.py(which rejects unknown keys). The normalization is logged.- If both appear, the explicit flag wins.
- An unparsable or out-of-range cap is never "unbounded" — the default
6applies and the substitution is written intosummary.md. (../../cc-dev/rules/orchestration-graph.md§3.2,null-as-pass: an unread limit must not read as "no limit".)
Phase Flow#
Substrate:
inline / sequential— unchanged, and un-parallelizable by construction. Read → Write → Reload → Capture → Compare is a strict data dependency: the next patch cannot be written before the previous capture is compared. A run holds one render target (the capture test, or one device / VM-Service session) for its whole duration, and nothing inside a run is fanned out. Notation, loop-contract fields, gate tri-state and the substrate row are defined once in../../cc-dev/rules/orchestration-graph.md(§1, §2, §3, §4) and are cited, not restated. Port / simulator / device handles come from../../cc-dev/skills/parallel-test-env/SKILL.md(§4.3).
Normative declaration of this flow. The numbered list below it is a derived, non-normative view
(../../cc-dev/rules/orchestration-graph.md §6 — if the two disagree, the block wins):
PL0 ACT Read — compare-frames + plan + Figma exports, once per run writes:compare/frames-*.json,compare/plan.json,compare/figma/**
PL0g GATE Figma read verdict:whoami && get_metadata && compare-frames returned && plan.comparisons > 0 undet:fail fail:PL9
PLc LOOP converge cycle (pixelloop.loop-converge-core) contract:L-PL1
PL1 ACT Write — judge regions, then patch app/style-slot only writes:lib/**,compare/judgement.json tier:standard
PL1g GATE no literal patch verdict:literals.findings == 0 undet:fail fail:PLc
PL2 LOOP reload ladder (pixelloop.reload-recovery-ladder) contract:L-PL2
PL3 ACT Capture — capture test or nav.yaml replay → compare/render/<label>.png writes:compare/render/**,nav.yaml
PL3g GATE every comparable render saved verdict:render/<label>.png exists for every comparable label undet:fail fail:PLc
PL4 ACT Compare — figma_compare diff (each label) + summary writes:compare/reports/**,diff/0N/**
PL5 GATE progress guard (pixelloop.oscillation-guard) verdict:!stalled(N) && !oscillating(N) undet:fail fail:PL9
PL7 GATE converged verdict:summary.status == "pass" undet:fail fail:PLc
PL9 ACT summary.md status + return writes:summary.md # 이 흐름의 HALT 래퍼 — 묻지 않는다
S7.3 LOOP caller — run.md Step 7.3 design verification contract:L-7.3 extern:true
PL0 --> PL0g --> PLc --> PL1 --> PL1g --> PL2 --> PL3 --> PL3g --> PL4 --> PL5 --> PL7 --> PL9
S7.3 ..> PL0 # advisory: 직접 호출도 정상 진입점이다
PL0g ~~> PL9 on:!figmaRead record:summary.md status:"undetermined:figma-unreadable"+the failing call # 스크린샷에서 짐작하지 않는다
PL1g ==> PLc on:literals.findings > 0 bound:6 invalidates:PL1,PL1g # 그 패치는 되돌리고 반복 예산에서 센다
PL3g ==> PL2 on:render.missing && source=="device" bound:2 invalidates:PL3,PL3g # 기기 — 재시작 사다리, bound 는 run 전체 합산
PL3g ==> PLc on:render.missing && source=="render" bound:6 invalidates:PL1,PL1g,PL2,PL3,PL3g # 캡처 테스트 실패는 코드 · 테스트 오류 — 다음 Write 가 고친다(반복 예산에서 센다)
PL2 ~~> PL9 on:restartsUsed==2 && !vmAlive record:summary.md status:"not-converged:reload-exhausted"+VM error
PL5 ~~> PL9 on:stalled||oscillating record:summary.md status:"not-converged:stalled|oscillating"
PL7 ==> PLc on:summary.status != "pass" bound:6 invalidates:PL1,PL1g,PL2,PL3,PL3g,PL4,PL5,PL7 # = --iteration-cap 기본값
PLc ~~> PL9 on:iterations==iterationCap record:summary.md status:"not-converged:cap-exhausted"
PL7 ~~> PL9 on:channels==[] record:summary.md status:"undetermined:no-comparable-channel" # 대조할 수 있는 비교가 하나도 없다
PL7 ~~> PL9 on:comparablePass && designSideOnly record:summary.md status:"undetermined:design-side"+the labels # 남은 것은 시안 쪽 판정 불가뿐 — 코드로 고칠 수 없다Reading it: PL0g is the first gate — a run that could not read Figma reaches PL9 before any patch
is written. PL9 is the only sink and it always writes summary.md first, so every terminal
failure (fail:PL9) is a HALT with a durable, machine-readable record. PL7's failure is the
ordinary case (not converged yet) and is the feedback edge; PL5 sits before it so a stalled or
oscillating run cannot spend the rest of the budget. PL1g makes a literal patch a wasted
iteration rather than progress — the patch is reverted and the cycle starts over, charged to the
same cap. PL7 reads summary.status, so an undetermined comparison (figma_compare.py exit 3)
can never read as convergence.
A comparable label is one whose Figma side exists — its source is not unthemable and its
export or clone was downloaded. A label that is not comparable is not a Figma read failure
(PL0g passes) and not a capture failure (PL3g does not wait for it): it stays undetermined in
the summary. When every comparable label passes and only design-side undetermined labels remain
(figma-unthemable, unnamed-frame, a clone that could not be made), the loop stops with
undetermined:design-side instead of spending the cap on something no patch can fix — the caller
routes it to the designer (../../cc-dev/commands/run.md Step 7.3 ⑤b).
- Read — the official Figma MCP (
../README.md→ Prerequisite), once per run:whoami→create_session→cc-blueprint의compare-frames.figma.js(섹션 또는 프레임) →figma_compare.py plan→download_assets(png, defaultScale)for every planned label (다른 밝기는dark-frames.figma.js복제본 — 받은 뒤 반드시remove) →fonts·coui.get_design_context(loadfigma:figma-design-to-codefirst) is read once for the spec Write needs — its reference code is React/HTML to translate into CoUI, not to copy. Ifwhoami,get_metadata,compare-framesorplanfails,PL0gfails:status: undetermined:figma-unreadable, and no spec is reconstructed from a screenshot. A clone or download that fails for one comparison leaves only that comparison undetermined — it is not a Figma read failure. If the capture test is missing, Phase 1 writes it in the shape ofcc-blueprint:widgetbook-wiring4b (the same mock scenarios as the widgetbook state knob). - Write — 남은 영역마다 고칠 자리를 먼저
compare/judgement.json에 적는다(visual-verify5절 —known·version·style-slot·app).known·version은 고치지 않는다.style-slot·app만 CoUI 부품과 토큰으로 고치고, 반복마다 가장 작은 패치를 고른다. 그다음figma_compare.py literals --base <loop 시작 커밋>— 덧대기가 있으면PL1g가 그 패치를 되돌린다. - Reload —
render원천은 할 일이 없다(다음 Capture 가 테스트를 다시 돌린다).device원천은 Dart MCPhot_reload, 필요하면hot_restart(run 의 재시작 예산 2 — Failure Recovery). - Capture —
render: 캡처 테스트(CO_GOLDEN_MODE=capture flutter test <test>)를 돌리고figma_compare.py ingest --run compare --captures <CO_GOLDEN_OUTPUT>/images/<suite>로 plan 의 label 마다compare/render/<label>.png에 옮긴다(장치 · 시나리오 이름은 plan 의devices·captureScenario).device:marionette.connect→nav.yaml재생 →take_screenshots(기기 배율로 — plan 의--scale을 기기 배율로 만든다). - Compare — label 마다
figma_compare.py diff --run compare --label <label>, 그다음summary --check-issues.compare/reports/를diff/0N/에 복사하고diff/0N.yaml에 점수를 적는다. 통과가 아니면 2번으로.
Convergence#
Every iteration writes three machine-readable fields into diff/0N.yaml — the per-region detail
stays in diff/0N/<label>.report.json:
| field | meaning |
|---|---|
delta_ids | sorted set of <label>/<region id> for regions still open after judgement — e.g. 12-345__light/node:12:357. Region ids are node ids from the Figma frame, so the same defect keeps the same id across iterations. The identity of what is still wrong. |
score | summary.json 의 openScore — 면제를 뺀 남은 차이 넓이(논리 px²). figma_compare.py 가 계산한다. unknown if any comparison was undetermined — never 0. |
channels | [visual] when every comparable label produced a report, [] otherwise. |
The number is computed by the script, not estimated by the model, so it is comparable across
iterations of the same run. score is the loop's prog: quantity.
Contract L-PL1 (pixelloop.loop-converge-core) — field definitions:
SoT §2. Values live here.
inv: exactly one render target is live for the whole run (the capture test, or one device /
VM-Service session) and every capture reads that one; iteration N does not end before
diff/0N.yaml is written (no unrecorded iteration)
prog: score (summary.openScore), strictly decreasing per iteration
no-prog: never re-apply the same patch to the same delta_ids — the run terminates per
term:, it does not spend the remaining budget on the same attempt. A literal patch is
reverted (PL1g) and counts as an iteration without progress
term: converged = summary.status == "pass" && channels !== []
|| designSide = every comparable label passes && only design-side undetermined remain
|| stalled(N) = N>=3 && score(N) >= score(N-1) && score(N-1) >= score(N-2)
|| oscillating(N) = N>=2 && delta_ids(N) ∈ { delta_ids(N-1), delta_ids(N-2), delta_ids(N-3) }
|| iterations === iterationCap
score === unknown counts as "did not improve" — never as convergence
budget: 6 iterations (--iteration-cap; alias rules in Parameters) · 2 app restarts per run (L-PL2)
exhaust: write summary.md (status block below) with residual delta_ids + score history, then return.
No AskUserQuestion: a dispatched worker or CI has nobody to ask (SoT §2)
resume: the existing .claude/docs/pixel-loop/<run_id>/ folder — resume at max(N in diff/0N.yaml)+1
and carry the consumed budget forward (iterations spent = count(diff/*.yaml); re-entry
never resets the cap). Idempotent on re-entry: compare/figma/** and plan.json are re-read
from disk (Figma is not called again), nav.yaml replayed, diff/ is append-only
log: one line per iteration including the score —
"[3/6] Compare ✗ score 41→17 (3 open regions, channels=visual)"
plus one terminal status line; dropped regions are named, not summarized as "some"Why the guard watches two things. A period-2 ping-pong (fix colour → break padding → fix padding
→ break colour) yields alternating diffs, so consecutive iterations never match. stalled()
catches it by magnitude (the score stops falling) and oscillating() by identity (the same
delta_ids set reappears within the last 3 iterations); one set repeating at distance 2 is enough.
Example#
/pixel-loop loop https://www.figma.com/design/AbCd/App?node-id=12-340 --app ../app --mode=createConsole transcript:
[0/6] Read ✓ compare-frames 12:340 (frames 4) · plan 8 (4 × light/dark) · figma 8/8 · fonts pass · coui coui_flutter 0.163.0
[1/6] Judge ✓ 5 regions → app 3 · style-slot 1 · version 1 (coui:card)
[1/6] Write ✓ patched lib/src/presentation/page/home_page.dart (+21, -9) · literals 0
[1/6] Capture ✓ capture test → render 8/8
[1/6] Compare ✗ score 341.5 → 96.0 · 2 open regions (channels=visual)
[2/6] Write ✓ patched ... · literals 0
[2/6] Compare ✓ score 96.0 → 0.0 · summary pass (excused 1: version coui:card)
[2/6] Exit ✓ summary.md status: converged (iterations 2/6, restarts 0/2)Repair Mode#
/pixel-loop loop <figma-url> --mode=repair --app <앱 경로>Repair mode runs one Compare before any Write and keeps it as diff/00/, so the summary shows
before/after. The agent does not assume the existing widget tree is wrong — judgement decides
whether a region is the app's to fix at all.
Failure Recovery#
Contract L-PL2 (pixelloop.reload-recovery-ladder) — field definitions:
SoT §2. Values live here.
The render source has no ladder: a failed capture test is a build or test error, fixed by the next
Write. The ladder below is the device source's.
inv: at iteration end the app answers vm_service getVM and the Marionette session sits on --route
prog: rung = 0 hot_reload → 1 hot_restart → 2 relaunch (`flutter run`), strictly increasing
no-prog: a rung is never retried at the same rung → go up one rung (SoT §2 default ladder)
term: getVM answers && nav.yaml replay reaches --route && every planned render is written
budget: 2 restarts per run — rungs 1 and 2 combined, counted across all iterations of the run,
not per iteration (6 iterations do not buy 12 restarts)
exhaust: summary.md status:"not-converged:reload-exhausted" + the raw Dart VM error, then return
resume: nav.yaml (the recorded route recipe) + restarts in summary.md's status block + max(N) in diff/
log: "reload #2: rung1 hot_restart ok (restarts 1/2)" — the restart counter on every lineIf hot reload starts failing mid-loop (e.g. a structural change broke the isolate), the command:
- Issues
hot_restart(rung 1) and incrementsrestarts. At most 2 restarts per run. - Re-connects Marionette (a restart drops the VM Service session) and replays
nav.yamlrather than re-deriving the route. - Rung 2 relaunches the app (
flutter run) — charged to the same 2-restart budget. - Once both restarts are spent and the VM is still unreachable, writes
summary.mdwithstatus: not-converged:reload-exhaustedplus the raw Dart VM error and returns.
nav.yaml — the replayable navigation recipe (device source)#
Recorded on the first successful capture and rewritten only when the route itself changes:
route: /home
vm_service_hint: reconnect # fresh URI after every restart — never cached
steps:
- { tool: tap, key: home_tab } # ValueKey first; text matching only as a last resort
- { tool: scroll_to, key: featured_section }
- { tool: enter_text, key: search_field, text: "" }
settle_ms: 300
recorded_at_iteration: 1Keys come from the same ValueKey discipline as ../skills/pixel-loop-guide/SKILL.md (Phase 4) — a
recipe built from text matching is not replayable. If the replay does not reach route, the capture
gate (PL3g) fails and re-enters the ladder; it is never treated as a successful capture.
Output#
.claude/docs/pixel-loop/<run_id>/
├── compare/ # visual-verify 실행 폴더 — plan · figma · render · reports · judgement · summary
├── nav.yaml # device 원천만 — route recipe
├── diff/
│ ├── 00/ (repair) · 0N/ # 그 반복의 reports
│ └── 0N.yaml # delta_ids + score + channels
└── summary.md # status block (below) + residual regionssummary.md status block — the exit contract#
Written on every exit path, before returning, as the first block of the file:
status: not-converged:oscillating
# converged
# | not-converged:{cap-exhausted, stalled, oscillating, reload-exhausted}
# | undetermined:{figma-unreadable, no-comparable-channel, design-side}
run_id: 20260801-1412
source: render # render | device
iterations: 4 # spent, of iteration_cap
iteration_cap: 6 # effective value
iteration_cap_source: flag # flag | tolerance-alias | default (unparsable → default, never unbounded)
restarts: 1 # of 2 (device source)
score_history: [341.5, 96.0, 96.0, 96.0]
residual_delta_ids: [12-345__dark/node:12:357]
literal_patches_reverted: 1
compare_summary: compare/summary.json
channels: [visual]undetermined:figma-unreadable is not a design diff: nothing was compared and no patch was written.
A caller treats it like missing tooling (../../cc-dev/commands/run.md Step 0 · 7.3) — never as
convergence, never as a screen to repair. undetermined:design-side is the same for the caller:
the comparable part converged, the rest needs the designer (bind the colours, name the frames).
Callers branch on status, never on the prose below it — ../../cc-dev/commands/run.md Step 7.3
(L-7.3) consumes this and records its own verdict there, per
../../cc-dev/rules/orchestration-graph.md §3 (a missing or unreadable status is undetermined,
and undetermined fails). A re-entry with --run-id reads iterations and restarts back out of
this block, which is why both counters are written even on a converged run.