/jaspr-web match — 웹·플러터 화면 "다른 그림 찾기"#
| 항목 | 내용 |
|---|---|
| 실행 명령 | /cc-jaspr-web:match |
| 분류 | 웹 |
| 난이도 | ●●● 높음 |
| MCP 서버 | playwright |
한마디로#
같은 컴포넌트를 웹으로 그린 화면과 플러터로 그린 화면을 나란히 두고, 픽셀 단위로 "어디가 다른지" 자동으로 찾아주는 단계입니다. 틀린 그림 찾기처럼 두 그림을 겹쳐 보고, 다른 부분만 빨갛게 표시한 비교 사진과 보고서를 만들어 줍니다.
누가·언제 쓰나요#
- 같은 UI를 웹과 플러터 두 방식으로 보여주는 미리보기 페이지(live-preview 류)를 점검할 때
- "웹에서는 잘 보이는데 플러터에서는 글씨 굵기·색·배치가 다른 것 같다"를 눈이 아니라 수치로 확인하고 싶을 때
- PR(코드 변경 요청)을 합치기 전에 자동 검사 관문으로 걸어, 진짜 차이가 생기면 통과를 막고 싶을 때
👉 이럴 땐 다른 명령을 쓰세요: 페이지에 웹·플러터 중 한쪽만 있으면 /jaspr-web regression, 플러터 쪽을 직접 만져 보며 확인하려면
/jaspr-web widgetbook-web.
무엇을 해주나요#
지정한 페이지의 미리보기 카드마다 비교 결과를 정리해, .claude/docs/jaspr-web/match-<시각>/ 폴더 하나로 남깁니다. 그 안에는:
-
변형별 비교 사진 — 웹 화면(
A.png), 플러터 화면(B.png), 그리고 둘을 겹쳐 차이를 빨갛게 칠한 사진(diff.png) -
차이 분류 기록(
classification.yaml) — 차이가 글씨(typography)·색(color)·배치(layout)·잡음(noise) 중 무엇 때문인지, 또는 플러터 쪽이 끝내 안 그려졌는지(timeout). 플러터가 그려질 때까지 기다린 시간도 함께 적습니다 -
종합 판정서(
verdict.md) — "찾은 카드 8 / 비교한 카드 8 / 건너뛴 카드 0" 카운터로 시작해 "8개 중 6개 통과, 1개 글씨 차이, 1개 색 차이" 식으로 요약 - 사용한 기준 기록(
selectors-used.yaml) — 같은 검사를 똑같이 다시 돌릴 수 있도록
어떻게 쓰나요#
# 기본 사용 - 미리보기 페이지 주소만 주면 됨
/jaspr-web match \
--url=https://coui.cocode.im/live-preview-poc \
--tolerance-preset=default
--url(필수): 웹·플러터 미리보기가 같이 있는 페이지 주소-
--variants: 특정 변형만 비교하고 싶을 때 이름을 쉼표로 나열 (비우면 보이는 전부). 적어 준 이름 중 하나라도 페이지에서 못 찾으면 조용히 넘어가지 않고 못 찾은 이름을 알려주며 멈춥니다 -
--tolerance-preset: 얼마나 깐깐하게 볼지 —strict(엄격) /default(기본) /loose(느슨) --mask: 비교에서 제외하고 싶은 영역 지정--output: 결과를 저장할 폴더 위치--flutter-ready-timeout: 플러터 쪽이 그려질 때까지 기다리는 최대 시간 (기본 15초, 느리게 시작하는 CI 기계는 45초 권장)--force: 이미 결과가 남아 있는 카드도 다시 찍기 (없으면 이미 끝난 카드는 건너뛰므로, 중간에 끊긴 검사를 이어서 돌릴 수 있습니다)
PR 자동 검사로 걸 때는 결과 폴더를 따로 지정하고, 글씨·색·배치 차이나 끝내 안 그려진 카드(timeout) 가 하나라도 나오면 검사를 실패 처리합니다(잡음·통과는 막지 않음). 카드를 한 장도 못 찾은 경우,
--variants 로 적어 준 이름을 못 찾은 경우도 "차이 없음"이 아니라 실패입니다 — 비어 있는 판정서가 조용히 통과하는 것이 이 검사의 가장 위험한 고장이라서 그렇습니다.
안에서 무슨 일이 벌어지나요#
페이지를 열어 미리보기 카드를 하나씩 돌며 두 화면을 비교합니다.
- 페이지 열기 — 주소로 이동해 화면이 다 뜰 때까지 기다립니다.
- 카드 찾기 — 먼저 페이지를 한 화면씩 끝까지 훑어 내렸다가 맨 위로 돌아와, 아직 안 뜬 카드까지 다 그려지게 만듭니다. 그다음 미리보기 카드를 전부 찾아 카드마다 웹 패널과 플러터 패널을 짚습니다. 이때 찾은 카드가 0장이면 그 자리에서 멈춥니다 — 0장은 "차이 없음"이 아닙니다.
-
이미 끝난 카드 건너뛰기 — 결과 폴더에 그 카드의 분류 기록(
classification.yaml)이 이미 있으면 다시 찍지 않고 이유를 남긴 채 넘어갑니다(--force면 다시 찍습니다). -
준비 기다리기 — 카드를 화면에 띄우고, 플러터 쪽 "Loading Flutter..." 문구가 사라져 그릴 준비가 될 때까지(기본 최대 15초,
--flutter-ready-timeout으로 조정) 기다립니다. 시간이 다 지나도 안 그려지면 억지로 사진을 찍거나 조용히 넘기지 않고 "안 그려짐(timeout)"으로 못 박아 검사를 막습니다 — 기다린 시간도 같이 기록합니다. - 사진 찍기 — 웹 화면과 플러터 화면을 각각 캡처합니다(
A.png,B.png). - 차이 비교·분류 — 두 사진을 기준값으로 비교하고, 차이를 글씨·색·배치·잡음으로 분류한 뒤 겹친 비교 사진을 만듭니다.
-
종합 정리 — 모든 카드 결과를 모아 판정서(
verdict.md)로 정리합니다. 찾은 카드 수가 비교한 카드 수 + 건너뛴 카드 수와 안 맞으면 그것도 실패로 봅니다.
⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)
Parameters#
| Parameter | Required | Description | Default |
|---|---|---|---|
--url | ✅ | Page URL with dual previews | — |
--variants | ❌ | Comma-separated variant names; omit for "all visible" (visibility decided after the forced-mount scroll). Any named entry that resolves to no card BLOCKS | all |
--tolerance-preset | ❌ | strict / default / loose | default |
--selectors | ❌ | Path to selector override YAML | bundled defaults |
--mask | ❌ | CSS selector list to mask out before diffing | none |
--output | ❌ | Output dir | .claude/docs/jaspr-web/match-<ts>/ |
--flutter-ready-timeout | ❌ | Per-card Flutter-ready poll budget in ms. Raise it for cold CI runners (45000) | 15000 |
--force | ❌ | Re-capture cards that already have a classification.yaml; without it those cards are skipped as already-classified | off |
Default selectors#
variant_card: ' [data-co= " preview-card " ] '
preview_web_panel: ' [data-co= " preview-web " ] '
preview_flutter_panel: ' [data-co= " preview-flutter " ] '
flutter_ready_indicator: ' flutter-view[data-flt-renderer] '
flutter_loading_text: ' text= " Loading Flutter... " '
variant_label: ' [data-co= " preview-variant-label " ] 'If coui.cocode.im/live-preview-poc uses different markers, override via --selectors. The skill stops with a clear error if the catalogue doesn't resolve. A catalogue that does resolve is not sufficient — see Discovery & Count Contract below.
Discovery & Count Contract#
A resolved catalogue that matches zero cards is this command's fail-open seat: a renamed
data-co value, a gallery that failed to render, or a --variants list matching nothing
produces an empty for-each, an empty verdict.md, and a CI job that exits 0. Those are the
empty-set pass and nothing-to-check pass forms named in
../../cc-dev/rules/orchestration-graph.md §3.2. Gate verdicts here are tri-state and
undetermined defaults to fail (§3) — "couldn't tell how many cards there are" is a block,
not a pass.
Substrate: inline / sequential, unchanged (§4). All cards share one browser tab and the sweep mutates scroll position, so the card loop is genuinely sequential and is never fanned out.
Before the first screenshot:
- Force lazy mounts. Scroll one viewport at a time to the bottom, then back to the top,
before enumerating. Both panels mount on visibility, so
--variantsomitted = "all visible" only means what it says after this pass; without it a below-the-fold card silently leaves the run. - Assert the expected count. Two gates, both blocking:
G-match.discovery GATE cardsFound > 0
verdict:count(variant_card) > 0 undet:fail fail:HALT
G-match.variants GATE every --variants name resolved
verdict:setdiff(requested, found) is empty undet:fail fail:HALTcardsFound == 0 BLOCKS, echoing the resolved selectors and the page URL — it is never
reported as "0 divergences". A named --variants entry with no matching card BLOCKS with the
missing names listed; a silent subset run is not an outcome this command has.
- Resume is by artifact, not by counter. A card whose
<output>/variants/<name>/classification.yamlexists is done and is skipped with reasonalready-classifiedunless--forceis passed (--forcere-captures and overwrites). This makes re-running with the same--outputidempotent.
Every run logs three counters, and verdict.md repeats them on its first line:
cards-found: N · cards-compared: M · cards-skipped: K, followed by one
<name>: <reason> line per skipped card (already-classified / variants-filtered /
not-rendered). cards-compared == 0 BLOCKS. cards-found != cards-compared + cards-skipped is an internal inconsistency and also BLOCKS — a counter that doesn't add up
means a card fell out of the sweep unrecorded.
Loop#
0. Resolve the selector catalogue (stop on failure); load existing
< output > /variants/*/classification.yaml as " done " markers (ignored under --force)
1. Navigate to < url >
2. Wait for networkidle, then force lazy mounts: scroll one viewport at a time to the
bottom, then back to the top
3. Find all [data-co= " preview-card " ]; assert cardsFound > 0 and every --variants name
resolved (G-match.discovery / G-match.variants — BLOCK, never an empty pass)
4. For each card, sequentially in the one shared tab — L-jaspr.match-card-foreach:
a. Skip with reason `already-classified` if classification.yaml exists and no --force
b. Find Web + Flutter panels
c. Scroll card into view; re-poll the Flutter panel ' s ready indicator until
" Loading Flutter... " disappears — L-jaspr.match-flutter-ready-poll, budget
--flutter-ready-timeout (default 15000ms). On exhaustion: no screenshot,
classification = `timeout`, BLOCKING, elapsed ms recorded
d. takeScreenshot --clip= < web-rect > → A.png
e. takeScreenshot --clip= < flutter-rect > → B.png
f. Diff A vs B with tolerance preset
g. Classify divergence (typography / color / layout / noise / timeout)
h. Write 3-up image (A | B | diff overlay) + classification.yaml
5. Aggregate into verdict.md with cards-found / cards-compared / cards-skipped and one
reason line per skipped card; cards-compared == 0 or a counter mismatch BLOCKSLoop contracts#
Field definitions (inv: prog: term: budget: exhaust: resume: log:) come from
../../cc-dev/rules/orchestration-graph.md §2; the values below are this command's own and
live here, at the loop's site.
L-jaspr.match-flutter-ready-poll (step 4c — one card ' s Flutter panel)
inv: the card stays inside the viewport and scroll position is fixed for the whole
iteration; screenshots are taken only after this loop confirms ready — an
unconfirmed capture never enters the diff
prog: elapsed_ms strictly increasing at a 500ms poll interval, against
readySignals = flutter_ready_indicator present & & flutter_loading_text absent
no-prog: identical signals until the budget runs out is not retried — the poll
resolves to `timeout` instead of re-navigating or reloading the card
term: flutter_ready_indicator present & & flutter_loading_text absent
budget: --flutter-ready-timeout, default 15000ms; 45000ms for cold CI runners. Sizing rule
(measured max × 2) is `../../cc-dev/skills/job-timeout-budget/SKILL.md`
exhaust: classification = `timeout` (alias `not-rendered`) and it is **BLOCKING**. Not
screenshot-anyway (a spinner reads as `layout` = false block, or as `noise` = Pass
on an embed that never painted) and not skip-the-card (it would vanish from a CI
rule that only fails on typography/color/layout)
resume: < output > /variants/ < name > /classification.yaml — a recorded `timeout` is a result,
so the card is not re-polled without --force
log: one console line " ✗ Flutter not rendered after < n > ms — timeout (BLOCKING) " plus
flutter_ready_elapsed_ms / flutter_ready_outcome / flutter_ready_timeout_ms in
that card ' s classification.yaml
L-jaspr.match-card-foreach (step 4 — the card sweep)
inv: one card = one < output > /variants/ < name > / directory; all cards share the single
browser tab in sequence (no fan-out — §4 inline/sequential row)
prog: cardsRemaining = cardsFound - (cards-compared + cards-skipped), decreasing by 1
per iteration
no-prog: a card is never processed twice — its classification.yaml is the done marker
term: cardsRemaining == 0
budget: cardsFound iterations, exactly one pass per card, no retries × the per-card
--flutter-ready-timeout
exhaust: if the iteration count reaches cardsFound while cardsRemaining > 0 (the DOM grew
mid-sweep), do not re-enumerate — BLOCK and list the unprocessed card names in
verdict.md. A single card ' s failure is recorded as that card ' s classification and
the sweep continues (siblings continue, as in
`../../cc-dev/agents/sequential-workflow.md`)
resume: the set of classification.yaml files under < output > /variants/; re-running with the
same --output processes only the remaining cards (idempotent), --force redoes all
log: one console line per card (index/total + verdict) and the three counters
(cards-found / cards-compared / cards-skipped) at the endExample#
/jaspr-web match \
--url=https://coui.cocode.im/live-preview-poc \
--tolerance-preset=defaultConsole transcript (excerpt):
Mounted lazy cards (scrolled 4 viewports, back to top)
cards-found: 8 (--variants: all)
[Card 1/8] Button — Primary
✓ Web rendered (24x32+312x28)
✓ Flutter rendered (24x32+312x28) — first frame at 412ms
✓ Diff: 0.4% pixels, 0.0 ΔE — pass
[Card 2/8] Button — Outline
✓ Web rendered
✓ Flutter rendered
✗ Diff: 2.1% pixels, 3.2 ΔE — typography divergence (font-weight: 500 vs 600)
[Card 3/8] Button — Ghost → pass
[Card 4/8] Button — Destructive
✓ Web rendered
✗ Flutter not rendered after 15000ms — timeout (BLOCKING), no screenshot taken
...
Summary: 5 pass / 1 typography / 1 color / 1 timeout
Counters: cards-found: 8 · cards-compared: 8 · cards-skipped: 0
Report: .claude/docs/jaspr-web/match-20260512-1130/verdict.mdArtifacts#
.claude/docs/jaspr-web/match- < ts > /
├── variants/
│ ├── button_primary/
│ │ ├── A.png # Web panel clip
│ │ ├── B.png # Flutter panel clip
│ │ ├── diff.png # 3-up overlay
│ │ └── classification.yaml
│ └── ...
├── verdict.md # first line = the three counters
└── selectors-used.yaml # for repeatabilityclassification.yaml per card — the Flutter-ready poll's outcome is part of the record, not
just a console line:
variant: button_destructive
classification: timeout # typography | color | layout | noise | timeout | pass
blocking: true # timeout/typography/color/layout → true
flutter_ready_outcome: timeout # ready | timeout
flutter_ready_elapsed_ms: 15000
flutter_ready_timeout_ms: 15000
screenshots: [] # empty on timeout — no unconfirmed capture is diffedCI Wiring#
For a PR gate, fail the job when any classification is typography, color, layout, or
timeout. noise and pass are non-blocking. Raise --flutter-ready-timeout on cold
runners rather than accepting timeout results.
/jaspr-web match \
--url=$DEPLOY_URL/live-preview-poc \
--tolerance-preset=default \
--flutter-ready-timeout=45000 \
--output=$GITHUB_WORKSPACE/artifacts/match/
# The command itself exits non-zero — do not re-derive the verdict from the artifacts alone:
# cards-found == 0 → BLOCK (G-match.discovery)
# a --variants name with no card → BLOCK (G-match.variants)
# cards-compared == 0, or counters mismatch → BLOCK
# classification in {typography,color,layout,timeout} → BLOCK
# Then parse classification.yaml for the per-card report. An artifact dir with no
# variants/ subdirectories is a failed run, never a clean one.When not to use this#
- The page renders only one of (Web, Flutter) — use
/jaspr-web regressioninstead. - The Flutter side is in debug mode and you want to drive it interactively — use
/jaspr-web widgetbook-web.