LogoSkills

/jaspr-web match — 웹·플러터 화면 "다른 그림 찾기"

cocode live-preview 의 카드마다 웹 화면과 Flutter Web 화면을 픽셀 비교해 A/B/diff 이미지, 차이 원인 분류(글씨·색·배치·잡음·timeout), 종합 판정서를 폴더 하나로 남깁니다.

/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 로 적어 준 이름을 못 찾은 경우도 "차이 없음"이 아니라 실패입니다 — 비어 있는 판정서가 조용히 통과하는 것이 이 검사의 가장 위험한 고장이라서 그렇습니다.

안에서 무슨 일이 벌어지나요#

페이지를 열어 미리보기 카드를 하나씩 돌며 두 화면을 비교합니다.

  1. 페이지 열기 — 주소로 이동해 화면이 다 뜰 때까지 기다립니다.
  2. 카드 찾기 — 먼저 페이지를 한 화면씩 끝까지 훑어 내렸다가 맨 위로 돌아와, 아직 안 뜬 카드까지 다 그려지게 만듭니다. 그다음 미리보기 카드를 전부 찾아 카드마다 웹 패널과 플러터 패널을 짚습니다. 이때 찾은 카드가 0장이면 그 자리에서 멈춥니다 — 0장은 "차이 없음"이 아닙니다.
  3. 이미 끝난 카드 건너뛰기 — 결과 폴더에 그 카드의 분류 기록(classification.yaml)이 이미 있으면 다시 찍지 않고 이유를 남긴 채 넘어갑니다(--force면 다시 찍습니다).
  4. 준비 기다리기 — 카드를 화면에 띄우고, 플러터 쪽 "Loading Flutter..." 문구가 사라져 그릴 준비가 될 때까지(기본 최대 15초, --flutter-ready-timeout으로 조정) 기다립니다. 시간이 다 지나도 안 그려지면 억지로 사진을 찍거나 조용히 넘기지 않고 "안 그려짐(timeout)"으로 못 박아 검사를 막습니다 — 기다린 시간도 같이 기록합니다.
  5. 사진 찍기 — 웹 화면과 플러터 화면을 각각 캡처합니다(A.png, B.png).
  6. 차이 비교·분류 — 두 사진을 기준값으로 비교하고, 차이를 글씨·색·배치·잡음으로 분류한 뒤 겹친 비교 사진을 만듭니다.
  7. 종합 정리 — 모든 카드 결과를 모아 판정서(verdict.md)로 정리합니다. 찾은 카드 수가 비교한 카드 수 + 건너뛴 카드 수와 안 맞으면 그것도 실패로 봅니다.

⚙️ 상세 옵션·실행 명세 (개발자 / AI 에이전트용)

Parameters#

ParameterRequiredDescriptionDefault
--urlPage URL with dual previews
--variantsComma-separated variant names; omit for "all visible" (visibility decided after the forced-mount scroll). Any named entry that resolves to no card BLOCKSall
--tolerance-presetstrict / default / loosedefault
--selectorsPath to selector override YAMLbundled defaults
--maskCSS selector list to mask out before diffingnone
--outputOutput dir.claude/docs/jaspr-web/match-<ts>/
--flutter-ready-timeoutPer-card Flutter-ready poll budget in ms. Raise it for cold CI runners (45000)15000
--forceRe-capture cards that already have a classification.yaml; without it those cards are skipped as already-classifiedoff

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 --variants omitted = "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:HALT

cardsFound == 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.yaml exists is done and is skipped with reason already-classified unless --force is passed (--force re-captures and overwrites). This makes re-running with the same --output idempotent.

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 BLOCKS

Loop 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 end

Example#

/jaspr-web match \
  --url=https://coui.cocode.im/live-preview-poc \
  --tolerance-preset=default

Console transcript (excerpt):

Mounted lazy cards (scrolled 4 viewports, back to top)
cards-found: 8   (--variants: all)
[Card 1/8] ButtonPrimaryWeb rendered            (24x32+312x28)Flutter rendered        (24x32+312x28) — first frame at 412ms
  ✓ Diff: 0.4% pixels, 0.0 ΔE — pass
[Card 2/8] ButtonOutlineWeb rendered
  ✓ Flutter rendered
  ✗ Diff: 2.1% pixels, 3.2 ΔE — typography divergence (font-weight: 500 vs 600)
[Card 3/8] ButtonGhost  → pass
[Card 4/8] ButtonDestructiveWeb 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.md

Artifacts#

.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 repeatability

classification.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 diffed

CI 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 == 0BLOCK (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 regression instead.
  • The Flutter side is in debug mode and you want to drive it interactively — use /jaspr-web widgetbook-web.