/pixel-loop loop — 디자인이랑 똑같아질 때까지 자동 수정#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-pixel-loop:loop |
| 분류 | Flutter |
| 난이도 | ●●● 높음 |
| MCP 서버 | figma, marionette, dart |
한마디로#
Figma 디자인과 실제 앱 화면을 나란히 놓고 비교한 뒤, 똑같아질 때까지 코드를 자동으로 고쳐주는 반복 작업입니다. "디자인 시안 보고 → 화면 고치고 → 다시 캡처해서 비교하고" 를 사람이 손으로 수십 번 반복하던 일을, AI가 알아서 돌려주는 자동 사진 맞추기라고 보면 됩니다.
누가·언제 쓰나요#
- 새 화면을 Figma 디자인 그대로 만들어야 할 때 (
create모드) - 이미 만든 화면이 시간이 지나며 디자인과 어긋났을 때, 다시 맞추고 싶을 때 (
repair모드) - "디자인이랑 픽셀 단위로 똑같이 맞춰주세요" 같은 요청이 있을 때
⚠️ 주의: 이 명령은 실제 소스 코드를 직접 고칩니다. 그래서 작업 전 변경사항이 없는 깨끗한 상태이거나, 별도 작업 브랜치에서 돌리는 것을 권장합니다.
무엇을 해주나요#
화면이 디자인과 일치할 때까지 반복한 뒤, .claude/docs/pixel-loop/<run_id>/ 폴더에 결과를 남깁니다:
- design.json / design.md — Figma에서 가져온 디자인 정보 정리본
- screens/ — 매 반복마다 찍은 실제 화면 스크린샷
- diff/ — 디자인과 실제 화면의 차이 기록
- summary.md — 최종 요약 (못 맞춘 부분이 남았다면 그 내용도 함께)
어떻게 쓰나요#
# 새 화면을 디자인대로 만들기 (기본)
/pixel-loop loop https://www.figma.com/design/AbCd/Home?node-id=12-345 \
--mode=create \
--target=ios \
--tolerance pixel=2,color_delta_e=3
# 이미 있는 화면을 디자인에 다시 맞추기 (repair 모드)
/pixel-loop loop < figma-url > --mode=repair --route=/home
주요 옵션:
figma-url(필수) — 맞출 대상 Figma 프레임 주소 (node-id가 포함된 링크)-
--mode—create(새 화면) 또는repair(어긋난 화면 보정). 기본은create --route—repair모드일 때 필수. 비교할 앱 안의 화면 경로 (예:/home)-
--target— 어떤 플랫폼에서 캡처할지 (ios,android,macos,linux,windows,auto). 기본auto --tolerance— 어디까지를 "같다"고 볼지 허용 오차 (픽셀/색상). 기본pixel=1,color_delta_e=2-
--iteration-cap— 최대 몇 번까지 반복할지. 기본6(정식 이름은 이 하나입니다. 가이드 문서에 나오는--tolerance pixel=2,...,iteration_cap=8처럼 허용 오차 안에 끼워 적은 형태도 같은 값으로 읽어 줍니다) --no-screenshot-diff— 스크린샷 비교는 건너뛰고 구조만 비교
안에서 무슨 일이 벌어지나요#
크게 다섯 단계를 한 바퀴로 묶어, 디자인과 같아질 때까지 반복합니다.
- 읽기(Read) — Figma에서 디자인 정보(레이아웃·스타일·색상)를 가져와 저장합니다.
- 고치기(Write) — Flutter 화면 코드를 수정합니다. 한 번에 크게 바꾸지 않고, 차이 나는 부분만 최소한으로 고칩니다.
- 새로고침(Reload) — 고친 코드를 실행 중인 앱에 즉시 반영합니다(핫 리로드). 실패하면 앱을 다시 띄우는데, 한 번의 실행에서 다시 띄우기는 통틀어 2번까지만 씁니다.
-
캡처(Capture) — 해당 화면으로 이동해 스크린샷을 찍습니다. 이때 "그 화면까지 가는 순서"를
nav.yaml에 적어 두고, 앱을 다시 띄운 뒤에는 그 순서를 그대로 재생해서 같은 화면으로 돌아갑니다. - 비교(Compare) — 디자인과 실제 화면을 비교하고, 남은 차이를 점수 하나(score) 로 적습니다. 점수가 0이 아니면 다시 2번으로 돌아갑니다.
언제 멈추나요?
- 차이가 허용 오차 안으로 사라지면(점수 0) → 완료
- 정해둔 최대 반복 횟수에 도달하면 → 남은 차이를
summary.md에 적고 종료 - 고쳐도 나아지지 않으면 → 종료. 두 가지로 봅니다: 점수가 2번 연속 줄지 않았거나, 최근 3번 안에 나왔던 "차이 조합"이 또 나온 경우(색 고치면 여백이 깨지고 여백 고치면 색이 깨지는 식의 제자리 왕복)
- 앱 다시 띄우기 2번을 다 써도 화면을 못 잡으면 → 그 사실을
summary.md에 적고 종료
어떤 경우든 답을 기다리며 멈춰 서 있지 않습니다. 결과는 항상 summary.md에 기계가 읽을 수 있는 상태값으로 먼저 적고 종료하며, 사람이 직접 부른 경우에만 그 뒤에 추가로 물어봅니다 (자동 실행·CI에서 물어보면 아무도 답할 사람이 없어 그대로 멈추기 때문입니다).
중간에 끊긴 실행을 같은 run_id로 다시 부르면 이미 찍힌 반복 다음 번호부터 이어가고, 이미 써 버린 반복 횟수도 그대로 이어서
셉니다(다시 부른다고 횟수가 초기화되지 않습니다).
repair 모드에서는 맨 앞에 "수정 전" 스크린샷을 한 장 더 찍어, 요약에서 수정 전후를 비교해 볼 수 있게 해줍니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Parameters#
| Parameter | Required | Description | Default |
|---|---|---|---|
figma-url | ✅ | Figma frame URL (file/design link with node-id) | — |
--mode | ❌ | create (new screen) or repair (drift fix) | create |
--route | conditional | Required for repair; in-app route to navigate to (e.g. /home) | — |
--target | ❌ | ios, android, macos, linux, windows, or auto | auto |
--tolerance | ❌ | pixel=N,color_delta_e=N | pixel=1,color_delta_e=2 |
--iteration-cap | ❌ | Max iterations before bailing out. This spelling is canonical. | 6 |
--run-id | ❌ | Reuse an existing .claude/docs/pixel-loop/<run_id>/ folder instead of starting a new one — resumes at max(N)+1 with the consumed budget carried forward | new YYYYMMDD-HHMM |
--no-screenshot-diff | ❌ | Skip visual diff (use structural diff only) | false |
--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. ../skills/pixel-loop-guide/SKILL.md documents the cap
inside the --tolerance block (--tolerance pixel=2,color_delta_e=3,iteration_cap=8). An agent
following that spelling used to hand this parser a key it did not accept, and the discarded cap made
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 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 on one device: the next patch cannot be written before the previous screenshot is compared. A run therefore holds a single device / VM-Service session for its whole duration, and nothing inside a run is fanned out — multi-screen fan-out is deliberately not proposed here. 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 are orthogonal to the substrate choice and 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 — figma.getNode/getStyles/getVariables writes:design.json,design.md
PLc LOOP converge cycle (pixelloop.loop-converge-core) contract:L-PL1
PL1 ACT Write — smallest patch for the current deltas writes:lib/** tier:standard
PL2 LOOP reload ladder (pixelloop.reload-recovery-ladder) contract:L-PL2
PL3 ACT Capture — replay nav.yaml → screens/0N.png writes:screens/**,nav.yaml
PL3g GATE route reached ∧ screenshot saved verdict:navReplay.ok & & screens/0N exists undet:fail fail:PL2
PL4 ACT Compare — delta_ids + score + channels writes:diff/0N.yaml
PL5 GATE progress guard (pixelloop.oscillation-guard) verdict:!stalled(N) & & !oscillating(N) undet:fail fail:PL9
PL7 GATE converged verdict:score===0 & & channels!==[] 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 -- > PLc -- > PL1 -- > PL2 -- > PL3 -- > PL3g -- > PL4 -- > PL5 -- > PL7 -- > PL9
S7.3 .. > PL0 # advisory: 직접 호출도 정상 진입점이다
PL3g == > PL2 on:navReplay.fail||!screenshot bound:2 invalidates:PL3,PL3g # bound 는 run 전체 합산
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:score > 0 bound:6 invalidates:PL1,PL2,PL3,PL3g,PL4,PL5,PL7 # = --iteration-cap 기본값
PLc ~~ > PL9 on:iterations==iterationCap record:summary.md status: " not-converged:cap-exhausted "Reading it: 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 — that is what replaces the
old "pauses and asks the user" exit. PL7's failure is the ordinary case (not converged yet) and is
the single feedback edge; PL5 sits before it so a stalled or oscillating run cannot spend the
rest of the budget. channels!==[] in PL7 blocks the nothing-to-check pass form
(../../cc-dev/rules/orchestration-graph.md §3.2): with no comparable channel the verdict is
undetermined, which fails, and the run ends at the cap with
status: undetermined:no-comparable-channel — it never reports convergence.
- Read —
figma.getNode(nodeId)+figma.getStyles(nodeId)+figma.getVariables(nodeId). Persist to.claude/docs/pixel-loop/<run_id>/design.json. - Write — Edit Flutter source. Use CoUI primitives by default. Always prefer the smallest patch that addresses the current diff.
- Reload —
dart.hotReload(); fall back todart.hotRestart()onrequires_restart(charged to the run's 2-restart budget — see Failure Recovery). Confirm withdart.getVMInfo(). - Capture —
marionette.connect(VM Service URI, app already running with state intact) → navigate to route (tap/scroll_to) →take_screenshots. Saved underscreens/. The successful navigation is recorded once asnav.yamland replayed verbatim after every restart. - Compare — Visual + structural diff →
delta_ids+score+channelsindiff/0N.yaml. Ifscore > 0, return to step 2 with the residual deltas.
Convergence#
Every iteration writes three machine-readable fields into diff/0N.yaml, on top of the per-category
delta lists (whose schema stays in ../skills/pixel-loop-guide/SKILL.md → Phase 5):
| field | meaning |
|---|---|
delta_ids | sorted set of <category>:<element>:<property> ids — e.g. color:TitleText:fill, layout:PrimaryButton:y. The identity of what is still wrong. |
score | scalar residual (formula below). 0 ⟺ every delta is inside tolerance. unknown if it could not be computed — never 0. |
channels | which comparisons actually ran: [visual, structural], [structural] (--no-screenshot-diff), or []. |
score = Σ_bounds max(0, |Δpx| − tolerance.pixel) # per x / y / width / height
+ Σ_colors max(0, ΔE − tolerance.color_delta_e) × 4 # ΔE 1 ≈ 4px equivalent
+ Σ_discrete 8 # weight / family / align / missing / extra: 8 eachThe weights are fixed for the whole run: the number only has to be comparable across iterations of
the same run, and retuning it mid-run destroys exactly that. score is the loop's prog: quantity.
Contract L-PL1 (pixelloop.loop-converge-core) — field definitions:
SoT §2. Values live here.
inv: exactly one device / VM-Service session is live for the whole run and every capture reads
that one; iteration N does not end before diff/0N.yaml is written (no unrecorded iteration)
prog: score (formula above), 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
term: converged = score === 0 & & channels !== []
|| 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: design.json/design.md re-read, nav.yaml
replayed, screens/ and diff/ are append-only
log: one line per iteration including the score —
" [3/6] Compare ✗ score 41→17 (3 deltas, channels=visual+structural) "
plus one terminal status line; dropped deltas are named, not summarized as " some "Why the previous guard could not work. It stopped on "the same diff twice in a row", which the
period-2 ping-pong it was named for never produces: fix colour → break padding → fix padding → break
colour yields alternating diffs, so consecutive iterations never match and the guard stayed silent
until the cap. 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 delta set repeating at
distance 2 is enough.
Example#
/pixel-loop loop https://www.figma.com/design/AbCd/Home?node-id=12-345 \
--mode=create \
--target=ios \
--tolerance pixel=2,color_delta_e=3Console transcript:
[1/6] Read ✓ figma.getNode 12:345 (Auto-layout, 14 children)
[1/6] Write ✓ patched lib/features/home/home_page.dart (+38, -12)
[1/6] Reload ✓ dart.hotReload (12ms)
[1/6] Capture ✓ marionette take_screenshots → screens/01.png
[1/6] Compare ✗ score 58 · 3 deltas (TitleText weight, FAB y+4, BG color #F9FAFB→#FFFFFF)
[2/6] Write ✓ patched ... (+5, -3)
...
[3/6] Compare ✓ score 17→0 · converged (channels=visual+structural)
[3/6] Exit ✓ summary.md status: converged (iterations 3/6, restarts 0/2)Repair Mode#
/pixel-loop loop < figma-url > --mode=repair --route=/homeRepair mode inserts an initial 00_before.png capture, so the summary shows before/after for review. The agent does not assume the existing widget tree is wrong — it diffs both directions and chooses the minimal patch.
Failure Recovery#
Contract L-PL2 (pixelloop.reload-recovery-ladder) — field definitions:
SoT §2. Values live here.
inv: at iteration end the app answers dart.getVMInfo() and the Marionette session sits on --route
prog: rung = 0 hotReload → 1 hotRestart → 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: getVMInfo() answers & & nav.yaml replay reaches --route & & screens/0N.png 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 screens/
log: " reload #2: rung1 hotRestart 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
dart.hotRestart()(rung 1) and incrementsrestarts. At most 2 restarts per run, so the ladder cannot silently consume the whole session budget. - 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. It does not block waiting for input.
nav.yaml — the replayable navigation recipe#
Recorded on the first successful capture and rewritten only when the route itself changes, so re-navigation after a restart is a replay, not a re-derivation:
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 > /
├── design.json
├── design.md
├── nav.yaml # route recipe — replayed verbatim after every restart
├── screens/
│ └── 0N_after_write.png
├── diff/
│ └── 0N.yaml # delta lists + delta_ids + score + channels
└── summary.md # status block (below) + residual deltassummary.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:no-comparable-channel
run_id: 20260801-1412
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
score_history: [58, 22, 22, 22]
residual_delta_ids: [color:TitleText:fill, layout:PrimaryButton:y]
channels: [visual, structural]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.