design-md
Add a new design.md catalog entry to ko-design-md. Use this skill IMMEDIATELY when the user wants to onboard a new brand into THIS project's catalog — produce services/{slug}.md (Stitch v0.1 format) plus services/{slug}.tokens.json (token-card sidecar) plus public/preview/{slug}/
Install
npx skills add https://github.com/CaesiumY/ko-design-md/tree/main/.claude/skills/design-md
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install caesiumy-ko-design-md@llmmart
git clone https://github.com/CaesiumY/ko-design-md.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole caesiumy/ko-design-md collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
/design-md skill — orchestration body
This skill builds a complete catalog entry through a 5-subagent pipeline with one user checkpoint every entry hits (Stage 7), plus a conditional one ahead of it when a design board is the upstream (Stage 4c). The pipeline is heavy (research, drafting, two review loops) so resumability matters: each stage's artifact lives on disk in .claude/cache/design-md/{slug}/ and the next stage reads from there. State is encoded by file presence — no separate state.json needed for v1.
Pipeline shape
[INTAKE] → research-collector → design-md-author ⇄ design-md-reviewer (loop ≤3)
↓ score≥8 or N=3
[USER CHECKPOINT]
↓ approve
[WRITE_MD] → [TOKENS]
↓
preview-html-author ⇄ preview-html-reviewer (loop ≤3, non-blocking)
↓
[WRITE_PREVIEW]
↓
[BUILD_OG]
↓
[VERIFY]
↓
END
Loop termination: design loop is blocking — score must reach 8/10 within 3 iterations or the user decides at the checkpoint. Preview loop is non-blocking — proceed with warning if score < 8 at iteration 3.
Key reference files (read these before dispatching subagents that need them):
.claude/skills/design-md/references/stitch-format.md.claude/skills/design-md/references/design-md-template.md.claude/skills/design-md/references/rubric-design.md.claude/skills/design-md/references/rubric-preview.md
Stage 1 — Preflight
Verify the working environment before doing anything user-visible.
Bash:pwdto capture the absolute repo root. Hold this value as${repo_root}in your reasoning and substitute it literally into every later Bash command and dispatch prompt that touches a repo path. The shell preserves cwd across calls, but pinning the absolute path makes Stage 8/10/11 robust to any inadvertentcd.Bash:date +%Y-%m-%dto capture today's date. Hold this value as${today}in your reasoning. Stage 6a passes this to the author for both thelast_updatedandcreated_atfrontmatter fields — a brand-new entry is added and last-updated on the same day. The project's date validator atsrc/lib/content-parser.tsrejects any other format, andmissing-created-atinsrc/lib/draft-validator.tsblocks a draft that omitscreated_at.Read${repo_root}/package.json. If"name"is not exactly"ko-design-md", abort with: "이 스킬은 ko-design-md 레포 안에서만 동작합니다. 현재 디렉터리: $". Do not proceed.- Verify
${repo_root}/src/lib/content-types.tsis readable. If not, abort. Read${repo_root}/src/lib/content-types.tsand extract the liveCATEGORIESconst. Use this as the source of truth for the intake category picker (do NOT hardcode the enum from memory — it can drift).
Stage 2 — Conversational intake
Use a single AskUserQuestion form with these 3 questions (multi-select where indicated):
- 브랜드명 (text via "Other" → custom input): e.g. "토스", "당근", "구름". Use the Korean company/brand display name as it should appear in the
namefrontmatter, not the design system product name. If research later surfaces a distinct design system name (e.g. "SEED Design", "Vapor UI"), the author stores that in optionaldesign_system_name. - 참고 URL (text via "Other"): comma-separated URLs. 2개 이상 권장 — 1개만 입력 시 research-collector가 INSUFFICIENT_SOURCES로 중단할 수 있고, 그 경우 스크린샷 보강 필요. Brand homepage, design system page, blog post about their UI, etc.
- 카테고리 (single-select): all values from
CATEGORIESconst, in order. Last option isetc.
Do not ask for a language. An entry is one Korean design.md — lang is always ko (docs/adr/0001-korean-design-md-only.md), and every dispatch below passes it as a literal.
Then ask four follow-up text inputs:
스크린샷 경로 (optional) — comma-separated absolute paths to screenshot files. The user can type "없음" to skip.
로고 자산 경로 (optional) — an existing local file path for a brand logo. Accept only
.svg,.png,.webp, or.avif. The user can type "없음" to skip.CRITICAL — pick a small square symbol mark, NOT a wordmark. Two square slots in the site consume this asset: the catalog grid card (~48–96 px on screen) AND the OG image's top-left brand mark (32×32 px in the 1200×630 social card, see
src/og/template.tsx). The OG renderer (Satori) has limitedobject-fitsupport, so a non-square asset is stretched into the 32×32 box rather than letterboxed — the catalog card has the same constraint at its own scale. Choose accordingly:- ✅ Pick the brand's standalone symbol / mark / favicon shape with a transparent background — e.g. SOCAR's angular blue mark, Toss's curved oval lens, Gmarket's circular G, Baemin's symbol. Match the style of existing
public/logos/{toss,socar,baemin,…}.png(square, no text, no baked-in frame). - ❌ Avoid the horizontal wordmark / lockup (the brand name written out, e.g. "Gmarket", "toss", "쏘카") — wordmarks render too small in the grid card or break its aspect.
- ❌ Avoid iOS-squircle / framed app icons with a rounded gradient background baked in — that frame conflicts with the catalog card's own background. Prefer the unframed symbol form.
- When a bundle provides multiple variants (e.g.
logo-brand.pngwordmark vslogo-circular-g.pngsymbol vslogo-app-icon.pngframed), the unframed symbol is correct for the catalog grid. Filename hints for the GRID-WRONG forms:*wordmark*,*logotype*,*-brand*,*-horizontal*,*-app-icon*(framed). Filename hints for the GRID-RIGHT form:*-symbol*,*-mark*,*-circular*,*-icon*(when unframed), or a generic{slug}.pngthat is already a symbol.
Optional second asset — wordmark/logotype for the preview hero. The catalog grid uses the symbol, but the preview HTML hero (
public/preview/{slug}/preview.html) has room for a richer brand lockup with the brand name visible. If the source provides BOTH a symbol AND a horizontal wordmark/logotype, capture both paths. Stage 4a will place the wordmark atpublic/logos/{slug}-logotype.{ext}(matching the existingtoss-logotype.pngconvention), and the preview-html-author renders the wordmark in the hero where there is space. The grid card always uses the symbol; the wordmark has no frontmatter field — it stays a site-internal preview-only asset (the design.md'slogofrontmatter URL still points to the symbol so the file remains portable outside ko-design-md).- ✅ Pick the brand's standalone symbol / mark / favicon shape with a transparent background — e.g. SOCAR's angular blue mark, Toss's curved oval lens, Gmarket's circular G, Baemin's symbol. Match the style of existing
디자인 시스템 문서 사이트 URL (optional) — if the brand publishes its design system as a documentation website (not only Figma), the root URL of that site (e.g.
https://socarframe.socar.kr/). Stage 4b crawls it into a research corpus. The user can type "없음" to skip.디자인 보드 / 핸드오프 번들 경로 (optional) — comma-separated absolute paths to a Claude Design board's exported frames, or a directory the handoff bundle was already extracted into. The user can type "없음" to skip. This is what makes Stage 4c fire; without it there is nothing to approve and the run goes straight from Stage 4b to Stage 5. Ask for it even when the brand has a docs site — the two are different upstreams and an entry can have both. The link expires. A Claude Design handoff URL 404s in roughly fifteen minutes, so the user must have already saved it locally; do not accept a URL here.
Capture the answers as: brand_name, source_urls (parsed array), category, screenshot_paths (parsed array, may be empty), logo_asset_path (string or empty), docs_site_url (string or empty), design_board_paths (parsed array, may be empty).
Screenshot path preflight: for each path in screenshot_paths, run Bash: [ -f "$path" ]. If any path is missing, surface the missing list to the user and re-prompt the screenshot question. This avoids research-collector failing silently mid-read.
Logo path preflight: if logo_asset_path is not empty/없음, run Bash: [ -f "$logo_asset_path" ] and verify the extension matches svg|png|webp|avif. If missing or unsupported, surface the problem and re-prompt the logo question. Do not download logos from the web.
Stage 3 — Slug derivation + conflict resolution
Derive slug from brand_name:
- NFD-normalize and strip diacritics/non-ASCII.
- Lowercase, replace
[^a-z0-9]+with-, trim leading/trailing-. - If the result is empty (Korean-only brand with no Latin form), prompt the user via
AskUserQuestionfor an explicit slug. Question wording: "slug은 영문 소문자/숫자/하이픈만 가능합니다 (예:toss,karrot-market)." Validate the user's input matches^[a-z0-9-]+$; on mismatch, re-prompt.
Check for conflicts via Bash (ls services/{slug}.md 2>/dev/null):
- No conflict → proceed.
- Conflict →
AskUserQuestion:- "다른 slug 사용" → user provides a new slug, recheck.
- "기존 항목 업데이트" → set
mode = update. The pipeline still runs but final write overwrites. - "취소" → abort.
Stage 4 — Cache setup
Create the staging directory:
mkdir -p .claude/cache/design-md/{slug}
This directory holds all intermediate artifacts. It's gitignored (.claude/cache/ was added to .gitignore when the skill was installed) so partial work won't leak into PRs.
Stage 4a — Logo asset resolution
Before resolving paths — verify the logo asset is the right FORM. The catalog grid card uses a small square logo slot, so the chosen asset MUST be a symbol / mark / favicon shape (transparent background, no text), NOT a horizontal wordmark and NOT an iOS-squircle app icon with a baked-in background. When auto-picking from a bundle/zip that contains multiple variants, prefer filenames matching *-symbol*, *-mark*, *-circular*, or unframed *-icon*; reject filenames matching *wordmark*, *logotype*, *-horizontal*, *-brand* (often the wordmark), or *-app-icon* (often the iOS-squircle framed form). If only a wordmark variant is available, prompt the user to confirm before placing it — wordmarks are a known catalog-grid mismatch (see Stage 2's logo intake rule and the existing public/logos/{toss,socar,baemin,...}.png reference). This check applies inside step 1 below.
Resolve two logo values before dispatching author agents — different downstream concerns need different forms:
logo_url— fully-qualified URL likehttps://getdesign.kr/logos/toss.png. Goes into design.md frontmatter, where it must stay meaningful when the file is copied outside the ko-design-md site (PRD User Story 1 — vibe-coding flow).logo_src_path— site-relative path like/logos/toss.png. Goes into preview HTML<img src>, which is only ever loaded inside the catalog site's iframe. Keeping it relative avoids making dev/staging depend on the production-domain asset.
Both either co-exist (logo found) or are simultaneously empty (no logo).
The canonical site origin is https://getdesign.kr. Change this constant in one place only — this paragraph — if the origin ever moves.
- If
logo_asset_pathwas provided:- Verify it exists and has a supported extension (
svg,png,webp,avif). - If it already lives under
${repo_root}/public/logos/, setlogo_src_path = /logos/{basename}andlogo_url = https://getdesign.kr/logos/{basename}. - Otherwise copy it to
${repo_root}/public/logos/{slug}.{ext}and setlogo_src_path = /logos/{slug}.{ext}andlogo_url = https://getdesign.kr/logos/{slug}.{ext}. This is allowed only for user-supplied local logo assets.
- Verify it exists and has a supported extension (
- If no logo path was provided, auto-detect the first existing file in
public/logos/{slug}.{svg,png,webp,avif}(in that order) and setlogo_src_path = /logos/{slug}.{ext}andlogo_url = https://getdesign.kr/logos/{slug}.{ext}. - If nothing is found, set both to an empty string and continue. The entry may ship without a logo, but Stage 13 must report the missing logo TODO.
- Optional wordmark / logotype for the preview hero. If a wordmark variant was captured at Stage 2 (a horizontal lockup that contains the brand name as text — e.g.
logo-brand.png,*-logotype.svg), copy it to${repo_root}/public/logos/{slug}-logotype.{ext}and setlogo_wordmark_src_path = /logos/{slug}-logotype.{ext}. If no wordmark was captured at intake but a file already exists atpublic/logos/{slug}-logotype.{svg,png,webp,avif}, auto-detect it (same precedence order as the symbol). Otherwise setlogo_wordmark_src_path = "". There is NO frontmatter URL for the wordmark — it is a site-internal preview-only asset; the design.mdlogofield always references the symbol so the file remains portable outside ko-design-md.
The auto-detect pattern for the catalog grid logo is exactly public/logos/{slug}.{svg,png,webp,avif}; for the optional wordmark it is public/logos/{slug}-logotype.{svg,png,webp,avif}. When the symbol values are non-empty, every later stage must preserve them exactly — design-md-author writes logo_url verbatim into frontmatter, preview-html-author embeds logo_src_path as <img src> (or logo_wordmark_src_path in the hero when that is non-empty), and the Stage 10 grep checks match each file against the appropriate form.
Stage 4b — Docs-site crawl (conditional)
If docs_site_url is empty or "없음", skip this stage and set crawl_corpus_path = "none".
Otherwise, crawl the brand's documentation site into the cache directory so research-collector can use it as a primary source. This runs the docs-crawler skill's engine — a sitemap-driven crawl with a JS-render fallback that also localizes images (external and inline base64) into crawl/images/, so the cached corpus is self-contained:
cd "${repo_root}" && pnpm crawl:docs "${docs_site_url}" --out "${repo_root}/.claude/cache/design-md/{slug}"
The crawl writes crawl-corpus.md (the merged corpus) plus crawl/pages/*.md, the downloaded crawl/images/, and crawl/manifest.json into the (gitignored) cache directory. The first crawl of a JavaScript-rendered site auto-installs a headless browser (~150MB, one-time).
After it returns, verify the corpus landed:
[ -s "${repo_root}/.claude/cache/design-md/{slug}/crawl-corpus.md" ] && echo CORPUS_OK || echo CORPUS_MISSING
CORPUS_OK→ setcrawl_corpus_path = ${repo_root}/.claude/cache/design-md/{slug}/crawl-corpus.md.CORPUS_MISSING, or the crawl exited non-zero → the crawl failed. It is best-effort: research can still proceed fromsource_urls.AskUserQuestion: "문서 사이트 크롤 실패 — (a) 다시 시도 / (b) 크롤 없이 진행 / (c) 취소". On "다시 시도" re-run the crawl; on "크롤 없이 진행" setcrawl_corpus_path = "none"; on "취소" abort with the resume path.
Stage 4c — Design board checkpoint (conditional)
Run this stage when design_board_paths from Stage 2 is non-empty; otherwise skip it and go to Stage 5. That variable is the trigger — the condition is not a judgment call.
Preflight: for each path, Bash: [ -e "$path" ]. Surface any missing path and re-prompt, the same as the screenshot preflight. Then Read the frames (image paths read as images; a directory is listed and its files read).
Show what the board settled and what it left open: the palette in both themes, the type scale, the components it laid out, and anything it declined to define. Then AskUserQuestion: "디자인 보드를 확인해 주세요 — (a) 승인하고 리서치로 / (b) 보드를 고치고 다시 / (c) 취소". On (b), the user's corrections go back to the board; nothing downstream is generated until it is approved.
On approval, carry the board forward — the gate is worthless if the values stop here. Append the approved image files to screenshot_paths before Stage 5 dispatches, reusing the listing the preflight already produced: a path that was a directory is expanded to the files inside it, and the directory string itself is never appended. research-collector's contract is "an array of local image paths to read" and its tools are WebFetch, WebSearch, Read, Write — it has no Bash to list a directory, and Read cannot open one, so a directory handed over here is a source that silently never gets read. Without this step Stage 5 receives only source_urls and the crawl corpus, and the run rebuilds from public research alone while the approval implies otherwise.
This is what puts the board upstream of research.md, and through it upstream of the draft, the preview, the sidecar and the OG image.
The asymmetry is what justifies a second gate. Approving a wrong board costs the entire run, because every later artifact is rebuilt from it — and the Stage 7 checkpoint cannot recover it, since by then the draft has already transcribed the wrong values and reads as internally consistent. Approving a right one pays off at Stage 12: on remember the board's twenty role colors survived the whole chain into the preview unchanged, and the one discrepancy the comparison found was not a value but where a value had been applied — which no gate in this pipeline looks at.
An entry researched from public sources alone has no board to approve and goes straight to Stage 5.
Stage 5 — Research (research-collector)
Dispatch via Agent tool with subagent_type: "research-collector". Pass this prompt:
Research the brand "{brand_name}" (slug: {slug}) for ko-design-md catalog onboarding.
source_urls: {comma-separated URLs}
screenshot_paths: {comma-separated paths or "none"}
crawl_corpus_path: {crawl_corpus_path from Stage 4b — absolute path to crawl-corpus.md, or "none"}
category: {category}
lang: ko
cache_dir: {absolute path to .claude/cache/design-md/{slug}/}
Follow your agent definition. If crawl_corpus_path is not "none", read that corpus first as your primary source. Write exactly one file at {cache_dir}/research.md with the cited-claims structure. Halt with INSUFFICIENT_SOURCES only if crawl_corpus_path is "none" AND fewer than 2 URLs return 2xx.
After the agent returns, Read {cache_dir}/research.md.
- If the first line of
## SourcesisINSUFFICIENT_SOURCES, surface this to the user viaAskUserQuestionwith options: "URL 추가 입력" / "스크린샷 경로 추가" / "취소". On URL/screenshot addition, re-dispatch research-collector with the augmented inputs. - Section sanity check:
Bash:grep -c '^## ' {cache_dir}/research.md. Expected output is9(one per documented H2 section). If less than 9, the agent silently produced a malformed file — re-dispatch with an instruction prefixed: "Your previous research.md was malformed (only N sections found). Produce ALL 9 H2 sections in the documented order, even if some are(no public evidence found)." - Otherwise, proceed.
Stage 6 — Draft + design.md review loop
Iteration counter N = 1. Loop:
6a. Dispatch design-md-author
Via Agent with subagent_type: "design-md-author". Pass:
Author a Stitch v0.1-format design.md draft for "{brand_name}".
cache_dir: ${repo_root}/.claude/cache/design-md/{slug}/
slug: {slug}
name: {brand_name}
category: {category}
lang: ko
today: {today as YYYY-MM-DD}
logo_url: {logo_url or "none"}
research_path: ${repo_root}/.claude/cache/design-md/{slug}/research.md
prior_review_path: ${repo_root}/.claude/cache/design-md/{slug}/review-{N-1}.json or "none" on first pass
format_reference_path: ${repo_root}/.claude/skills/design-md/references/stitch-format.md
template_path: ${repo_root}/.claude/skills/design-md/references/design-md-template.md
demo_paths: (none — leave empty by default; pass an existing ${repo_root}/services/*.md only if a stylistic peer genuinely fits the new brand. The early _demo-*.md fixtures have been removed.)
Follow your agent definition. Write {cache_dir}/draft.md.
After return, verify {cache_dir}/draft.md exists and is non-empty. If missing, the author failed — log the issue, retry once with the same prompt; if still missing, abort with a diagnostic message.
6a2. Deterministic draft gate (machine validation)
Before spending a reviewer dispatch, run the draft validator — it covers every mechanically checkable rubric item (frontmatter round-trip, section presence/order, OKLCH-only token values, [src:N]/References integrity, expected logo) so the reviewer model never has to "grep mentally":
cd "${repo_root}" && pnpm validate:draft .claude/cache/design-md/{slug}/draft.md \
--slug {slug} --expected-logo {logo_url or none} --lang ko \
--iteration {N} --json-out "${repo_root}/.claude/cache/design-md/{slug}/review-machine-{N}.json"
- Exit 0 → proceed to 6b, passing the machine report path (see the 6b prompt).
- Exit 1 (block issues) → do NOT dispatch the reviewer. Re-dispatch 6a with
prior_review_path= thereview-machine-{N}.jsonabove (itsissues[]uses the sameseverity/section/fixshape the author already consumes). Machine retries use a sub-counter K (max 2) and do not increment N — machine fixes are cheap and must not consume the semantic-review budget. - K exhausted with blocks remaining → dispatch 6b anyway; the reviewer receives the failing machine report and the normal loop/checkpoint rules take over (no new termination path).
6b. Dispatch design-md-reviewer
Via Agent with subagent_type: "design-md-reviewer". Pass:
Score the draft.md at {cache_dir}/draft.md against the rubric.
cache_dir: {abs path}/.claude/cache/design-md/{slug}/
draft_path: {cache_dir}/draft.md
research_path: {cache_dir}/research.md
content_types_path: {abs path}/src/lib/content-types.ts
rubric_path: {abs path}/.claude/skills/design-md/references/rubric-design.md
expected_logo_url: {logo_url or "none"}
machine_report_path: {cache_dir}/review-machine-{N}.json
iteration_n: {N}
output_path: {cache_dir}/review-{N}.json
Follow your agent definition. Write exactly one file at output_path.
The machine report has already verified the deterministically checkable items
(frontmatter round-trip, section presence/order, hex/rgba token scan,
[src:N]/References integrity, expected logo). Do not re-verify those — spend
your review on judgment items: Brand fidelity semantics against research.md,
Voice/tone, and cross-section token contradictions.
After return, Read {cache_dir}/review-{N}.json.
6c. Loop decision
- If
review.passed && review.score >= 8→ exit loop, go to Stage 7. - Else if
N < 3→N += 1, go back to 6a (the author will read review-.json and revise). - Else (
N == 3and not passed) → exit loop with awarnflag; go to Stage 7. The user will see the failed verdict at the checkpoint and decide.
Stage 7 — User checkpoint
This is the gate every entry passes through — Stage 4c precedes it only when a design board is upstream. Show the user:
- The current
draft.mdcontent (read it and display the full file inline, formatted as markdown — paste in code fences). - The latest
review-{final}.jsonverdict — extractscore,passed,verdict, and bullet the issues array. - If iteration > 1, show a brief diff highlight:
"Iter 1 score: X → Iter {final} score: Y"plus the top 1–2 issues that improved between iterations (comparereview-1.json.issuesandreview-{final}.json.issues).
Then AskUserQuestion:
| Option | Effect |
|---|---|
| "승인하고 계속" | Approve as-is. Proceed to Stage 8. |
| "수정 사항 알려주고 한 번 더" | User provides feedback in the "Other" custom input. Append the user's notes to the prior review-N.json's issues array (with severity: block) and re-dispatch the author for one more revision. After this extra revision, run the reviewer once more, then return to checkpoint with the new draft. |
| "취소" | Abort. Cache dir is left intact. Print: "취소되었습니다. 재개하려면 cache 디렉터리에서 작업을 이어가세요: .claude/cache/design-md/{slug}/" |
Stage 8 — Write design.md to services/
After approval:
Bash:cp ${repo_root}/.claude/cache/design-md/{slug}/draft.md ${repo_root}/services/{slug}.mdReadthe placed file to confirm content arrived intact.- Generate the token sidecar —
Bash:pnpm tokens:build {slug}extractsservices/{slug}.tokens.jsonfrom the design.md you just placed. This is the visual design-token data (colors / typography / spacing / radius / elevation) that drives the detail page's Tokens tab (src/routes/services/$slug.tsx:257);src/lib/content-collection.tsloads it asdoc.tokens(runtime is a plainJSON.parse, no markdown parsing). Inspect the printedNc Nt Ns Nrline (a trailingNeappears when## Elevation & Depthpublishes shadow values). The extractor reads the frontmatter token maps (colors:/typography:/spacing:/rounded:) and markdown tables, one token per line —name: oklch(...)(colors),name: 16px(spacing/rounded). Typography is the exception: it nests. A bare style name on its own line, then four-spacefontSize/fontWeight/lineHeight/letterSpacing. The inlinename: { size, weight, line-height }andname: 16 / 24 / 700forms are read only from markdown tables and legacy body fences — written into frontmatter they yield zero type tokens, andtokens:checkthen agrees with the empty sidecar it generated from them. Semantic aliases ({colors.x}, bare references likefill-brand: blue-500) are intentionally excluded — they stay in the prose only. If any of the four counts is unexpectedly0, that## Colors / Typography / Spacing / Roundedsection isn't in a codegen-readable form.Neis exempt from that rule — it is absent whenever the entry's Elevation section carries usage labels or z-indices instead of shadow values (bezier and class101 are both legitimately shadow-less), so a missingNeis only a signal when you authored realbox-shadowvalues there. The deterministic path is to route back to a Stage 6 draft revision with a blocking prior-review issue naming the unreadable section (a human operator running the skill by hand may instead fix the section directly), then re-runpnpm tokens:build {slug}so the entry ships with full token cards.
If the cp itself fails (filesystem error), surface the error and route back to the checkpoint.
Stage 9 — Preview HTML author + review loop
Iteration counter M = 1. Same shape as Stage 6, dispatching preview-html-author and preview-html-reviewer.
9a. Dispatch preview-html-author
Build preview.html for "{brand_name}".
cache_dir: {abs path}/.claude/cache/design-md/{slug}/
slug: {slug}
name: {brand_name}
lang: ko
design_md_path: {abs path}/services/{slug}.md
runtime_tokens_path: {abs path}/public/preview/_runtime/tokens.css
runtime_iframe_path: {abs path}/public/preview/_runtime/iframe.js
logo_src_path: {logo_src_path or "none"}
logo_wordmark_src_path: {logo_wordmark_src_path or "none"}
demo_html_paths: (none — leave empty by default; pass an existing {abs path}/public/preview/*/preview.html only if a visual peer genuinely fits. The early demo-courier/demo-pay previews have been removed.)
prior_review_path: {cache_dir}/preview-review-{M-1}.json or "none"
Follow your agent definition. Write {cache_dir}/preview.html. If `logo_wordmark_src_path` is not "none", render that wordmark `<img>` in the hero brand lockup (the hero has room for the brand name) and reserve `logo_src_path` (the small symbol) for compact references inside the component showcase, favicons, or chip-sized contexts. If `logo_wordmark_src_path` is "none", use `logo_src_path` in the hero too. Size hero `<img>` by `height` + `width: auto` so either aspect ratio (square symbol or horizontal wordmark) renders correctly.
9a2. Deterministic preview gate (machine validation)
Same shape as 6a2 — run the preview validator before spending a reviewer dispatch:
cd "${repo_root}" && pnpm validate:previews \
--preview .claude/cache/design-md/{slug}/preview.html \
--design-md "${repo_root}/services/{slug}.md" \
--expected-logo-src {logo_src_path or none} \
--expected-wordmark-src {logo_wordmark_src_path or none} \
--iteration {M} --json-out "${repo_root}/.claude/cache/design-md/{slug}/preview-review-machine-{M}.json"
It hard-checks the structural rubric items (data-theme/lang, absolute runtime paths, foreign scripts, transfer size, hero logo src, catalog disclosure strip) plus two content rules that used to be reviewer judgment — a swatch catalog (fill-only elements per theme, Item 2) and a type-scale showcase (design.md typography token names printed as visible text labels, Item 3). Size is weighed in brotli bytes, the form Vercel actually serves, with a raw cap kept only as a safety net against runaway generated markup. It also emits warn-level responsive heuristics plus an oklch coverage metric (matched/total per theme) in metrics.
- Exit 0 → proceed to 9b, passing the machine report path.
- Exit 1 → do NOT dispatch the reviewer. Re-dispatch 9a with
prior_review_path= thepreview-review-machine-{M}.json. Machine retries use a sub-counter K (max 2) and do not increment M. - K exhausted with blocks remaining → dispatch 9b anyway; the normal non-blocking loop rules take over.
9b. Dispatch preview-html-reviewer
Score the preview HTML files at {cache_dir} against the rubric.
cache_dir: {abs path}/.claude/cache/design-md/{slug}/
preview_path: {cache_dir}/preview.html
design_md_path: {abs path}/services/{slug}.md
rubric_path: {abs path}/.claude/skills/design-md/references/rubric-preview.md
expected_logo_src_path: {logo_src_path or "none"}
machine_report_path: {cache_dir}/preview-review-machine-{M}.json
iteration_n: {M}
output_path: {cache_dir}/preview-review-{M}.json
Follow your agent definition. Write exactly one file at output_path.
The machine report has already verified the structural Item 1 checks
(data-theme, absolute runtime paths, foreign scripts, transfer size, hero
logo src, catalog disclosure strip). Do not re-verify those — adopt the
report's result for Item 1. It also carries two content blocks: a swatch
catalog forces Item 2 to 0 and a type-scale showcase forces Item 3 to 0,
adopted wholesale on the same rule and mirrored into `issues` as `block`.
Spend the rest of your review on judgment: Color fidelity semantics (use the
report's oklch coverage metric as the Item 2 input), Component coverage,
Typography hierarchy, and dark-mode appropriateness.
9c. Loop decision (non-blocking)
- If
passed && score >= 8→ exit loop, go to Stage 10. - Else if
M < 3→M += 1, go back to 9a. - Else (
M == 3and not passed) → log the warning, exit loop, go to Stage 10 anyway. Preview review is non-blocking because visual previews iterate naturally during real use; the user already approved the design.md the preview is built from.
Stage 10 — Write previews to public/
mkdir -p ${repo_root}/public/preview/{slug}
cp ${repo_root}/.claude/cache/design-md/{slug}/preview.html ${repo_root}/public/preview/{slug}/preview.html
Logo deterministic check
If the resolved logo values are non-empty, verify the placed main markdown contains the absolute URL form and the preview HTML contains the site-relative form:
# Markdown: frontmatter `logo` is the symbol's absolute URL (portable across copies of the file).
rg -q -F "logo: {logo_url}" "${repo_root}/services/{slug}.md" || echo "LOGO_MISSING_MD"
# Preview HTML hero src: when the wordmark exists, the hero uses the wordmark; otherwise the symbol.
# `{logo_wordmark_src_path}` is the literal string "none" when no wordmark was captured —
# `${var:-fallback}` would treat "none" as a non-empty value and skip the fallback, so use an
# explicit conditional that handles both "none" and the empty case.
HERO_SRC="{logo_wordmark_src_path}"
if [ "$HERO_SRC" = "none" ] || [ -z "$HERO_SRC" ]; then
HERO_SRC="{logo_src_path}"
fi
rg -q -F "src=\"${HERO_SRC}\"" "${repo_root}/public/preview/{slug}/preview.html" || echo "LOGO_MISSING"
If any sentinel prints, do not proceed to Stage 11. If the markdown is missing the logo, re-run Stage 6a with a blocking prior-review issue that says logo_url must appear as frontmatter logo (the exact fully-qualified URL — not a site-relative shortcut). If the preview is missing the hero logo, re-run Stage 9a with a blocking prior-preview issue that says the exact HERO_SRC (site-relative form — wordmark when defined, else symbol) must render as an <img src> in the shared markup — not inside a theme variant.
Catalog disclosure deterministic check
validate:previews already blocks on this at 9a2, so reaching here with a missing strip means the file changed after the gate. Re-check the placed file:
F="${repo_root}/public/preview/{slug}/preview.html"
rg -q -F 'class="catalog-disclaimer"' "$F" || echo "DISCLAIMER_MISSING"
rg -q -F '제휴·후원 관계가 없습니다' "$F" || echo "DISCLAIMER_NO_NONAFFILIATION"
rg -q -F '더미 데이터' "$F" || echo "DISCLAIMER_NO_DUMMYDATA"
rg -qU '<body>(\s|<!--[\s\S]*?-->)*<div class="catalog-disclaimer"' "$F" || echo "DISCLAIMER_MISPLACED"
-U (multiline) is required for the placement check — the strip sits on the line after <body>, so a line-scoped match never sees both. The comment alternation matches DISCLAIMER_FIRST_CHILD in src/lib/preview-validator.ts: a comment between <body> and the strip is still a strip-first document, and flagging it here would send Stage 9a back to fix markup that 9a2 already passed.
These sentinels are deliberately stricter than the validator on one axis: quoting. preview-validator.ts accepts class='catalog-disclaimer' because it also validates hand-edited and pre-existing files. Stage 10 re-checks pipeline output, where .claude/agents/preview-html-author.md prescribes the strip byte for byte with double quotes — so -F on the verbatim form is the point, not an oversight. A single-quoted strip reaching here means the author deviated from a verbatim instruction, which is worth a Stage 9a round. Do not "fix" this by loosening it to match the validator.
The two phrase sentinels are literal (-F) for the same reason: the non-affiliation sentence is shared verbatim with src/components/site/footer.tsx and pinned by src/lib/license-notice-consistency.test.ts.
If any DISCLAIMER_* sentinel prints, do not proceed to Stage 11. Re-run Stage 9a with a blocking prior-preview issue quoting the verbatim strip from .claude/agents/preview-html-author.md and stating it must be the first child of <body> — in the shared markup, not inside a theme variant.
Preview token alias registration
The moment the previews land in public/, the OKLCH drift gate starts expecting this slug to be accounted for. Run it:
cd "${repo_root}" && pnpm test src/lib/oklch-drift-corpus.test.ts
That gate compares each preview's --custom-property: oklch(…) declarations against the name: oklch(…) definitions in services/{slug}.md, by exact name. Previews almost always namespace their variables (--tds-blue-500 for md blue-500), so each slug declares a rewrite rule in PREVIEW_TOKEN_ALIASES (src/lib/oklch-drift.ts). Without one, every declaration in this preview is unreachable and the gate silently checks nothing for the new entry — which is what the corpus test refuses to let happen.
Stage 9a2's oklch coverage metric does not cover this. It searches the HTML for the design.md's OKLCH values as substrings; it never looks at custom-property names, and src/lib/preview-validator.ts does not consult the drift gate at all. A fully namespaced preview can score 100% coverage at 9a2 and still match zero declarations here.
And no gate here looks at WHERE a value is painted. The drift gate compares a preview's
--custom-property: oklch(…) declaration against the md's definition of the same name.
pnpm audit:oklch compares an OKLCH literal against the hex annotated beside it. Both answer
"is this value right?"; neither answers "is it on the right element?" — var(--x) usage sites
are not scanned by either. remember declared its surface tokens correctly (light 10 and dark 10
matched the design boards 20/20) and painted one of them on six cards where the research had
observed a single 550px column, at radius-md where the observation said 4px. Every gate was
unanimous: audit:oklch 0 mismatched, the drift gate matched, validate:previews --slug remember
0 blocking 0 warning. Checking the application site means opening the research cache or the brand
publication and comparing element by element — the Stage 12 board cross-check's work and a
human's, not a gate's. Do not read a clean audit:oklch as "the preview is faithful".
Three rule shapes are in use, all measured against real entries:
| md name | preview name | rule |
|---|---|---|
blue-500 |
--tds-blue-500 |
["", "tds-"] — prepend |
ldsg-color-linegreen |
--ldsg-linegreen |
["ldsg-color-", "ldsg-"] — replace |
gray0 |
--g-gray-0 |
[["gray", "g-gray-"], ["", "g-"]] — one family differs from the rest |
Rules are tried in order, first match wins, and an empty from matches anything so it belongs last.
If the test fails with these slugs have no entry in MATCH_FLOOR, add the rule, then record the count the failure message prints into MATCH_FLOOR (src/lib/oklch-drift-corpus.test.ts) — that table is a per-slug floor, so it also has to be raised deliberately rather than guessed. Recording a bare 0 is refused: only bezier is entitled to it, because its preview is hex rather than OKLCH and no naming rule can reach it.
Both files are outside this skill's write scope, so this does not route back to Stage 9a — a human operator running the skill by hand makes these two edits directly. Skipping them does not corrupt the entry; it leaves the drift gate blind to it, and CI fails on the pull request rather than here.
Token coverage row
The same moment creates a row this entry owes to src/lib/token-coverage.test.ts. Run it:
cd "${repo_root}" && pnpm test src/lib/token-coverage.test.ts
That file pins, per entry, how many name: oklch(…) definitions each token gate can see — exact in both directions, because a count that rises can mean a reader widened, not that tokens were added. A new entry fails it with these entries have no row in TOKEN_COVERAGE, and the message prints the row itself: paste that line into TOKEN_COVERAGE at its sorted position, then read it. drift: 0 is refused (the drift gate would have no md-side name to compare for the whole entry — fix the frontmatter token map instead), while annotated: 0 can be right (an entry that comments its colours in prose with no hex, as baemin and toss do). Nothing else in the repo prints these numbers, so do not guess them. A row per entry is what lets two catalogue pull requests be open at once without the second one failing on the first one's merge (#324).
This file is outside the skill's write scope too: the operator makes the edit by hand, next to the MATCH_FLOOR row above.
The rest of the per-slug rows
Four more tables owe this entry a row, and none is reachable from this stage — each fails in CI on
someone who never saw it. CLAUDE.md 「카탈로그 정책」 carries the full list; the ones not already
covered above are:
BASELINE_TABLE(src/lib/contrast-baseline.ts) — four rows per slug, mirrored byte for byte intodocs/preview-contrast-baseline.md.pnpm gate:contrastprints the rows on failure, and it drives a browser, so CI is the authority on the numbers.- The
NOTICEasset inventory — any file Stage 4a placed underpublic/logos/owes a row.license-notice-consistency.test.tscompares both directions. - The missing-primary list (
src/lib/google-designmd-corpus.test.ts) — only when the entry has no token literally namedprimary. Do not invent one to avoid the row. KNOWN_SPEC_LIMITATIONS(same file) — only when the entry publishes a%radius or a multi-stop gradient, the two places the catalog is more expressive than the published spec. It is a two-way ratchet on the slug's error count, so a later silent fix fails it too. Record the count; do not flatten the value to satisfy the linter.
All are outside this skill's write scope: the operator edits them by hand, the same as the two above.
Stage 11 — Build OG image
cd "${repo_root}" && pnpm build:og
After the command:
If exit non-zero: capture stderr. Likely cause is frontmatter that buildDoc in src/lib/content-parser.ts rejects after slipping past the 6a2 gate (e.g. a hand edit at the Stage 7 checkpoint): a last_updated / created_at that is not a real YYYY-MM-DD date, or an estimated_tokens that is not a number. An off-enum category does not fail build:og (buildDoc falls back to etc); once the draft is past 6a2, only pnpm validate:catalog catches it, so run it before finishing. Surface stderr via text. Offer via AskUserQuestion: "frontmatter 직접 수정 후 재시도" (open ${repo_root}/services/{slug}.md for editing; on user confirmation that they've edited, re-run pnpm build:og and re-validate — loop up to 3 retries), "취소 (파일 유지)" (partial state is acceptable since the index works without an OG image — the route falls back per build-og.ts). Do NOT auto-rollback the placed .md. After 3 failed retries, fall through to "취소" with a diagnostic message.
If exit zero: validate the OG output (catches the corrupt-PNG silent failure mode that happens on satori panic):
[ -s "${repo_root}/public/og/{slug}.png" ] || echo "OG_EMPTY"
file "${repo_root}/public/og/{slug}.png" | grep -q PNG || echo "OG_NOT_PNG"
If either check prints its sentinel, treat as the same error path as a non-zero exit (surface, ask the user, do not auto-rollback).
Stage 12 — Verification (preview MCP)
Start the dev server and confirm the new entry renders correctly. This is the strongest end-to-end check.
Port preflight:
Bash:lsof -i :3000 -t 2>/dev/null. If the output is non-empty, port 3000 is already in use (the user has a dev server running).AskUserQuestion: "포트 3000이 사용 중입니다 — (a) 기존 서버 종료 후 재시작 / (b) 검증 단계 건너뛰기 / (c) 취소". On "건너뛰기", skip to Stage 13 with averification_skipped: port_collisionflag in the report.Bash(run_in_background):cd "${repo_root}" && pnpm dev— runs on port 3000.Server readiness poll:
Bash:for i in $(seq 1 30); do curl -sf http://localhost:3000 -o /dev/null && echo READY && break; sleep 0.5; done. The dev server takes a few seconds to bind; without this poll,preview_startmay hit a connection refused before Vite is up. If the loop completes without printingREADY, fall through to thecurlfallback at the end of this stage.mcp__Claude_Preview__preview_startwith URLhttp://localhost:3000/services/{slug}.mcp__Claude_Preview__preview_eval:document.title— should contain{brand_name}andko/design.md. Then confirm the token-card section loaded from the Stage 8 sidecar:preview_eval:document.querySelector('[aria-label="Design tokens"]')?.querySelector('p')?.textContent ?? 'MISSING'— should return the count badge ({N} Colors · {N} Type · …).MISSINGmeansservices/{slug}.tokens.jsonis absent or failedcoerceServiceTokens; verify it exists and is valid JSON before continuing.preview_evalagainst the iframe: confirmdocument.querySelector('iframe')?.srccontains/preview/{slug}/preview.html, and that the iframe document'sdata-themematches the route default (src/routes/services/$slug.tsx:97) — one file serves both themes now.preview_screenshotonce on the default tab.preview_eval: navigate to?tab=mdand confirm DESIGN.md tab renders the syntax-highlighted markdown.preview_screenshotonce on the?tab=mdview.Agent endpoint check:
Bash:curl -sf -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:3000/services/{slug}/llms.txt— expect200 text/plain; charset=utf-8. This raw-markdown sibling route reads fromservices/{slug}.mddirectly, so a failure here means either the file wasn't placed correctly or the project's/services/$slug/llms.txtroute regressed. Surface non-200 output to the user before stopping the server.Responsive sweep (mobile / tablet / desktop) — confirm the preview demo doesn't break at narrow widths (the regression class hotfixed in PR #77). Load each demo top-level, not through the width-constrained detail-page iframe. Sweep both themes of the one file: load it, then run the probe once with
data-theme="light"and once with"dark". They share layout, but PR #77's lesson is that a fix can miss one theme, and the two scopes can still size text differently.preview_eval:window.location.href = "http://localhost:3000/preview/{slug}/preview.html", and set the theme withdocument.documentElement.setAttribute('data-theme', '{theme}')before each probe, then at 375 (mobile) / 768 (tablet) / 976 (detail-page embed width — the historical blind spot where multi-column cells are tightest, see PR #150) / 1440 (desktop) × 900 runmcp__Claude_Preview__preview_resizeto{width} × 900followed bypreview_evalof the overflow probe:(() => { const d = document.documentElement; const vw = d.clientWidth; const overflowPx = d.scrollWidth - vw; const broken = overflowPx > 1; // Collect culprits only when the document actually overflows, AND skip any element // inside an overflow-x: hidden/auto/scroll ancestor: an intentional horizontally- // scrollable row (chip row, carousel) reports right > vw without extending the // document, so it is not the break even when another element broke the page. const culprits = !broken ? [] : Array.from(document.querySelectorAll('body *')) .filter(el => { const r = el.getBoundingClientRect(); if (r.right <= vw + 1 && r.left >= -1) return false; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const ox = getComputedStyle(p).overflowX; if (ox === 'hidden' || ox === 'auto' || ox === 'scroll') return false; } return true; }) .slice(0, 6) .map(el => { // getAttribute('class') works for SVG too (el.className is an SVGAnimatedString there). const cls = (el.getAttribute('class') || '').trim(); return el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\s+/).slice(0, 2).join('.') : ''); }); return { viewport: vw, scrollWidth: d.scrollWidth, overflowPx, broken, culprits }; })()broken = overflowPx > 1(1px tolerance) means a horizontal scrollbar — a broken responsive layout. The check keys on the documentscrollWidth, so an element inside an intentional horizontally-scrollable row (a chip row, a carousel) does not trip it.culprits(collected only whenbroken, and excluding any element nested in anoverflow-xscroll/clip ancestor) lists the genuinely page-extending elements (e.g..comp-card,.ftile) so the fix can be targeted. Collect everybrokenresult asbreaks[] = {file, width, overflowPx, culprits}.Auto-fix loop (≤ 2 attempts) when
breaksis non-empty — reuses the Stage 9a dispatch and the Stage 10 copy, so there is no new mechanism:- Write a synthetic review at
{cache_dir}/preview-review-resp-{attempt}.jsonwhoseissues[]carries oneseverity: "block"entry per break:{"severity":"block","section":"responsive — {theme} @{width}px","fix":"Horizontal overflow {overflowPx}px at {width}px. Offending: {culprits}. Repair per the author's Responsive & mobile-overflow guard — the usual root cause is a bare 1fr grid track flooring at its content min-content, so switch content tracks to minmax(0, 1fr), add a mobile grid-collapse @media rule, and put min-width: 0 on items wrapping fixed-width children. A layout fix belongs in the shared rules, not duplicated per theme scope."}. - Dispatch
preview-html-authorexactly as in Stage 9a, withprior_review_path= that JSON. The author rewrites{cache_dir}/preview.html. - Re-copy staging → public with the Stage 10
cpcommands (the Stage 10 logo deterministic check still applies). preview_eval:window.location.reload()(the/preview/*no-cacheheader serves the fresh file), then re-run the sweep. Stop whenbreaksis empty or after 2 attempts.
Result: if
breaksis empty →responsive_result = ok(record the attempt count). If still non-empty after 2 attempts →preview_screenshotat the narrowest failing width for evidence and setresponsive_result = warnwith the residualbreaks. Non-blocking either way (consistent with the non-blocking preview loop). The OG image is derived from design.md, so re-fixed previews do not trigger an OG rebuild. (The port-collision path never reaches this step — it returns to Stage 13 at step 1 — so handling for that case lives in the Stage 13 report, not here.)- Write a synthetic review at
Design-board cross-check (conditional) — run when
design_board_pathsfrom Stage 2 is non-empty; otherwise setboard_result = skipped (no board)and go to step 13. Stage 4c approved the board before anything was generated; this is the other half of that gate — the finished preview measured back against it. The question is not whether the values are right.pnpm audit:oklchand the drift gate already answered that, and onrememberthey answered it correctly: the board's twenty role colors reached the preview 20/20 unchanged. The question is where each value landed, which no gate in this pipeline asks — see Preview token alias registration in Stage 10, where that blind spot is stated againstaudit:oklchand the drift gate.Readthe board frames fromdesign_board_pathsagain. The preview is still loaded top-level from step 11;preview_resizeback to1440 × 900and run this probe once per theme, settingdata-themethe same way step 11 does:(() => { const seen = new Map(); for (const el of document.querySelectorAll('body *')) { const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) continue; const cs = getComputedStyle(el); for (const prop of ['backgroundColor', 'color', 'borderTopColor']) { const v = cs[prop]; if (!v || v === 'transparent' || v === 'rgba(0, 0, 0, 0)') continue; const key = prop + ' ' + v; const bucket = seen.get(key) ?? []; const cls = (el.getAttribute('class') || '').trim(); bucket.push({ el: el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\s+/).slice(0, 2).join('.') : ''), w: Math.round(r.width), h: Math.round(r.height), radius: cs.borderRadius, }); seen.set(key, bucket); } } return [...seen] .map(([k, v]) => ({ k, count: v.length, sample: v.slice(0, 3) })) .sort((a, b) => b.count - a.count); })()Buckets are keyed on the serialized computed string, whatever the browser emits for it — Chromium keeps
oklch()colors in their own space rather than converting torgb(), and that format is not stable across versions. You are matching buckets to board tokens by reading them, not by string-comparing against the md, so the format does not matter.For each token the board defines, check three things against the board frame and
{cache_dir}/research.md: which element carries it, how many of them, and that element's radius and box size. A discrepancy is a token whose bucket disagrees on any of the three. That is exactly the shape of the only thing this comparison has ever caught — onrememberone surface color was painted on six cards where the research had observed a single 550px column, atradius-mdwhere the observation said 4px, while every gate was green.Record, do not auto-fix. Set
board_result = okwhen no bucket disagrees, orboard_result = discrepanciescarrying one line per finding ({token} — 보드/관측: {expected}, 프리뷰: {observed}). Do not route back into the Stage 9a loop the way the responsive sweep does: that loop's input is a layout rule the author can apply blind, whereas this finding is a judgment about a brand publication the author cannot see, and a wrong auto-fix repaints a correct value onto a different wrong element. Stage 13 reports it and the person decides.Stop the dev server:
Bash:kill $(lsof -t -i:3000) 2>/dev/null || true. Killing by port is portable across macOS/Linux and avoids accidentally killing otherpnpmprocesses the user might be running. The|| truekeeps the skill from aborting if the process already exited.
If preview MCP tools are unavailable, fall back to Bash: curl -sf http://localhost:3000/services/{slug} | grep -q '<iframe' — non-zero exit means the page failed to render. The responsive sweep (step 11) requires the preview MCP tools; without them, note responsive_result = skipped (no preview MCP) in the report, and the board cross-check (step 12) the same way as board_result = skipped (no preview MCP).
Stage 13 — Final report and cleanup
Print a summary message containing:
Files written (with absolute paths):
services/{slug}.mdservices/{slug}.tokens.json(visual design-token sidecar → detail-page card view)public/preview/{slug}/preview.htmlpublic/og/{slug}.png(frompnpm build:og)
Surfaced URLs (paths only — host depends on env):
/services/{slug}— HTML detail page (Live Preview · Tokens · DESIGN.md tabs; the Tokens tab appears only when the sidecar carries tokens)./services/{slug}/llms.txt— rawtext/plaindesign.md (frontmatter + body) for LLMs / agents to fetch directly. Discoverable via<link rel="alternate" type="text/plain">on the HTML page.
Final review scores: design
{score}/10, preview{score}/10.Screenshots taken during verification (paths or inline).
Responsive verification (Stage 12 sweep) — pick the line by state:
responsive_result = ok→반응형: ✅ 375/768/976/1440 가로 오버플로 없음 (자동수정 {attempts}회)responsive_result = warn→반응형: ⚠️ 잔여 오버플로 — {file} @{width}px {overflowPx}px, 요소 {culprits} (스크린샷 {path}, 자동수정 2회 후 잔존)- skipped — set when
verification_skipped: port_collision(step 1 returned early, soresponsive_resultwas never assigned) orresponsive_result = skipped(preview MCP unavailable) →반응형: ⏭ 검증 건너뜀 (포트 충돌 / preview MCP 없음)
Design-board cross-check (Stage 12, after the responsive sweep) — pick the line by state:
board_result = ok→보드 대조: ✅ 보드 토큰의 적용 위치·개수·형상이 관측과 일치board_result = discrepancies→보드 대조: ⚠️ 적용 위치 불일치 {N}건 — {finding 한 줄씩}board_result = skipped (no board)→보드 대조: ⏭ 보드 없음 — 공개 자료만으로 만든 항목이라 대조할 상류가 없습니다board_result = skipped (no preview MCP), or step 1 returned early onverification_skipped: port_collisionsoboard_resultwas never assigned →보드 대조: ⏭ 검증 건너뜀 (포트 충돌 / preview MCP 없음)
No two of the four may print the same line — that is what the contract test pins. Three of them are where it actually happens:
discrepanciescarries a ⚠️ and is never mistaken for silence, while the other three all look like nothing-to-see. "보드가 없었다" · "대조했고 어긋난 것이 없었다" · "대조가 아예 안 돌았다" 는 서로 다른 사실이고, 이 저장소에서 반복적으로 같은 침묵으로 보고돼 왔다. 특히skipped를 침묵으로 처리하면 사람은 초록으로 읽는다.Leftover TODOs:
- If the logo values are empty: "Logo asset:
public/logos/{slug}.svg|png|webp|avif가 아직 없습니다. 직접 추가한 뒤 frontmatterlogo: https://getdesign.kr/logos/{slug}.{ext}(절대 URL, 외부 복사 대비) 를 채우고 preview HTML에는<img src=\"/logos/{slug}.{ext}\">(site-relative, iframe 전용) 형식으로 렌더링하세요." - Every
warnin the final preview review, whatever the score. The rubric's three advisory sections —Mobile overflow,Dummy-data labelling,Explanatory prose— add no points by design, so a preview can carry all of them and still pass 9c'sscore >= 8on the first iteration and exit without the author ever seeing the review. Reporting them only when iteration 3 fell short drops them in exactly the case they exist for:rememberscored 10/10 with 61% of its rendered text restating the design.md. List each one'ssectionandfixverbatim. If the list is empty, say so — an absent line reads as "none found" whether or not the check ran.
- If the logo values are empty: "Logo asset:
What is left for the person to look at. Close the report by saying the loops are already done — the draft gate and review (6a2/6b), the preview gate and review (9a2/9b) and the Stage 12 sweep have all run — and then name what they do not cover, so the user spends their pass on the residue instead of re-checking what a machine just checked. On
rememberevery gate was green when the user found four things: the preview was more than half explanat
Files (ko-design-md)
-
references
-
design-md-template.md 9.2 KB
# design.md 스켈레톤 `services/{slug}.md` 를 새로 만들 때 통째로 복사해 채우는 뼈대다. 규칙의 근거와 예외는 [`stitch-format.md`](./stitch-format.md) 가 갖는다 — 이 문서는 **무엇을 어느 순서로 쓰는지**만 보여 준다. 둘이 어긋나면 `stitch-format.md` 가 정본이다. 채우고 나면 `pnpm validate:draft <file> --slug <slug>` 로 확인한다. ## Google DESIGN.md 정규명 대응 카탈로그 섹션 순서는 Google 이 발행한 DESIGN.md 명세 (`github.com/google-labs-code/design.md`, 버전 `alpha`) 의 정규 순서와 어긋나지 않는다. 명세의 8개 섹션은 **전부 optional** 이고, 순서 검사도 명세가 아는 섹션만 추려서 보므로 카탈로그 고유 섹션이 사이에 끼어도 문제가 없다. | 카탈로그 섹션 | 명세 정규명 | 관계 | | --- | --- | --- | | `Brand & Style` | `Overview` | 명세가 인정하는 **별칭** — 그대로 쓰면 된다 | | `Colors` | `Colors` | 동일 | | `Typography` | `Typography` | 동일 | | `Spacing` · `Rounded` | `Layout` | **의도적 분리.** 명세는 한 섹션이지만 카탈로그는 둘로 나눈다. 토큰은 frontmatter `spacing:`·`rounded:` 키가 가르므로 헤딩을 합쳐도 추출은 안 깨지지만, `REQUIRED_SECTIONS` 가 두 헤딩을 모두 요구해 `missing-section` 으로 막힌다 | | `Elevation & Depth` | `Elevation & Depth` | 동일 | | `Shapes` | `Shapes` | 동일 | | `Components` | `Components` | 동일 | | `Do's and Don'ts` | `Do's and Don'ts` | 동일 | | `Responsive Behavior` · `Known Gaps` · `References` | — | 카탈로그 고유. 명세는 모르는 헤딩이지만 **결함으로 잡지 않는다** | **토큰은 frontmatter 에 쓴다** — 그 자체가 명세 형태다. 본문 ```yaml 펜스는 폐기된 형태이니 새로 쓰지 말 것(추출기가 폴백으로만 읽는다). `/services/{slug}/DESIGN.md` 라우트는 계속 남아 표준 도구용으로 정리된 뷰를 서빙한다 — 본문에 남는 펜스(shadow·컴포넌트 스펙)를 걷어내고 `radius` 를 명세의 `rounded` 로 바꾼다. **그 변환을 위해 이 파일에서 따로 할 일은 없다.** ## 스켈레톤 `{{...}}` 는 채울 자리, `<!-- -->` 주석은 채우고 나서 지운다. ````markdown --- name: {{브랜드명 — 한국어 정식 표기. 사이트 H1·OG·RSS 제목이 된다}} design_system_name: {{디자인 시스템의 고유 이름. 브랜드명과 다를 때만 쓴다(GDS, Vapor UI). 없으면 이 줄을 지운다}} slug: {{a-z0-9- 만. 파일명과 반드시 같아야 한다}} category: {{finance|messenger|commerce|delivery|mobility|content|community|travel|gov|developer|education|career|etc 중 하나}} last_updated: "{{YYYY-MM-DD — 기억이 아니라 조회한 날짜}}" created_at: "{{YYYY-MM-DD — 카탈로그 정렬 키. last_updated 보다 이를 수 없다}}" lang: ko logo: https://getdesign.kr/logos/{{slug}}.{{svg|png|webp|avif}} colors: ## {{그룹 라벨 — 이 주석 행이 그룹을 연다. 사이드카 `group` 이 되어 Tokens 탭의 구분 라벨로 렌더된다}} {{token-name}}: oklch({{L C H}}) # {{용도. 브랜드 발행 #HEX}} ← 트레일링 주석이 사이드카 `note` {{token-name-2}}: oklch({{L C H}}) ## {{다크 램프가 있으면 이름을 갈라 쓴다 — 한 이름 두 값은 대조를 꺼 버린다}} dark-{{token-name}}: oklch({{L C H}}) {{semantic-alias}}: "{colors.{{token-name}}}" # 참조는 반드시 인용 — 안 하면 YAML 이 flow mapping 으로 읽어 값이 null 이 된다 typography: {{style-name}}: fontSize: {{56}}px fontWeight: {{700}} lineHeight: {{1.30}} letterSpacing: {{0}}em spacing: {{space-1}}: {{4px}} rounded: {{radius-s}}: {{8px}} --- # {{브랜드명}} — design.md <!-- 아래 첫 산문 줄이 태그라인이 된다(그리드·OG·meta 설명이 여기서 파생). "[브랜드]는 [정체성] + [시그니처 특색]" 꼴로 색이 있게 쓸 것. "디자인 시스템은 ~이다" 류 메타 정의 금지. --> ## Brand & Style {{디자인 철학·타깃·정서적 톤. 기업 미션이 아니라 시각/UX 의도를 쓴다. 무드를 한 문단으로 요약하고, 그 판단의 근거를 [src:N] 으로 붙인다.}} ## Colors {{팔레트의 의도·역할·프로비넌스를 산문으로. **값은 frontmatter 가 갖는다** — 여기에 다시 나열하면 드리프트하는 두 번째 사본이 된다. 표는 토큰 맵이 담을 수 없는 것(용도 매트릭스, 라이트/다크 짝)을 실을 때만 쓴다. 각 주장에 [src:N].}} ## Typography {{서체 선택의 근거와 역할 배분. 값은 frontmatter `typography:` 에. Pretendard 밖의 자체 서체를 쓰면 스택은 frontmatter `fonts:` 맵에, 로드 가능한 CSS 진입점 URL 은 **최상위** `font-display-src:` 키에 적는다 — 없으면 프리뷰가 조용히 Pretendard 로 폴백한다.}} ## Spacing {{그리드의 기준 단위와 그 근거. 값은 frontmatter `spacing:` 에.}} ## Rounded {{곡률 체계가 무엇을 구분하는지. 값은 frontmatter `rounded:` 에.}} ## Elevation & Depth {{그림자 체계와 깊이 언어. 브랜드가 발행하지 않으면 섹션을 지우지 말고 "공개된 elevation 체계가 없다" 를 근거와 함께 한 줄로 적는다.}} ## Shapes {{곡률·기하 언어. 각진 편인지 둥근 편인지, 아이콘 그리드가 있는지.}} ## Components <!-- 기능이 다른 변형과 의미 있는 상태는 각각 ### 로 분해한다. 프로즈에서 토큰을 가리킬 때는 {colors.name} 형식을 쓴다. --> ### {{component-name}} {{치수·상태·토큰 참조. 각 주장에 [src:N].}} ## Do's and Don'ts **Do** {{…}} [src:N] **Don't** {{…}} [src:N] <!-- 최소 하나는 도메인 경계 Don't 여야 한다 — 소비자는 시각 언어를 빌리는 것이지 이 브랜드의 제품 개념·플로우·카피를 가져가는 게 아니다. design_system_name 이 있으면 벤더 중립 Don't 도 추가한다(시스템 이름· 패키지명·클래스 접두를 소비자 UI 에 노출하지 않는다). 이 둘은 카탈로그 정책이라 [src:N] 이 필요 없다. --> ## Responsive Behavior {{브레이크포인트 표 + Key Changes, 터치 타깃, 컴포넌트별 축소 전략.}} ## Known Gaps - {{리서치에서 끝내 확인하지 못한 것. 정직한 2~5개.}} ## References 1. {{https://… 공개 URL — 소스의 성격만 짧게. 출처 목록은 여기 한 곳이다}} ```` ## 채우면서 자주 틀리는 것 이번 표준 준수 작업에서 나온 경고 31건 중 **28건이 아래 두 가지**였다. - **테마별 팔레트에 같은 이름을 두 번 쓰기.** `dark-` 로 가른다. 위 frontmatter `colors:` 스켈레톤 참고. - **출처를 추가하고 인용하지 않기.** References 에 URL 을 넣었으면 **같은 커밋에서** 본문 어딘가에 `[src:N]` 으로 쓴다. 나중에 지우려면 `[src:N]` 이 정수 인덱스라 뒤 번호가 전부 밀린다 — 실측으로 한 항목에서 인용 214회 재번호가 필요했다. 여기에 하나 더: - **`typography:` 를 인라인으로 쓰기.** 이름 줄은 **비우고** 속성을 네 칸 들여쓴다. `{{style-name}}: { size: …, weight: … }` 꼴은 추출기가 못 읽어 **타입 토큰이 0개**가 되는데, `tokens:check` 는 방금 자기가 만든 빈 사이드카와 일치하므로 통과한다. 속성명도 스펙 이름(`fontSize`·`fontWeight`·`lineHeight`·`letterSpacing`)이지 사이드카 이름(`size`·`weight`)이 아니다. - **`letterSpacing`/`tracking` 에 단위 빼먹기.** `0` 은 CSS 로는 유효하지만 명세의 Dimension 은 px/em/rem 만 받는다. `0em` 으로 쓴다. 토큰이 frontmatter 로 옮겨오면서 생긴 함정이 둘 더 있다. 둘 다 **조용히** 틀린다. - **색 값을 인용하기.** `primary: "oklch(...)"` 로 쓰면 `audit:oklch` 와 드리프트 검사가 그 토큰을 못 본다. 둘 다 원문을 정규식으로 훑기 때문에, 아무것도 안 보면서 성공을 보고한다 — 출력이 통과와 구분되지 않는다. 색 값은 벌거벗겨 쓴다. - **반대로, 인용해야 하는 두 가지를 안 쓰기.** 참조(`"{colors.x}"`)를 벌거벗기면 YAML 이 flow mapping 으로 읽어 값이 null 이 되고, 따옴표로 시작하는 폰트 스택을 벌거벗기면 **frontmatter 전체 파스가 실패**해 그 항목의 토큰이 0개가 된다. 실측으로 각각 린터 error 126건(vapor-ui 76 · toss 41 · baemin 9)과 항목 7개에서 났다. ## 명세가 표현하지 못하는 값 아래는 **카탈로그가 맞고 명세가 못 담는** 경우다. 고치지 말고 그대로 두면 된다 (`validate:spec` 이 error 로 보고하지만 코퍼스 테스트가 알려진 한계로 고정해 둔다). - `border-radius: 50%` 같은 **`%` 단위** — 표준 CSS 인데 명세 Dimension 은 px/em/rem 만 받는다. - **다중 스톱 그라디언트** 를 색 토큰으로 둔 경우 — 명세의 Color 는 단색만이다. 새로 이런 값을 넣게 되면 `src/lib/google-designmd-corpus.test.ts` 의 `KNOWN_SPEC_LIMITATIONS` 를 함께 갱신한다. 그 표는 양방향 래칫이라 수를 안 맞추면 테스트가 실패한다. -
rubric-design.md 8.4 KB
# RUBRIC — design.md review (10 points; pass ≥ 8) The design-md-reviewer subagent uses this rubric to score `draft.md` against `research.md`. Output is a `review-{N}.json` with per-item scores and issues. The reviewer never edits the draft — only scores and explains. ## Item 1 — Schema validity (3 pts, hard requirement) Frontmatter must round-trip through `buildDoc()` in `src/lib/content-parser.ts` without throwing. Concretely: - All required keys present: `name, slug, category, last_updated, created_at, lang`. There is no `sources` key — `## References` is the only source list. - `name` is the Korean company/brand display name. If the design system has a distinct public name, `design_system_name` may be present as an optional string and is not a hard-fail requirement. - `category` ∈ `CATEGORIES` const from `src/lib/content-types.ts` (one of: finance, messenger, commerce, delivery, mobility, content, community, travel, gov, developer, education, career, etc). - `last_updated` matches `^\d{4}-\d{2}-\d{2}$`. The validator in content-parser.ts throws on any other format. - `created_at` is present and matches `^\d{4}-\d{2}-\d{2}$` — for a new entry it equals `last_updated`. This is the catalog's ordering key (the list, llms.txt, sitemap and OG build all sort by it), so omitting it is a block, not a nit: the entry would sink to the bottom of the list regardless of when it was added. - `## References` lists at least one public URL, **with no ephemeral/private handoff-bundle links** — no `api.anthropic.com/v1/design/h/...` URL, no local `.claude/cache/...` path. **Every entry must be an externally-accessible public URL** — label-only / ephemeral placeholder entries are NOT allowed (a source readers cannot open is not a valid source). If a claim's only basis is an ephemeral/private source, drop the citation rather than keeping a label-only entry. Enforced by `src/lib/source-citations.ts` (`pnpm validate:sources`). - `slug` matches `^[a-z0-9-]+$` and equals the staging filename stem (e.g. draft.md for slug X has frontmatter `slug: X`). - `lang` is exactly `ko` — an entry is one Korean design.md. - `logo` remains optional overall, but if the orchestrator passes an **Expected logo** (`expected_logo_url`) other than `none`, frontmatter must include `logo` and it must equal that exact URL string. If `logo` is present without an Expected logo, it must be a fully-qualified URL starting with `https://getdesign.kr/logos/` and ending in `.svg`, `.png`, `.webp`, or `.avif`. Bare `/logos/...` site-relative paths are rejected — frontmatter values must stay meaningful when the design.md is copied outside the ko-design-md site. **Failure modes**: typo'd key (`last-updated` instead of `last_updated`), Date object instead of string (not reachable through the `buildDoc()` round-trip this item scores — it keeps an unquoted ISO date as a string, as does the `yaml` 1.2 parser; the risk is a copy re-parsed outside the ko-design-md site by a YAML 1.1 parser such as js-yaml, which reads an unquoted ISO date as a Date. The template quotes both dates), off-enum category (`fintech`, `media`), an empty `## References`, an `api.anthropic.com/v1/design/h/...` handoff link (or `.claude/cache/...` path) left in `## References`, a leftover frontmatter `sources` list, slug with capitals or underscores, expected logo `https://getdesign.kr/logos/toss.png` omitted from frontmatter or downgraded to a site-relative `/logos/toss.png`. This item is **hard-fail**: if any sub-check fails, deduct the full 3 pts and mark `severity: block` in the issue list. The skill body refuses to advance to the user checkpoint without a valid frontmatter. ## Item 2 — Stitch section coverage (2 pts) All 10 standard sections present in order: `## Brand & Style`, `## Colors`, `## Typography`, `## Spacing`, `## Rounded`, `## Elevation & Depth`, `## Shapes`, `## Components`, `## Do's and Don'ts`, `## References`. Each section must contain ≥ 2 sentences of substantive content (or, for genuinely unknown information, one short explanatory line per stitch-format.md). `## Do's and Don'ts` must carry at least one brand-specific **domain-boundary Don't**. When frontmatter has a `design_system_name` (or `name`/`slug` is itself a design system — e.g. KRDS), it must *also* carry a **vendor-neutrality Don't** forbidding the system's own name / package names / class prefixes in the consumer's generated UI (per stitch-format.md §9). A slug ending in `-design`, `-design-system`, `-ds` is a *secondary hint only* — do not raise this warn for a coincidental slug (`kids-design-studio`) that isn't genuinely a reusable design system. A missing vendor-neutrality Don't on a genuine design-system entry is a `warn` issue. **Pass criteria**: - 2 pts: all 10 sections present in order, each ≥ 2 sentences (or a documented gap line). - 1 pt: 9 of 10 sections, or one section is a 1-sentence stub. - 0 pts: ≥ 2 sections missing or full of `TODO`/placeholder text. **Failure modes**: dropping `## Shapes` because "the brand doesn't really have a shape language" (write the gap line instead); writing `TODO: figure this out` in a section; reordering sections. ## Item 3 — Token consistency (2 pts) Color values are expressed in OKLCH only — declared in the frontmatter `colors:` map, referenced inside backticks in prose. The brand's published hex may appear only as a trailing `#` comment on the token's own line. Typography references match across all mentions of the same role. Radius and spacing values are concrete numbers, not vague descriptors. **Pass criteria**: - 2 pts: all colors OKLCH; typography names consistent (e.g. "Pretendard Variable" not also "Pretendard"); spacing/radius numerical (`16px`, not "약간 둥근"). - 1 pt: one or two minor inconsistencies (e.g. one color in hex, mixed font name spelling). - 0 pts: hex/rgba colors anywhere; contradicting numbers between sections. **Failure modes**: `## Colors` says primary is `oklch(0.7 0.18 50)` but `## Components` references `#FF8800`; `## Typography` calls it "Pretendard" in one place and "Pretendard Variable" in another. ## Item 4 — Brand fidelity (2 pts) Every concrete claim in the draft (specific colors, named components, screen descriptions) traces back to a `[src:N]` citation in `research.md`. No invented component names. No facts that contradict research.md. Inferred-from-screenshots values (when no public design system exists) are acceptable but must be marked with `≈` and a short note: ```markdown - **primary** ≈ `oklch(0.62 0.18 250)` (값은 공개된 토큰이 없어 캡처에서 추정) ``` **Pass criteria**: - 2 pts: every claim either cited or marked `≈`; no contradictions with research.md. - 1 pt: one or two uncited specifics, or one minor contradiction. - 0 pts: invented components, fabricated brand history, or systematic divergence from research.md. **Failure modes**: claiming the brand has a "Hero" component that research.md never mentions; inventing OKLCH values to make prose flow without marking them `≈`; citing research.md for one section then ignoring it for another. ## Item 5 — Voice/tone (1 pt) Body prose is Korean editorial register: ending with ~다, no honorifics, no marketing fluff ("혁신적인", "차세대"), no chatbot tone ("~해보세요!"). **Pass**: voice is consistent throughout, matches that register. **Fail**: any section reads like marketing copy or chatbot output. ## Output JSON shape ```json { "score": 8, "passed": true, "iteration": 1, "rubric": [ {"item": "Schema validity", "earned": 3, "max": 3, "notes": "All keys present and valid."}, {"item": "Stitch section coverage", "earned": 2, "max": 2, "notes": "All 10 sections present with substantive content."}, {"item": "Token consistency", "earned": 1, "max": 2, "notes": "## Components mentions 'rounded' without a px value."}, {"item": "Brand fidelity", "earned": 2, "max": 2, "notes": "All claims cited."}, {"item": "Voice/tone", "earned": 1, "max": 1, "notes": "Korean editorial register held throughout."} ], "issues": [ {"severity": "warn", "section": "Components", "fix": "Replace 'rounded' with a concrete radius value (e.g. `12px`) consistent with `## Rounded`."} ], "verdict": "Pass with one minor warning. Components section should align radius vocabulary with the Rounded section." } ``` `passed = score >= 8`. Reviewer must include all 5 rubric items even if 0 pts. `issues[].severity` is `block` (must fix to pass) or `warn` (should fix but not blocking). -
rubric-preview.md 21.4 KB
# RUBRIC — preview HTML review (10 points; pass ≥ 8) The preview-html-reviewer subagent scores `preview.html` against the approved `draft.md` (now `services/{slug}.md`). Reviewer reads only — no edits. ## Item 1 — File structure (2 pts, hard requirement) The HTML file exists and conforms: - `<html lang="ko" data-theme="light">` — the file's own state is light; dark is reached by changing this attribute, not by loading another file. - `<link rel="stylesheet" href="/preview/_runtime/tokens.css">` — absolute path, not relative or under `{slug}/_runtime/`. - `<script src="/preview/_runtime/iframe.js" defer></script>` — required for the parent route to grow the iframe to fit content. - Page CSS lives in `<style>` blocks inside `<head>`, and the **last one carries the `[data-theme="dark"]` scope with nothing after it** — the sheets are read by position, so a sheet written after the dark one stands in for it and the file is refused. Two is the floor, not the ceiling: a light scope plus the trailing dark scope. Every shipped preview carries two or three. Beyond tokens.css the only external stylesheet is the `font-display-src` webfont `<link>` Item 3 requires when the design.md names a display face; anything else is a foreign stylesheet. - No external JS frameworks (no React, no jQuery — these are static HTML pages). - Transfer size within budget. The gate measures **brotli** bytes, not raw — Vercel serves these files with `content-encoding: br`, so raw size was never the transfer cost. Repetitive markup compresses to nearly nothing; inline binary payloads (base64 `data:` images, embedded fonts) do not compress and cost their full size. The hard caps are **40 KiB brotli** and, as a safety net against generated markup that has run away, **256 KiB raw**. A separate **24 KiB brotli budget is advisory** — the gate emits a `warn` for it, and a warn never costs this item's 2 points; only a block does. The unit is the file a browser downloads — one `preview.html` carrying both themes, not either theme on its own. Without a machine report you cannot compute brotli, so judge the payload instead: measured across the catalog on the merged layout, previews ran 6–21 KiB brotli from 38–160 KiB of source (11–17%). A file that inlines no `data:` asset and stays under roughly 200 KiB of source is inside both hard caps. - If the orchestrator passes `expected_logo_src_path` (or design.md frontmatter includes `logo`), `preview.html` must contain a `<img src="{expected_logo_src_path}">` rendered in a visible brand/hero position. The required form is **site-relative** (e.g. `/logos/toss.png`) — NOT the absolute URL (`https://getdesign.kr/logos/toss.png`) that design.md frontmatter stores. Preview HTML lives inside the catalog site's iframe, so site-relative is correct; the absolute URL exists only in frontmatter so that copied design.md files stay meaningful outside the site. - The file carries the catalog disclosure strip — `<div class="catalog-disclaimer" role="note">` as the **first child of `<body>`**, verbatim, carrying both `제휴·후원 관계가 없습니다` and `더미 데이터`. It cannot be injected at runtime (`iframe.js` returns early when `window.parent === window`), so a standalone open or a redistributed copy sees only what is in the file. Position counts: the strip has to land in the first screen and in the hero crop a screenshot takes. **Pass**: 2 pts if all checks pass. 0 pts if any structural element missing or wrong path. No partial credit. **Failure modes**: writing `tokens.css` as a relative path; creating a per-slug `_runtime/` folder (the runtime is shared); adding `<script src="https://cdn.../react.js">`; inlining the hero image as a base64 `data:` URI, or embedding a webfont as `@font-face { src: url(data:font/woff2;base64,…) }` instead of `<link>`-ing it — base64 is the one payload in these files that brotli cannot recover, and it is what the size cap exists to catch; frontmatter says `logo: https://getdesign.kr/logos/toss.png` but `preview.html` omits the `<img src="/logos/toss.png">` site-relative form (or worse, embeds the absolute URL itself in `<img src>`); the disclosure strip is absent, reworded, moved below the hero, or reduced to one of its two sentences. ## Item 2 — Color fidelity (2 pts) The preview is a **component demo**, not a swatch catalog — the standalone color-swatch grid moved to the token cards (`{slug}.tokens.json`, rendered on the detail page). Color fidelity is therefore checked in **application**: the documented `## Colors` are declared as CSS custom properties (inline `:root` or inherited from tokens.css) and the brand/semantic roles (primary, accent, surface, text, state colors) are visibly applied to components with their exact OKLCH expression — character-for-character, no rounding, no hex/rgb conversion. **Pass criteria**: - 2 pts: the documented palette is declared as tokens and the key roles (primary, accent, surface, text) are applied to real components with exact OKLCH values. - 1 pt: colors applied but one or two roles hardcoded as hex/rgb, or a documented role unused anywhere. - 0 pts: ≥ 3 roles missing/unused, or values converted to hex/rgba in component styles. **A machine block on this item forces `earned` to 0.** The deterministic gate now checks the swatch catalog clause above: it counts **fill-only elements per theme** — an element carrying an inline `background` with no text of its own — and blocks at **24 or more**. When the machine report carries that block, Item 2 is **0**, adopted wholesale exactly as Item 1 adopts a machine block. Do not re-count it, and do not trade it against how faithfully the rest of the palette is applied: the block says the preview re-lists a ramp the token cards already render, and that is the whole of what this item asks. Mirror it into `issues` as a `severity: block` naming the file. **Failure modes**: rebuilding a color-swatch showcase grid (that catalog belongs in the token cards, not the preview); hardcoding `#E69245` in a button instead of the documented `oklch(0.7 0.18 50)` token. ## Item 3 — Typography hierarchy (2 pts) No standalone type-scale showcase — the documented scale lives in the token cards. Typography is checked in **application**: the component demo renders text across the documented hierarchy (display/heading, body, caption at minimum) at the documented sizes/weights. Pretendard Variable is applied to body text (inherited from tokens.css `body` rule). Tabular-nums (`font-feature-settings: "tnum"`) used wherever the design.md specifies. **Display face check**: if the design.md `## Typography` defines a `font-display` distinct from the body face (first family is not Pretendard) together with a `font-display-src`, the preview must (a) load that webfont via a `<link>` in the `<head>` and (b) apply it to the hero headline (`.hero h1`, via `var(--*-font-display)` or the stack). A documented brand display face that renders in Pretendard because the webfont link or stack was omitted is a fidelity miss. When the design.md has only one sans face, this check is N/A (Pretendard from tokens.css is correct). **Pass criteria**: - 2 pts: hierarchy visible across components at documented sizes/weights; body in Pretendard Variable and any documented `font-display-src` brand face loaded + applied to the hero headline; sample uses real Korean text for `lang: ko` previews to verify Korean fallback chain. - 1 pt: hierarchy present but one tier unused or wrong weight. - 0 pts: single flat text size; system font; English-only sample for a Korean-lang doc. **A machine block on this item forces `earned` to 0.** The deterministic gate now checks the no-standalone-showcase clause above: it counts how many of the design.md's typography token names appear in the preview as **visible text labels**, and blocks when **5 or more** do **and** that is **70% or more** of the design.md's unique typography tokens. Both conditions are required, and the ratio is what separates the two behaviours: naming two or three scales inside a component spec is what this item wants, while enumerating the scale is the violation. When the machine report carries that block, Item 3 is **0**, adopted wholesale exactly as Item 1 adopts a machine block, and mirrored into `issues` as a `severity: block`. **Failure modes**: rebuilding a typography-scale showcase section (that belongs in the token cards); using `font-family: -apple-system` somewhere that overrides Pretendard; a design.md `font-display` brand face (e.g. Wanted Sans) that never loads — no `<head>` webfont `<link>`, hero headline left rendering in Pretendard; loading the display webfont via `@import` instead of `<link>` (functional, but `<link>` is required for parallel load — emit a `warn`). ## Item 4 — Component coverage (2 pts) Each component named in `## Components` of the design.md is visibly rendered in the preview, with documented variants and states (hover, active, disabled where applicable). **Pass criteria**: - 2 pts: every named component rendered; primary variant + at least one state variation per interactive component. - 1 pt: most components present but one missing or showing only a default state. - 0 pts: ≥ 2 named components absent. **Failure modes**: naming `EtaBanner` and `RiderMapPin` in design.md but only rendering generic buttons; showing buttons but no hover/disabled states. ## Item 5 — Light↔dark distinction (2 pts) The `[data-theme="dark"]` scope uses brand-appropriate dark variants — not a literal inversion of the `:root` scope. Specifically: - Surface colors shift to dark variants chosen to match the brand mood (e.g. a warm brand uses a warm dark, not gray). - Primary color lightness is adjusted +5–10 (or whatever is needed) for sufficient contrast against the dark surface. - All component surfaces, text, and accents are updated to dark-mode token values. - Text remains comfortably legible (WCAG AA contrast at minimum). **Pass criteria**: - 2 pts: the dark scope shows considered dark adaptation; primary still recognizable but contrast-adjusted; no text below WCAG AA. - 1 pt: the dark scope exists and is distinct, but one or two tokens forgotten at light values, or contrast borderline. - 0 pts: the dark scope repeats the light one, or just sets `body { background: black; color: white }` without per-token thinking. **Failure modes**: copying the light scope and only flipping `background` and `color`; leaving the primary at its light-mode OKLCH; illegible accent text on dark. ## Mobile overflow (advisory static check — emits `warn` issues, does NOT change the 10-point score) The reviewer reads CSS only and cannot render, so this is a STATIC scan of the page's `<style>` blocks, not a measured check. It adds **no points** — the score stays out of 10 across Items 1–5. Instead, append one `warn` issue per distinct violation so the author fixes it on the next pass. Overflow does NOT live only at 375px — it hides in the intermediate multi-column widths and at the ~976px detail-page embed width (a 3-tab segmented control fit a 1-column phone cell yet overflowed every 4-column desktop cell), so flag a risk even when a phone collapse rule exists. The recurring causes are a CSS Grid `1fr` track flooring at `min-content` and an atomic `inline-flex` control group whose `nowrap` children can't shrink. The last pattern below is not an overflow at all — the row lays out wrong while `scrollWidth` stays clean — which is exactly why a reader has to catch it; it is also the one pattern the CSS alone cannot settle, so for it trace the container's selector into the markup (you have the whole file). Scan for these six patterns: - **Multi-column grid with no mobile collapse.** A `grid-template-columns` declaring 2+ tracks with no `@media (max-width: …)` override reducing the column count. Footer, swatch grid, and hero split are the usual offenders. - **Bare `1fr` on a content-bearing grid.** `1fr` / `repeat(n, 1fr)` (instead of `minmax(0, 1fr)`) on a track holding wide content (token strings, device mocks). Skip if the same selector also has a mobile rule collapsing it to one column. - **Flex/grid item with a fixed-width child but no `min-width: 0`.** A container that is itself a grid/flex item and wraps a device/phone mock, an `<img>`, or a `white-space: nowrap` label. - **Atomic `inline-flex`/`flex` control group with `nowrap` children and no shrink guard.** A segmented control, toggle group, or button/pill row (`.seg`, `.tg-group`, `.btn-group`, `.tabs`) whose buttons are `white-space: nowrap` while the group itself lacks `max-width: 100%` + `min-width: 0` (and its children lack `min-width: 0`). The group is one un-wrappable flex item, so a parent's `flex-wrap`/`min-width:0` cannot contain it — it overflows the cell at multi-column widths even when 375px is clean. Also flag a `flex: 1` + `white-space: nowrap` + `text-overflow: ellipsis` child missing `min-width: 0` (the ellipsis never fires and the track overflows). - **Generic class-name collision.** The same single-word class (`.brand`, `.card`, `.item`) used both as a standalone selector and in a compound selector (e.g. `.brand` AND `.swatch.brand`) — the standalone rule's `display`/`white-space`/`gap` leak onto the compound element. - **Card/tile row built on `flex-wrap` instead of grid.** A row of repeated cards or tiles (`.prod-row`, `.card-row`, `.item-row` — repeated siblings each carrying a media/thumbnail box) declared `display: flex; flex-wrap: wrap` with children on `flex: <grow ≥ 1> <shrink> <basis>`. When the item count does not divide by the column count, the last item sits alone on its row and `flex-grow` stretches it to the full row width; a child with `aspect-ratio` then balloons in height with it (gs-shop shipped `213/213/213/663px` at 768px with a 661px square thumbnail). Ask for `grid-template-columns: repeat(auto-fit, minmax(<min>px, 1fr))` — or `auto-fill` when the row holds only a few fixed-ratio tiles: `auto-fit` collapses the tracks those few items cannot fill, so on a wide row it stretches every tile and its thumbnail with it (gmarket's 3-card `.items-row` uses `auto-fill` for this reason). Judge this by what the row *is*, not by the declaration alone — `flex-wrap` is correct for tag/pill rows and button groups, where a wide last item is harmless — so find the container's selector in the markup, count its repeated children and look for a media box inside them; flag only rows of repeated cards/tiles, and flag hardest when a child sets `aspect-ratio`. A fixed-count `repeat(N, minmax(0, 1fr))` with its mobile collapse is as good an answer as `auto-fit`. Unlike the five above, this one does **not** overflow: the machine report and the render sweep both stay silent, so if you skip it nothing else catches it. Emit each as e.g. ``{"severity":"warn","section":"footer grid","fix":"`.brand-footer` declares 4 columns with no mobile collapse; add a `@media (max-width:720px)` override to 1–2 columns + `min-width:0` on items."}``. These are **non-blocking** (the whole preview review is non-blocking), but compounding — a preview that overflows at 375px reads as broken on the device most catalog users browse from, so surface them even when the 10-point score passes. ## Dummy-data labelling (advisory content check — emits `warn` issues, does NOT change the 10-point score) The disclosure strip itself is Item 1 (structural, machine-checked). This block is the part a machine cannot check: **whether each caption actually enumerates what its block fabricates.** `validate:previews` verifies the label literals exist; only a reader can tell whether the label is complete. Adds **no points** — append one `warn` per gap. For every block that shows invented values attached to a real, named third party (a brand, a company, a product, a person, a public body), check: - **Is there a label at all?** A `catalog-dummy` `<p>` immediately before the block in the same container, or a `<caption>` as the table's first child. A table inside an `overflow-x` wrapper must use the `<p>` form — a caption box takes the table's width, not the scroller's, so the sentence runs off screen at phone widths. - **Is the label outside the mocked UI?** It must not sit inside a phone mockup's `.screen`. It is the catalog's voice, not the mocked app's. - **Does the enumeration cover the claims, not just the numbers?** This is the one that gets missed. Prices, ratings, and counts are the easy half; badges, certifications, rankings, and identifiers are fabricated claims too — `공식` (seller verification), `베스트` (bestseller rank), `국비지원` (government-funded course), an invoice number attached to a real courier, a merchant name on a transaction row. Read the block's rendered text and compare item by item. - **Does the label read as an observation, not a norm?** "…는 레이아웃 시연용 더미 데이터입니다" describes the screen. A caption that instead asserts what the brand *does* is an unsourced claim about a real company and belongs in design.md with a `[src:N]`, not here. Emit each as e.g. ``{"severity":"warn","section":"kyobobook — device mock","fix":"The caption lists prices and delivery badges but the screen also shows a `베스트` rank badge and a 9.6 rating with 2,481 reviews; add those to the enumeration."}``. ## Explanatory prose (advisory content check — emits `warn` issues, does NOT change the 10-point score) Items 1–5 all score what the preview **renders**. None scores how much of the screen is explanation of it. remember passed every item at 2/2 — 10/10 — while 61% of its rendered text was captions restating `services/remember.md`. The reviewer did not err; the axis did not exist. Adds **no points** — append one `warn` per gap. Three kinds of text are out of scope. The disclosure strip is Item 1. The `catalog-dummy` and `catalog-attribution` lines are the section above, and they are required. A component's own label — a button reading `검색`, a tab reading `전체` — is the demo, not an explanation of it. For every remaining explanatory element — a hero lede, a section description, a note or caption under a demo — ask one question: **can the design.md say this?** You have the md open from step 3. The detail page carries the token cards and the design.md itself in their own tabs beside this iframe (`src/routes/services/$slug.tsx:212-216`), so a sentence the md already carries is its third copy on that page. Those three tabs are **exclusive** — the reader sees one at a time, so the copy is a tab away rather than on screen at once. Do not stretch this into "the reader sees it twice"; the reason a restatement goes is that **a preview demonstrates and the md states**, and that reason holds whichever tab is open. - **Restatement — flag it.** The sentence names a value, a scale step, a column ratio, a duration, an easing, a token name, or a known gap that the md states. Quote the md's own line in the `fix` so the author can delete without re-deriving. This is the whole of the check: the two machine content blocks count fill-only elements and rendered token names, and a value written as a sentence renders neither, so nothing else reaches it. - **Legitimately kept — leave it.** The md has no screen, so five kinds of sentence have no other home: how to trigger an interaction the demo animates (without it nobody finds the animation); a value the demo borrowed from another component because the md specifies none (deleting the note leaves a borrowed value displayed unmarked); a responsive behaviour that reads as a defect without a word (four columns becoming a clipped carousel under 767px); a place where the demo departed from a token and what filled it; an accessibility caveat about the demo itself (a 32px control under the 44×44 recommendation). - **Volume is the symptom, not the rule.** Do not compute a percentage — you cannot render, and a character count read by eye is not evidence. Judge each element on the question above. If what remains still outweighs what it explains, say so in the `verdict`. Emit each as e.g. ``{"severity":"warn","section":"typography — section note","fix":"The note prints the 20/600 · 16/400 · 14/400 · 12/500 pairs that `services/{slug}.md` already states under `### 실측된 타입 조합`, and the detail page's Tokens tab renders them. Delete the sentence."}``. ## Output JSON shape ```json { "score": 9, "passed": true, "iteration": 1, "rubric": [ {"item": "File structure", "earned": 2, "max": 2, "notes": "All structural checks pass."}, {"item": "Color fidelity", "earned": 2, "max": 2, "notes": "All 6 documented colors applied across components; OKLCH values match exactly."}, {"item": "Typography hierarchy", "earned": 2, "max": 2, "notes": "Display/body/caption hierarchy applied across components with Korean sample text."}, {"item": "Component coverage", "earned": 2, "max": 2, "notes": "EtaBanner, RiderMapPin both rendered with hover state."}, {"item": "Light↔dark distinction", "earned": 1, "max": 2, "notes": "Dark adaptation considered, but accent retained light-mode OKLCH."} ], "issues": [ {"severity": "warn", "section": "dark scope — accent color", "fix": "Adjust the accent token to its dark-mode OKLCH (currently still 0.92 lightness; should be ~0.75 for dark contrast)."} ], "verdict": "Pass. One swatch missed the dark-mode adjustment; non-blocking." } ``` `passed = score >= 8`. The skill treats the preview review loop as **non-blocking** — if score < 8 at iteration 3, the skill proceeds to BUILD_OG with a warning rather than asking the user, since visual previews iterate naturally during real use. -
stitch-format.md 17.1 KB
# Stitch v0.1 design.md format reference A catalog entry is a YAML-frontmatter + Markdown document that encodes a brand's design language in a single text file consumable by both humans and AI coding agents. **Stitch v0.1** is this catalog's name for the **section structure** it adopted from Google Stitch's design.md; the file format itself is specified by Google Labs as DESIGN.md (see *Relationship to Google's published DESIGN.md spec* below). Entries carry that spec's token maps in frontmatter, next to catalog-specific keys (`slug`, `category`, `created_at`, …). ## Standard section order (use as ## headings, in this order) 1. **Brand & Style** — design philosophy, target audience, emotional tone. Prose, body lang. 2. **Colors** — palette with semantic roles. Values are declared as OKLCH in the frontmatter `colors:` map (see the token rules below); the section carries the prose, plus a table only where it adds what the map cannot (usage matrix, light/dark pairing). 3. **Typography** — font families (Pretendard Variable for Korean coverage), scale, weights, line heights. 4. **Spacing** — base unit + scale. 5. **Rounded** — radius tokens. 6. **Elevation & Depth** — shadow system, depth language. 7. **Shapes** — visual language (curves vs. sharp, geometric vs. organic). 8. **Components** — signature components with variants and states. Code identifiers stay English. Include short ```tsx examples where a snippet clarifies the API. 9. **Do's and Don'ts** — guardrails for downstream LLMs. Always include at least one brand-specific *domain-boundary* Don't: consumers should borrow the visual treatment, not the source brand's product concepts, flows, or copy. (The catalog-wide statement of this principle lives in the README; per entry, write only the brand-specific line.) When the entry is a design system, add a second Don't enforcing **vendor neutrality**: the system's own name, its package names, and its class prefixes must not be surfaced in the consumer's generated UI copy, headers, titles, labels, or class names — borrow the visual language, not the system's name. The primary trigger is an explicit `design_system_name` (or a `name`/`slug` that is itself a design system, e.g. KRDS); a slug ending in `-design`, `-design-system`, `-ds` is a *secondary hint only*, so confirm the entry is genuinely a reusable design system before applying. Substitute *this* entry's own actual identifiers — do not copy the example verbatim (the Vapor UI entry uses `Vapor UI` / `@vapor-ui/*` / `vp-*`; use whatever name, package names, and class prefixes this brand actually ships). Like the domain-boundary Don't, this guardrail is catalog policy rather than a brand claim, so it needs no `[src:N]` citation. 10. **References** — the entry's only list of sources, numbered 1..N; `[src:N]` points at entry N. There is no frontmatter copy of this list. **Every entry must be an externally-accessible public URL** that both readers and `pnpm validate:sources` can open. Ephemeral or private sources (a user-supplied Claude Design handoff bundle at `api.anthropic.com/v1/design/h/...`, a local `.claude/cache/...` path) are NOT valid sources — do not list them in `## References`, and do not keep label-only placeholder entries. If a claim's only basis is such a source, either cite the public page that backs it or leave the claim uncited. If a brand genuinely lacks information for a section (e.g. no published shadow system), keep the section heading and write one short line explaining the gap (`(no published elevation system; observed shadows are minimal)`). Do not delete sections — downstream agents rely on a stable structure. A fill-in skeleton for all of the above lives at [`design-md-template.md`](./design-md-template.md). This file is the normative reference; the template is the shape. ### Relationship to Google's published DESIGN.md spec Google Labs published the DESIGN.md format spec (`github.com/google-labs-code/design.md`, version `alpha`, Apache-2.0) after this catalog adopted the Stitch section structure. The two agree, and the ordering above already satisfies the spec — verified by running the official linter (`@google/design.md`) over every entry: `src/lib/google-designmd-corpus.test.ts` runs it inside `pnpm test` (the CI gate), and `pnpm validate:spec` prints the same findings for local diagnosis. Three facts about the spec matter when editing this list: - **All eight of its canonical sections are optional.** There is no missing-section rule. Its `missing-sections` diagnostic inspects the frontmatter `spacing`/`rounded` token maps, not body headings, and is info-level. - **Its order check ignores headings it does not know.** Catalog-only sections (`Spacing`, `Rounded`, `References`, `Responsive Behavior`, `Known Gaps`) pass through silently, so adding one never breaks conformance. - **`Brand & Style` is the spec's own alias for `Overview`.** Do not rename it. The one place the two structures diverge on purpose: the spec has a single `Layout` section (alias `Layout & Spacing`) where this catalog keeps **`Spacing` and `Rounded` separate**. Keep them separate — tokens are now keyed by the frontmatter `spacing:` / `rounded:` maps, so a merge no longer empties the sidecar (the extractor reads those headings only as a legacy fallback), but the draft gate requires both headings and blocks a merged `Layout`. The catalog is also, in two places, *more* expressive than the `alpha` schema. Conforming would mean deleting real published values, so these are recorded rather than fixed, and `src/lib/google-designmd-corpus.test.ts` pins their exact counts: - `%` units in radius tokens (`50%` for a circle) — valid CSS, but the spec's `Dimension` accepts only `px`/`em`/`rem`. - Multi-stop gradients held as colour tokens — the spec's `Color` is a single colour. Catalog entries ARE spec documents. Tokens live in frontmatter in the shape Google's DESIGN.md defines, so a consumer reading the raw md off GitHub gets a document the official linter resolves. `/services/{slug}/DESIGN.md` still renders a cleaned view for standard tooling — it strips body fences and renames `radius` to the spec's `rounded` — but the file no longer depends on that route to be readable. ## Token expression **Declare tokens in frontmatter**, under `colors:`, `typography:`, `spacing:` and `rounded:`. This is a reversal: entries used to carry tokens in body ```yaml fences, and every entry was migrated in one pass. If you are looking at an older draft or an outside example that fences its tokens, that form is legacy — the extractor still reads it as a fallback, but nothing should be authored that way. ```yaml colors: ## Brand primary: oklch(0.64 0.19 40) # #E5581C 공식 발행값 primary-foreground: oklch(0.99 0.005 40) ## Surface surface: oklch(0.99 0.01 80) text: oklch(0.18 0.02 60) typography: display-1: fontSize: 56px fontWeight: 700 lineHeight: 1.30 letterSpacing: -0.005em spacing: space-1: 4px rounded: radius-s: 8px ``` Three details in that block are load-bearing, because they are what the sidecar extractor reads: - **`typography:` is the one map that nests, and its property names are the spec's** — `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, each on its own four-space line under a bare style name. The inline flow form (`display-1: { size: 56px, … }`) is NOT read: the extractor wants an empty head line, so that shape yields **zero** type tokens, and `tokens:check` then agrees with the empty sidecar it just generated. - **Names stay flat.** Do not nest a group as a sub-map. `brand.primary` would rename the token and break every `{colors.X}` prose reference and the preview's CSS-variable mapping along with them. - **A `## Heading` comment row opens a group.** It becomes the sidecar's `group` field, which the site's Tokens tab renders as a section label. - **A trailing `# comment` becomes the token's `note`.** That is the only channel that reaches machine consumers — the sidecar carries it, and both the Tokens tab and the `use-design-md` skill read it. Put per-token caveats here, not only in the section's prose. ### Values may be quoted only when YAML would misread them Write `primary: oklch(0.64 0.19 40)` bare. Quoting a colour value hides it from `audit:oklch` and the drift check — both regex over the raw text, and both report success while matching nothing, so the failure looks exactly like a pass. **`validate:catalog` now blocks a quoted colour literal** rather than leaving this to discipline, and it unquotes before judging, so a quoted `"#FF0038"` is rejected as non-OKLCH like the bare form. That matters more than it sounds: bare `primary: #FF0038` is not even valid YAML — the `#` opens a comment — so quoting was the only way to actually write a hex, and therefore the one spelling the rule had to catch. Two value shapes DO need quoting, because unquoted they are not the scalar you meant: - A reference: `fill-brand: "{colors.primary}"`. Bare, YAML reads `{...}` as a flow mapping and the value resolves to null. - A font stack that starts with a quote: `fontFamily: '"Noto Sans KR", Roboto, sans-serif'`. Bare, the leading quote makes YAML fail to parse the whole frontmatter. **That is now a block too** — the validator parses the frontmatter with a real YAML reader, which is what a consumer of this format uses. Seven entries once dropped to zero tokens on exactly this shape with every gate still green. ### Prose still uses inline backticks Referring to a value inside a sentence is unchanged, and does not duplicate a token — the frontmatter declaration is the definition, prose is commentary: ```markdown - **primary**: 따뜻한 오렌지 `oklch(0.7 0.18 50)` — 핵심 CTA, ETA 강조 ``` Every form must use OKLCH. Hex and rgba are rejected — `non-oklch-token-value` is a **block**, checked against the frontmatter maps — because downstream LLMs cannot reason about them the way they can about explicit lightness, chroma and hue. Keep the brand's published hex as the trailing comment; that is what it is for. ### Per-theme palettes need distinct names When a brand publishes both a light and a dark value for the same semantic role, **do not declare the role twice under one name**. Nothing downstream can tell which declaration is authoritative: `readDefinitions` (`src/lib/oklch-drift.ts`) drops a name that disagrees with itself rather than guessing, so the preview-drift comparison switches off for that token entirely — and the DESIGN.md adapter keeps only the first, because frontmatter keys must be unique. Prefix the dark scale instead. This is the established catalog convention, not a new rule: `codeit` names 78 tokens that way and `seed-design` 109, and both carry zero name collisions. ```yaml bg-canvas: oklch(1 0 0) dark-bg-canvas: oklch(0.148 0.004 277) ``` Measured cost of getting this wrong: `wanted` shipped 21 colliding names, which silenced 22 of its preview comparisons until they were renamed. `validate:catalog` warns on every collision and names the comparison it costs, so you do not have to spot them by eye. ### Dimension values carry a unit — including zero Write `0em`, not `0`. A bare zero is valid CSS but not a valid `Dimension` under Google's spec, which accepts only `px`, `em` and `rem`; `pnpm validate:spec` reports it as an error. This applies to `tracking`/`letterSpacing` most often, since zero tracking is common. The frontmatter token maps feed the **token-card sidecar** (`services/{slug}.tokens.json`, generated at Stage 8 by `pnpm tokens:build` and loaded as `doc.tokens` for the detail page's card view). Keep one token per line so the extractor can read each — `name: oklch(...)` (colors), `name: 16px` (spacing/rounded). **Typography is the exception — it nests**, as shown above: a bare style name, then four-space `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing`. The inline `name: { size, weight, … }` and `name: 16 / 24 / 700` forms are read only from markdown tables and legacy body fences; in frontmatter they yield **zero** type tokens. Alias rows whose value points at another token (`fill-brand: "{colors.red}"`) are skipped by the extractor and surface only in the prose — intended, since the cards show visually-renderable tokens, not pointers. `pnpm tokens:check` compares the regenerated sidecar byte-for-byte, so a formatting slip here fails CI rather than silently changing a card. ### Webfont source URLs (`font-*-src`) When `## Typography` names a face that the preview runtime's bundled Pretendard does NOT cover — almost always a `font-display` set to the brand's own display typeface (e.g. Wanted Sans, Toss Product Sans) — record that webfont's loadable CSS entry-point URL on a **top-level frontmatter key** `font-display-src` (or `font-sans-src`). The stack itself goes in the `fonts:` map; the `-src` key sits beside it at column 0, which is where `findFontDisplaySrc` looks: ```yaml fonts: font-display: "\"Wanted Sans Variable\", \"Wanted Sans\", \"Pretendard Variable\", system-ui, sans-serif" font-display-src: https://cdn.jsdelivr.net/npm/wanted-sans@1.0.3/fonts/webfonts/variable/split/WantedSansVariable.css ``` The stack is quoted because it begins with a quote character — see "Values may be quoted only when YAML would misread them" above. This URL is the single source of truth the **preview-html-author** loads into the preview `<head>`. Without it a brand-specific display face has no webfont to load and silently falls back to Pretendard in the preview (the gap that shipped on the wanted entry). Rules: - Point at a **loadable CSS entry point** (`@import`/`<link>`-able), not a demo or marketing page. Prefer a foundry's **dynamic-subset / split** build where one exists (lighter for Korean pages) and a **pinned version** over `@latest` — refresh the pin alongside the catalog entry when the typeface library publishes an update. - **Pretendard needs no `-src`** — the preview runtime already imports it. Only faces outside that baseline need a source. - The token-card extractor **ignores `*-src` lines and any `http…` value**, so these never appear as bogus type cards. ## Body language Body prose is Korean (`lang: ko` — the only value): editorial register (ends with ~다, no honorifics overuse, no marketing fluff). Section **headings stay in English** even though the body is Korean, so the structure is parseable by downstream agents that key off heading text. ## Token reference syntax (recommended) Within prose sections (`## Components`, `## Do's and Don'ts`, `## Responsive Behavior`, etc.), reference tokens using `{group.name}` syntax: - `{colors.primary}`, `{colors.fg-1}` - `{typography.body-m}`, `{typography.display-l}` - `{rounded.pill}`, `{rounded.medium}` - `{spacing.section}`, `{spacing.lg}` - `{component.button-primary}`, `{component.card-elevated}` Token definitions (the frontmatter `colors:` / `typography:` / `spacing:` / `rounded:` maps) keep their bare key names. The `{group.name}` form is for prose references only. This syntax makes downstream LLM consumption unambiguous — "use `{colors.primary-50}` background" is mechanically resolvable to the OKLCH value, whereas "use the primary blue background" requires inference. ## Component variant decomposition (recommended) Within `## Components`, decompose functionally distinct variants and meaningful states into separate `###` entries rather than nesting them inside one parent section. When to decompose: - Multiple button "kinds" (primary, secondary, danger, ghost) → each its own `###`. - Card states with distinct visual treatment (default, elevated, selected) → each its own `###`. - Singular components (SearchBar, Pagination, Breadcrumb) with no variant branching → keep as one `###`. State variants (`-active`, `-focus`, `-disabled`) also live as their own entries when they have distinct token references (e.g. `button-primary-active` shifts to `{colors.primary-60}` fill). ## Optional sections (recommended) These sections extend the 10 standard sections. They are recommended for new entries but **not required** by the rubric. If included, place them between `## Do's and Don'ts` and `## References`. - **`## Responsive Behavior`** — breakpoint table with a "Key Changes" column, touch target rules, per-component collapsing strategy, image/aspect-ratio behavior at small widths. Its input source is research.md's `## Responsive & breakpoints (observed)` section; include whenever that section surfaces any breakpoint or mobile-specific information. - **`## Known Gaps`** — honest 2~5 bullet list of what wasn't surfaced from research. Signals to downstream consumers what they need to fill in themselves. ## What's NOT in design.md - Implementation code beyond short illustrative snippets — design.md describes intent and tokens, not full components. - Marketing copy, taglines, or hero messaging — those belong in product surfaces. - Brand mission statements — keep `## Brand & Style` focused on visual/UX intent, not corporate positioning. ## Sources - Google Labs announcement: https://blog.google/innovation-and-ai/models-and-research/google-labs/stitch-design-md/ - Spec repo: https://github.com/google-labs-code/design.md - Curated examples: https://getdesign.md, https://github.com/VoltAgent/awesome-design-md
-
-
SKILL.md 64.6 KB
--- name: design-md metadata: internal: true description: Add a new design.md catalog entry to ko-design-md. Use this skill IMMEDIATELY when the user wants to onboard a new brand into THIS project's catalog — produce services/{slug}.md (Stitch v0.1 format) plus services/{slug}.tokens.json (token-card sidecar) plus public/preview/{slug}/preview.html (one file carrying both themes) plus the OG image. Trigger phrases include "add to design.md catalog", "new design.md entry for X", "onboard X to ko-design-md", "X를 ko-design-md에 추가", "X의 design.md 만들어줘", "/design-md", "X 카탈로그 항목 만들기", or any variant where the user is asking to populate this catalog with a new brand entry. Do NOT use for editing prose in an existing entry, fixing one frontmatter field, generating non-catalog design docs, or working in any other repository. The skill operates only inside the ko-design-md repo and verifies this via `package.json` name. --- # /design-md skill — orchestration body This skill builds a complete catalog entry through a 5-subagent pipeline with one user checkpoint every entry hits (Stage 7), plus a conditional one ahead of it when a design board is the upstream (Stage 4c). The pipeline is heavy (research, drafting, two review loops) so resumability matters: each stage's artifact lives on disk in `.claude/cache/design-md/{slug}/` and the next stage reads from there. State is encoded by file presence — no separate state.json needed for v1. ## Pipeline shape ``` [INTAKE] → research-collector → design-md-author ⇄ design-md-reviewer (loop ≤3) ↓ score≥8 or N=3 [USER CHECKPOINT] ↓ approve [WRITE_MD] → [TOKENS] ↓ preview-html-author ⇄ preview-html-reviewer (loop ≤3, non-blocking) ↓ [WRITE_PREVIEW] ↓ [BUILD_OG] ↓ [VERIFY] ↓ END ``` **Loop termination**: design loop is blocking — score must reach 8/10 within 3 iterations or the user decides at the checkpoint. Preview loop is non-blocking — proceed with warning if score < 8 at iteration 3. **Key reference files** (read these before dispatching subagents that need them): - `.claude/skills/design-md/references/stitch-format.md` - `.claude/skills/design-md/references/design-md-template.md` - `.claude/skills/design-md/references/rubric-design.md` - `.claude/skills/design-md/references/rubric-preview.md` ## Stage 1 — Preflight Verify the working environment before doing anything user-visible. 1. `Bash`: `pwd` to capture the absolute repo root. Hold this value as `${repo_root}` in your reasoning and substitute it literally into every later Bash command and dispatch prompt that touches a repo path. The shell preserves cwd across calls, but pinning the absolute path makes Stage 8/10/11 robust to any inadvertent `cd`. 2. `Bash`: `date +%Y-%m-%d` to capture today's date. Hold this value as `${today}` in your reasoning. Stage 6a passes this to the author for **both** the `last_updated` and `created_at` frontmatter fields — a brand-new entry is added and last-updated on the same day. The project's date validator at `src/lib/content-parser.ts` rejects any other format, and `missing-created-at` in `src/lib/draft-validator.ts` blocks a draft that omits `created_at`. 3. `Read` `${repo_root}/package.json`. If `"name"` is not exactly `"ko-design-md"`, abort with: "이 스킬은 ko-design-md 레포 안에서만 동작합니다. 현재 디렉터리: ${repo_root}". Do not proceed. 4. Verify `${repo_root}/src/lib/content-types.ts` is readable. If not, abort. 5. `Read` `${repo_root}/src/lib/content-types.ts` and extract the live `CATEGORIES` const. Use this as the source of truth for the intake category picker (do NOT hardcode the enum from memory — it can drift). ## Stage 2 — Conversational intake Use a single `AskUserQuestion` form with these 3 questions (multi-select where indicated): 1. **브랜드명** (text via "Other" → custom input): e.g. "토스", "당근", "구름". Use the Korean company/brand display name as it should appear in the `name` frontmatter, not the design system product name. If research later surfaces a distinct design system name (e.g. "SEED Design", "Vapor UI"), the author stores that in optional `design_system_name`. 2. **참고 URL** (text via "Other"): comma-separated URLs. **2개 이상 권장** — 1개만 입력 시 research-collector가 INSUFFICIENT_SOURCES로 중단할 수 있고, 그 경우 스크린샷 보강 필요. Brand homepage, design system page, blog post about their UI, etc. 3. **카테고리** (single-select): all values from `CATEGORIES` const, in order. Last option is `etc`. Do not ask for a language. An entry is one Korean design.md — `lang` is always `ko` (`docs/adr/0001-korean-design-md-only.md`), and every dispatch below passes it as a literal. Then ask four follow-up text inputs: - **스크린샷 경로** (optional) — comma-separated absolute paths to screenshot files. The user can type "없음" to skip. - **로고 자산 경로** (optional) — an existing local file path for a brand logo. Accept only `.svg`, `.png`, `.webp`, or `.avif`. The user can type "없음" to skip. **CRITICAL — pick a small square symbol mark, NOT a wordmark.** Two square slots in the site consume this asset: the catalog grid card (~48–96 px on screen) AND the OG image's top-left brand mark (32×32 px in the 1200×630 social card, see `src/og/template.tsx`). The OG renderer (Satori) has limited `object-fit` support, so a non-square asset is stretched into the 32×32 box rather than letterboxed — the catalog card has the same constraint at its own scale. Choose accordingly: - ✅ Pick the brand's standalone **symbol / mark / favicon shape** with a transparent background — e.g. SOCAR's angular blue mark, Toss's curved oval lens, Gmarket's circular G, Baemin's symbol. Match the style of existing `public/logos/{toss,socar,baemin,…}.png` (square, no text, no baked-in frame). - ❌ Avoid the **horizontal wordmark / lockup** (the brand name written out, e.g. "Gmarket", "toss", "쏘카") — wordmarks render too small in the grid card or break its aspect. - ❌ Avoid **iOS-squircle / framed app icons** with a rounded gradient background baked in — that frame conflicts with the catalog card's own background. Prefer the unframed symbol form. - When a bundle provides multiple variants (e.g. `logo-brand.png` wordmark vs `logo-circular-g.png` symbol vs `logo-app-icon.png` framed), the **unframed symbol** is correct for the catalog grid. Filename hints for the GRID-WRONG forms: `*wordmark*`, `*logotype*`, `*-brand*`, `*-horizontal*`, `*-app-icon*` (framed). Filename hints for the GRID-RIGHT form: `*-symbol*`, `*-mark*`, `*-circular*`, `*-icon*` (when unframed), or a generic `{slug}.png` that is already a symbol. **Optional second asset — wordmark/logotype for the preview hero.** The catalog grid uses the symbol, but the preview HTML hero (`public/preview/{slug}/preview.html`) has room for a richer brand lockup with the brand name visible. If the source provides BOTH a symbol AND a horizontal wordmark/logotype, capture both paths. Stage 4a will place the wordmark at `public/logos/{slug}-logotype.{ext}` (matching the existing `toss-logotype.png` convention), and the preview-html-author renders the wordmark in the hero where there is space. The grid card always uses the symbol; the **wordmark has no frontmatter field** — it stays a site-internal preview-only asset (the design.md's `logo` frontmatter URL still points to the symbol so the file remains portable outside ko-design-md). - **디자인 시스템 문서 사이트 URL** (optional) — if the brand publishes its design system as a documentation website (not only Figma), the root URL of that site (e.g. `https://socarframe.socar.kr/`). Stage 4b crawls it into a research corpus. The user can type "없음" to skip. - **디자인 보드 / 핸드오프 번들 경로** (optional) — comma-separated absolute paths to a Claude Design board's exported frames, or a directory the handoff bundle was already extracted into. The user can type "없음" to skip. This is what makes **Stage 4c** fire; without it there is nothing to approve and the run goes straight from Stage 4b to Stage 5. Ask for it even when the brand has a docs site — the two are different upstreams and an entry can have both. **The link expires.** A Claude Design handoff URL 404s in roughly fifteen minutes, so the user must have already saved it locally; do not accept a URL here. Capture the answers as: `brand_name`, `source_urls` (parsed array), `category`, `screenshot_paths` (parsed array, may be empty), `logo_asset_path` (string or empty), `docs_site_url` (string or empty), `design_board_paths` (parsed array, may be empty). **Screenshot path preflight**: for each path in `screenshot_paths`, run `Bash`: `[ -f "$path" ]`. If any path is missing, surface the missing list to the user and re-prompt the screenshot question. This avoids research-collector failing silently mid-read. **Logo path preflight**: if `logo_asset_path` is not empty/`없음`, run `Bash`: `[ -f "$logo_asset_path" ]` and verify the extension matches `svg|png|webp|avif`. If missing or unsupported, surface the problem and re-prompt the logo question. Do not download logos from the web. ## Stage 3 — Slug derivation + conflict resolution Derive `slug` from `brand_name`: 1. NFD-normalize and strip diacritics/non-ASCII. 2. Lowercase, replace `[^a-z0-9]+` with `-`, trim leading/trailing `-`. 3. If the result is empty (Korean-only brand with no Latin form), prompt the user via `AskUserQuestion` for an explicit slug. Question wording: **"slug은 영문 소문자/숫자/하이픈만 가능합니다 (예: `toss`, `karrot-market`)."** Validate the user's input matches `^[a-z0-9-]+$`; on mismatch, re-prompt. Check for conflicts via `Bash` (`ls services/{slug}.md 2>/dev/null`): - No conflict → proceed. - Conflict → `AskUserQuestion`: - "다른 slug 사용" → user provides a new slug, recheck. - "기존 항목 업데이트" → set `mode = update`. The pipeline still runs but final write overwrites. - "취소" → abort. ## Stage 4 — Cache setup Create the staging directory: ```bash mkdir -p .claude/cache/design-md/{slug} ``` This directory holds all intermediate artifacts. It's gitignored (`.claude/cache/` was added to `.gitignore` when the skill was installed) so partial work won't leak into PRs. ### Stage 4a — Logo asset resolution **Before resolving paths — verify the logo asset is the right FORM.** The catalog grid card uses a small square logo slot, so the chosen asset MUST be a **symbol / mark / favicon shape** (transparent background, no text), NOT a horizontal wordmark and NOT an iOS-squircle app icon with a baked-in background. When auto-picking from a bundle/zip that contains multiple variants, prefer filenames matching `*-symbol*`, `*-mark*`, `*-circular*`, or unframed `*-icon*`; reject filenames matching `*wordmark*`, `*logotype*`, `*-horizontal*`, `*-brand*` (often the wordmark), or `*-app-icon*` (often the iOS-squircle framed form). If only a wordmark variant is available, prompt the user to confirm before placing it — wordmarks are a known catalog-grid mismatch (see Stage 2's logo intake rule and the existing `public/logos/{toss,socar,baemin,...}.png` reference). This check applies inside step 1 below. Resolve **two** logo values before dispatching author agents — different downstream concerns need different forms: - **`logo_url`** — fully-qualified URL like `https://getdesign.kr/logos/toss.png`. Goes into design.md **frontmatter**, where it must stay meaningful when the file is copied outside the ko-design-md site (PRD User Story 1 — vibe-coding flow). - **`logo_src_path`** — site-relative path like `/logos/toss.png`. Goes into preview HTML `<img src>`, which is only ever loaded inside the catalog site's iframe. Keeping it relative avoids making dev/staging depend on the production-domain asset. Both either co-exist (logo found) or are simultaneously empty (no logo). The canonical site origin is **`https://getdesign.kr`**. Change this constant in one place only — this paragraph — if the origin ever moves. 1. If `logo_asset_path` was provided: - Verify it exists and has a supported extension (`svg`, `png`, `webp`, `avif`). - If it already lives under `${repo_root}/public/logos/`, set `logo_src_path = /logos/{basename}` and `logo_url = https://getdesign.kr/logos/{basename}`. - Otherwise copy it to `${repo_root}/public/logos/{slug}.{ext}` and set `logo_src_path = /logos/{slug}.{ext}` and `logo_url = https://getdesign.kr/logos/{slug}.{ext}`. This is allowed only for user-supplied local logo assets. 2. If no logo path was provided, auto-detect the first existing file in `public/logos/{slug}.{svg,png,webp,avif}` (in that order) and set `logo_src_path = /logos/{slug}.{ext}` and `logo_url = https://getdesign.kr/logos/{slug}.{ext}`. 3. If nothing is found, set both to an empty string and continue. The entry may ship without a logo, but Stage 13 must report the missing logo TODO. 4. **Optional wordmark / logotype for the preview hero.** If a wordmark variant was captured at Stage 2 (a horizontal lockup that contains the brand name as text — e.g. `logo-brand.png`, `*-logotype.svg`), copy it to `${repo_root}/public/logos/{slug}-logotype.{ext}` and set `logo_wordmark_src_path = /logos/{slug}-logotype.{ext}`. If no wordmark was captured at intake but a file already exists at `public/logos/{slug}-logotype.{svg,png,webp,avif}`, auto-detect it (same precedence order as the symbol). Otherwise set `logo_wordmark_src_path = ""`. There is NO frontmatter URL for the wordmark — it is a site-internal preview-only asset; the design.md `logo` field always references the symbol so the file remains portable outside ko-design-md. The auto-detect pattern for the catalog grid logo is exactly `public/logos/{slug}.{svg,png,webp,avif}`; for the optional wordmark it is `public/logos/{slug}-logotype.{svg,png,webp,avif}`. When the symbol values are non-empty, every later stage must preserve them exactly — design-md-author writes `logo_url` verbatim into frontmatter, preview-html-author embeds `logo_src_path` as `<img src>` (or `logo_wordmark_src_path` in the hero when that is non-empty), and the Stage 10 grep checks match each file against the appropriate form. ### Stage 4b — Docs-site crawl (conditional) If `docs_site_url` is empty or "없음", skip this stage and set `crawl_corpus_path = "none"`. Otherwise, crawl the brand's documentation site into the cache directory so research-collector can use it as a primary source. This runs the `docs-crawler` skill's engine — a sitemap-driven crawl with a JS-render fallback that also localizes images (external and inline base64) into `crawl/images/`, so the cached corpus is self-contained: ```bash cd "${repo_root}" && pnpm crawl:docs "${docs_site_url}" --out "${repo_root}/.claude/cache/design-md/{slug}" ``` The crawl writes `crawl-corpus.md` (the merged corpus) plus `crawl/pages/*.md`, the downloaded `crawl/images/`, and `crawl/manifest.json` into the (gitignored) cache directory. The first crawl of a JavaScript-rendered site auto-installs a headless browser (~150MB, one-time). After it returns, verify the corpus landed: ```bash [ -s "${repo_root}/.claude/cache/design-md/{slug}/crawl-corpus.md" ] && echo CORPUS_OK || echo CORPUS_MISSING ``` - `CORPUS_OK` → set `crawl_corpus_path = ${repo_root}/.claude/cache/design-md/{slug}/crawl-corpus.md`. - `CORPUS_MISSING`, or the crawl exited non-zero → the crawl failed. It is best-effort: research can still proceed from `source_urls`. `AskUserQuestion`: "문서 사이트 크롤 실패 — (a) 다시 시도 / (b) 크롤 없이 진행 / (c) 취소". On "다시 시도" re-run the crawl; on "크롤 없이 진행" set `crawl_corpus_path = "none"`; on "취소" abort with the resume path. ### Stage 4c — Design board checkpoint (conditional) Run this stage when `design_board_paths` from Stage 2 is non-empty; otherwise skip it and go to Stage 5. That variable is the trigger — the condition is not a judgment call. **Preflight**: for each path, `Bash`: `[ -e "$path" ]`. Surface any missing path and re-prompt, the same as the screenshot preflight. Then `Read` the frames (image paths read as images; a directory is listed and its files read). Show what the board settled and what it left open: the palette in both themes, the type scale, the components it laid out, and anything it declined to define. Then `AskUserQuestion`: "디자인 보드를 확인해 주세요 — (a) 승인하고 리서치로 / (b) 보드를 고치고 다시 / (c) 취소". On (b), the user's corrections go back to the board; nothing downstream is generated until it is approved. **On approval, carry the board forward — the gate is worthless if the values stop here.** Append the approved **image files** to `screenshot_paths` before Stage 5 dispatches, reusing the listing the preflight already produced: a path that was a directory is expanded to the files inside it, and the directory string itself is never appended. research-collector's contract is "an array of local image paths to read" and its tools are `WebFetch, WebSearch, Read, Write` — it has no `Bash` to list a directory, and `Read` cannot open one, so a directory handed over here is a source that silently never gets read. Without this step Stage 5 receives only `source_urls` and the crawl corpus, and the run rebuilds from public research alone while the approval implies otherwise. This is what puts the board upstream of `research.md`, and through it upstream of the draft, the preview, the sidecar and the OG image. The asymmetry is what justifies a second gate. Approving a wrong board costs the entire run, because every later artifact is rebuilt from it — and the Stage 7 checkpoint cannot recover it, since by then the draft has already transcribed the wrong values and reads as internally consistent. Approving a right one pays off at Stage 12: on `remember` the board's twenty role colors survived the whole chain into the preview unchanged, and the one discrepancy the comparison found was not a value but **where** a value had been applied — which no gate in this pipeline looks at. An entry researched from public sources alone has no board to approve and goes straight to Stage 5. ## Stage 5 — Research (research-collector) Dispatch via `Agent` tool with `subagent_type: "research-collector"`. Pass this prompt: ``` Research the brand "{brand_name}" (slug: {slug}) for ko-design-md catalog onboarding. source_urls: {comma-separated URLs} screenshot_paths: {comma-separated paths or "none"} crawl_corpus_path: {crawl_corpus_path from Stage 4b — absolute path to crawl-corpus.md, or "none"} category: {category} lang: ko cache_dir: {absolute path to .claude/cache/design-md/{slug}/} Follow your agent definition. If crawl_corpus_path is not "none", read that corpus first as your primary source. Write exactly one file at {cache_dir}/research.md with the cited-claims structure. Halt with INSUFFICIENT_SOURCES only if crawl_corpus_path is "none" AND fewer than 2 URLs return 2xx. ``` After the agent returns, `Read` `{cache_dir}/research.md`. - If the first line of `## Sources` is `INSUFFICIENT_SOURCES`, surface this to the user via `AskUserQuestion` with options: "URL 추가 입력" / "스크린샷 경로 추가" / "취소". On URL/screenshot addition, re-dispatch research-collector with the augmented inputs. - **Section sanity check**: `Bash`: `grep -c '^## ' {cache_dir}/research.md`. Expected output is `9` (one per documented H2 section). If less than 9, the agent silently produced a malformed file — re-dispatch with an instruction prefixed: "Your previous research.md was malformed (only N sections found). Produce ALL 9 H2 sections in the documented order, even if some are `(no public evidence found)`." - Otherwise, proceed. ## Stage 6 — Draft + design.md review loop Iteration counter `N = 1`. Loop: ### 6a. Dispatch design-md-author Via `Agent` with `subagent_type: "design-md-author"`. Pass: ``` Author a Stitch v0.1-format design.md draft for "{brand_name}". cache_dir: ${repo_root}/.claude/cache/design-md/{slug}/ slug: {slug} name: {brand_name} category: {category} lang: ko today: {today as YYYY-MM-DD} logo_url: {logo_url or "none"} research_path: ${repo_root}/.claude/cache/design-md/{slug}/research.md prior_review_path: ${repo_root}/.claude/cache/design-md/{slug}/review-{N-1}.json or "none" on first pass format_reference_path: ${repo_root}/.claude/skills/design-md/references/stitch-format.md template_path: ${repo_root}/.claude/skills/design-md/references/design-md-template.md demo_paths: (none — leave empty by default; pass an existing ${repo_root}/services/*.md only if a stylistic peer genuinely fits the new brand. The early _demo-*.md fixtures have been removed.) Follow your agent definition. Write {cache_dir}/draft.md. ``` After return, verify `{cache_dir}/draft.md` exists and is non-empty. If missing, the author failed — log the issue, retry once with the same prompt; if still missing, abort with a diagnostic message. ### 6a2. Deterministic draft gate (machine validation) Before spending a reviewer dispatch, run the draft validator — it covers every mechanically checkable rubric item (frontmatter round-trip, section presence/order, OKLCH-only token values, `[src:N]`/References integrity, expected logo) so the reviewer model never has to "grep mentally": ```bash cd "${repo_root}" && pnpm validate:draft .claude/cache/design-md/{slug}/draft.md \ --slug {slug} --expected-logo {logo_url or none} --lang ko \ --iteration {N} --json-out "${repo_root}/.claude/cache/design-md/{slug}/review-machine-{N}.json" ``` - **Exit 0** → proceed to 6b, passing the machine report path (see the 6b prompt). - **Exit 1** (block issues) → do NOT dispatch the reviewer. Re-dispatch 6a with `prior_review_path` = the `review-machine-{N}.json` above (its `issues[]` uses the same `severity`/`section`/`fix` shape the author already consumes). Machine retries use a sub-counter **K (max 2) and do not increment N** — machine fixes are cheap and must not consume the semantic-review budget. - **K exhausted with blocks remaining** → dispatch 6b anyway; the reviewer receives the failing machine report and the normal loop/checkpoint rules take over (no new termination path). ### 6b. Dispatch design-md-reviewer Via `Agent` with `subagent_type: "design-md-reviewer"`. Pass: ``` Score the draft.md at {cache_dir}/draft.md against the rubric. cache_dir: {abs path}/.claude/cache/design-md/{slug}/ draft_path: {cache_dir}/draft.md research_path: {cache_dir}/research.md content_types_path: {abs path}/src/lib/content-types.ts rubric_path: {abs path}/.claude/skills/design-md/references/rubric-design.md expected_logo_url: {logo_url or "none"} machine_report_path: {cache_dir}/review-machine-{N}.json iteration_n: {N} output_path: {cache_dir}/review-{N}.json Follow your agent definition. Write exactly one file at output_path. The machine report has already verified the deterministically checkable items (frontmatter round-trip, section presence/order, hex/rgba token scan, [src:N]/References integrity, expected logo). Do not re-verify those — spend your review on judgment items: Brand fidelity semantics against research.md, Voice/tone, and cross-section token contradictions. ``` After return, `Read` `{cache_dir}/review-{N}.json`. ### 6c. Loop decision - If `review.passed && review.score >= 8` → exit loop, go to Stage 7. - Else if `N < 3` → `N += 1`, go back to 6a (the author will read review-{N-1}.json and revise). - Else (`N == 3` and not passed) → exit loop with a `warn` flag; go to Stage 7. The user will see the failed verdict at the checkpoint and decide. ## Stage 7 — User checkpoint This is the gate **every** entry passes through — Stage 4c precedes it only when a design board is upstream. Show the user: 1. The current `draft.md` content (read it and display the full file inline, formatted as markdown — paste in code fences). 2. The latest `review-{final}.json` verdict — extract `score`, `passed`, `verdict`, and bullet the issues array. 3. If iteration > 1, show a brief diff highlight: `"Iter 1 score: X → Iter {final} score: Y"` plus the top 1–2 issues that improved between iterations (compare `review-1.json.issues` and `review-{final}.json.issues`). Then `AskUserQuestion`: | Option | Effect | |---|---| | "승인하고 계속" | Approve as-is. Proceed to Stage 8. | | "수정 사항 알려주고 한 번 더" | User provides feedback in the "Other" custom input. Append the user's notes to the prior review-N.json's issues array (with `severity: block`) and re-dispatch the author for one more revision. After this extra revision, run the reviewer once more, then return to checkpoint with the new draft. | | "취소" | Abort. Cache dir is left intact. Print: "취소되었습니다. 재개하려면 cache 디렉터리에서 작업을 이어가세요: `.claude/cache/design-md/{slug}/`" | ## Stage 8 — Write design.md to services/ After approval: 1. `Bash`: `cp ${repo_root}/.claude/cache/design-md/{slug}/draft.md ${repo_root}/services/{slug}.md` 2. `Read` the placed file to confirm content arrived intact. 3. **Generate the token sidecar** — `Bash`: `pnpm tokens:build {slug}` extracts `services/{slug}.tokens.json` from the design.md you just placed. This is the visual design-token data (colors / typography / spacing / radius / elevation) that drives the detail page's **Tokens** tab (`src/routes/services/$slug.tsx:257`); `src/lib/content-collection.ts` loads it as `doc.tokens` (runtime is a plain `JSON.parse`, no markdown parsing). Inspect the printed `Nc Nt Ns Nr` line (a trailing `Ne` appears when `## Elevation & Depth` publishes shadow values). The extractor reads the frontmatter token maps (`colors:` / `typography:` / `spacing:` / `rounded:`) and markdown tables, one token per line — `name: oklch(...)` (colors), `name: 16px` (spacing/rounded). **Typography is the exception: it nests.** A bare style name on its own line, then four-space `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing`. The inline `name: { size, weight, line-height }` and `name: 16 / 24 / 700` forms are read only from markdown tables and legacy body fences — written into frontmatter they yield **zero** type tokens, and `tokens:check` then agrees with the empty sidecar it generated from them. **Semantic aliases** (`{colors.x}`, bare references like `fill-brand: blue-500`) are intentionally excluded — they stay in the prose only. If **any** of the four counts is unexpectedly `0`, that `## Colors / Typography / Spacing / Rounded` section isn't in a codegen-readable form. **`Ne` is exempt from that rule** — it is absent whenever the entry's Elevation section carries usage labels or z-indices instead of shadow values (bezier and class101 are both legitimately shadow-less), so a missing `Ne` is only a signal when you authored real `box-shadow` values there. The deterministic path is to **route back to a Stage 6 draft revision** with a blocking prior-review issue naming the unreadable section (a human operator running the skill by hand may instead fix the section directly), then re-run `pnpm tokens:build {slug}` so the entry ships with full token cards. If the `cp` itself fails (filesystem error), surface the error and route back to the checkpoint. ## Stage 9 — Preview HTML author + review loop Iteration counter `M = 1`. Same shape as Stage 6, dispatching `preview-html-author` and `preview-html-reviewer`. ### 9a. Dispatch preview-html-author ``` Build preview.html for "{brand_name}". cache_dir: {abs path}/.claude/cache/design-md/{slug}/ slug: {slug} name: {brand_name} lang: ko design_md_path: {abs path}/services/{slug}.md runtime_tokens_path: {abs path}/public/preview/_runtime/tokens.css runtime_iframe_path: {abs path}/public/preview/_runtime/iframe.js logo_src_path: {logo_src_path or "none"} logo_wordmark_src_path: {logo_wordmark_src_path or "none"} demo_html_paths: (none — leave empty by default; pass an existing {abs path}/public/preview/*/preview.html only if a visual peer genuinely fits. The early demo-courier/demo-pay previews have been removed.) prior_review_path: {cache_dir}/preview-review-{M-1}.json or "none" Follow your agent definition. Write {cache_dir}/preview.html. If `logo_wordmark_src_path` is not "none", render that wordmark `<img>` in the hero brand lockup (the hero has room for the brand name) and reserve `logo_src_path` (the small symbol) for compact references inside the component showcase, favicons, or chip-sized contexts. If `logo_wordmark_src_path` is "none", use `logo_src_path` in the hero too. Size hero `<img>` by `height` + `width: auto` so either aspect ratio (square symbol or horizontal wordmark) renders correctly. ``` ### 9a2. Deterministic preview gate (machine validation) Same shape as 6a2 — run the preview validator before spending a reviewer dispatch: ```bash cd "${repo_root}" && pnpm validate:previews \ --preview .claude/cache/design-md/{slug}/preview.html \ --design-md "${repo_root}/services/{slug}.md" \ --expected-logo-src {logo_src_path or none} \ --expected-wordmark-src {logo_wordmark_src_path or none} \ --iteration {M} --json-out "${repo_root}/.claude/cache/design-md/{slug}/preview-review-machine-{M}.json" ``` It hard-checks the structural rubric items (data-theme/lang, absolute runtime paths, foreign scripts, transfer size, hero logo src, catalog disclosure strip) **plus two content rules that used to be reviewer judgment** — a swatch catalog (fill-only elements per theme, Item 2) and a type-scale showcase (design.md typography token names printed as visible text labels, Item 3). Size is weighed in **brotli** bytes, the form Vercel actually serves, with a raw cap kept only as a safety net against runaway generated markup. It also emits warn-level responsive heuristics plus an `oklch coverage` metric (`matched/total` per theme) in `metrics`. - **Exit 0** → proceed to 9b, passing the machine report path. - **Exit 1** → do NOT dispatch the reviewer. Re-dispatch 9a with `prior_review_path` = the `preview-review-machine-{M}.json`. Machine retries use a sub-counter **K (max 2) and do not increment M**. - **K exhausted with blocks remaining** → dispatch 9b anyway; the normal non-blocking loop rules take over. ### 9b. Dispatch preview-html-reviewer ``` Score the preview HTML files at {cache_dir} against the rubric. cache_dir: {abs path}/.claude/cache/design-md/{slug}/ preview_path: {cache_dir}/preview.html design_md_path: {abs path}/services/{slug}.md rubric_path: {abs path}/.claude/skills/design-md/references/rubric-preview.md expected_logo_src_path: {logo_src_path or "none"} machine_report_path: {cache_dir}/preview-review-machine-{M}.json iteration_n: {M} output_path: {cache_dir}/preview-review-{M}.json Follow your agent definition. Write exactly one file at output_path. The machine report has already verified the structural Item 1 checks (data-theme, absolute runtime paths, foreign scripts, transfer size, hero logo src, catalog disclosure strip). Do not re-verify those — adopt the report's result for Item 1. It also carries two content blocks: a swatch catalog forces Item 2 to 0 and a type-scale showcase forces Item 3 to 0, adopted wholesale on the same rule and mirrored into `issues` as `block`. Spend the rest of your review on judgment: Color fidelity semantics (use the report's oklch coverage metric as the Item 2 input), Component coverage, Typography hierarchy, and dark-mode appropriateness. ``` ### 9c. Loop decision (non-blocking) - If `passed && score >= 8` → exit loop, go to Stage 10. - Else if `M < 3` → `M += 1`, go back to 9a. - Else (`M == 3` and not passed) → log the warning, exit loop, go to Stage 10 anyway. Preview review is non-blocking because visual previews iterate naturally during real use; the user already approved the design.md the preview is built from. ## Stage 10 — Write previews to public/ ```bash mkdir -p ${repo_root}/public/preview/{slug} cp ${repo_root}/.claude/cache/design-md/{slug}/preview.html ${repo_root}/public/preview/{slug}/preview.html ``` ### Logo deterministic check If the resolved logo values are non-empty, verify the placed main markdown contains the absolute URL form and the preview HTML contains the site-relative form: ```bash # Markdown: frontmatter `logo` is the symbol's absolute URL (portable across copies of the file). rg -q -F "logo: {logo_url}" "${repo_root}/services/{slug}.md" || echo "LOGO_MISSING_MD" # Preview HTML hero src: when the wordmark exists, the hero uses the wordmark; otherwise the symbol. # `{logo_wordmark_src_path}` is the literal string "none" when no wordmark was captured — # `${var:-fallback}` would treat "none" as a non-empty value and skip the fallback, so use an # explicit conditional that handles both "none" and the empty case. HERO_SRC="{logo_wordmark_src_path}" if [ "$HERO_SRC" = "none" ] || [ -z "$HERO_SRC" ]; then HERO_SRC="{logo_src_path}" fi rg -q -F "src=\"${HERO_SRC}\"" "${repo_root}/public/preview/{slug}/preview.html" || echo "LOGO_MISSING" ``` If any sentinel prints, do not proceed to Stage 11. If the markdown is missing the logo, re-run Stage 6a with a blocking prior-review issue that says `logo_url` must appear as frontmatter `logo` (the exact fully-qualified URL — not a site-relative shortcut). If the preview is missing the hero logo, re-run Stage 9a with a blocking prior-preview issue that says the exact `HERO_SRC` (site-relative form — wordmark when defined, else symbol) must render as an `<img src>` in the shared markup — not inside a theme variant. ### Catalog disclosure deterministic check `validate:previews` already blocks on this at 9a2, so reaching here with a missing strip means the file changed after the gate. Re-check the placed file: ```bash F="${repo_root}/public/preview/{slug}/preview.html" rg -q -F 'class="catalog-disclaimer"' "$F" || echo "DISCLAIMER_MISSING" rg -q -F '제휴·후원 관계가 없습니다' "$F" || echo "DISCLAIMER_NO_NONAFFILIATION" rg -q -F '더미 데이터' "$F" || echo "DISCLAIMER_NO_DUMMYDATA" rg -qU '<body>(\s|<!--[\s\S]*?-->)*<div class="catalog-disclaimer"' "$F" || echo "DISCLAIMER_MISPLACED" ``` `-U` (multiline) is required for the placement check — the strip sits on the line *after* `<body>`, so a line-scoped match never sees both. The comment alternation matches `DISCLAIMER_FIRST_CHILD` in `src/lib/preview-validator.ts`: a comment between `<body>` and the strip is still a strip-first document, and flagging it here would send Stage 9a back to fix markup that 9a2 already passed. **These sentinels are deliberately stricter than the validator on one axis: quoting.** `preview-validator.ts` accepts `class='catalog-disclaimer'` because it also validates hand-edited and pre-existing files. Stage 10 re-checks *pipeline output*, where `.claude/agents/preview-html-author.md` prescribes the strip **byte for byte** with double quotes — so `-F` on the verbatim form is the point, not an oversight. A single-quoted strip reaching here means the author deviated from a verbatim instruction, which is worth a Stage 9a round. Do not "fix" this by loosening it to match the validator. The two phrase sentinels are literal (`-F`) for the same reason: the non-affiliation sentence is shared verbatim with `src/components/site/footer.tsx` and pinned by `src/lib/license-notice-consistency.test.ts`. If any `DISCLAIMER_*` sentinel prints, do not proceed to Stage 11. Re-run Stage 9a with a blocking prior-preview issue quoting the verbatim strip from `.claude/agents/preview-html-author.md` and stating it must be the first child of `<body>` — in the shared markup, not inside a theme variant. ### Preview token alias registration The moment the previews land in `public/`, the OKLCH drift gate starts expecting this slug to be accounted for. Run it: ```bash cd "${repo_root}" && pnpm test src/lib/oklch-drift-corpus.test.ts ``` That gate compares each preview's `--custom-property: oklch(…)` declarations against the `name: oklch(…)` definitions in `services/{slug}.md`, by **exact name**. Previews almost always namespace their variables (`--tds-blue-500` for md `blue-500`), so each slug declares a rewrite rule in `PREVIEW_TOKEN_ALIASES` (`src/lib/oklch-drift.ts`). Without one, every declaration in this preview is unreachable and the gate silently checks nothing for the new entry — which is what the corpus test refuses to let happen. **Stage 9a2's `oklch coverage` metric does not cover this.** It searches the HTML for the design.md's OKLCH *values* as substrings; it never looks at custom-property *names*, and `src/lib/preview-validator.ts` does not consult the drift gate at all. A fully namespaced preview can score 100% coverage at 9a2 and still match zero declarations here. **And no gate here looks at WHERE a value is painted.** The drift gate compares a preview's `--custom-property: oklch(…)` *declaration* against the md's definition of the same name. `pnpm audit:oklch` compares an OKLCH literal against the hex annotated beside it. Both answer *"is this value right?"*; neither answers *"is it on the right element?"* — `var(--x)` usage sites are not scanned by either. remember declared its surface tokens correctly (light 10 and dark 10 matched the design boards 20/20) and painted one of them on six cards where the research had observed a single 550px column, at `radius-md` where the observation said 4px. Every gate was unanimous: `audit:oklch` 0 mismatched, the drift gate matched, `validate:previews --slug remember` 0 blocking 0 warning. Checking the application site means opening the research cache or the brand publication and comparing element by element — the Stage 12 board cross-check's work and a human's, not a gate's. **Do not read a clean `audit:oklch` as "the preview is faithful".** Three rule shapes are in use, all measured against real entries: | md name | preview name | rule | |---|---|---| | `blue-500` | `--tds-blue-500` | `["", "tds-"]` — prepend | | `ldsg-color-linegreen` | `--ldsg-linegreen` | `["ldsg-color-", "ldsg-"]` — replace | | `gray0` | `--g-gray-0` | `[["gray", "g-gray-"], ["", "g-"]]` — one family differs from the rest | Rules are tried in order, first match wins, and an empty `from` matches anything so it belongs last. If the test fails with `these slugs have no entry in MATCH_FLOOR`, add the rule, then record the count the failure message prints into `MATCH_FLOOR` (`src/lib/oklch-drift-corpus.test.ts`) — that table is a per-slug floor, so it also has to be raised deliberately rather than guessed. Recording a bare `0` is refused: only `bezier` is entitled to it, because its preview is hex rather than OKLCH and no naming rule can reach it. Both files are outside this skill's write scope, so this does not route back to Stage 9a — a human operator running the skill by hand makes these two edits directly. Skipping them does not corrupt the entry; it leaves the drift gate blind to it, and CI fails on the pull request rather than here. ### Token coverage row The same moment creates a row this entry owes to `src/lib/token-coverage.test.ts`. Run it: ```bash cd "${repo_root}" && pnpm test src/lib/token-coverage.test.ts ``` That file pins, per entry, how many `name: oklch(…)` definitions each token gate can see — exact in both directions, because a count that rises can mean a reader widened, not that tokens were added. A new entry fails it with `these entries have no row in TOKEN_COVERAGE`, and the message prints the row itself: paste that line into `TOKEN_COVERAGE` at its sorted position, then read it. `drift: 0` is refused (the drift gate would have no md-side name to compare for the whole entry — fix the frontmatter token map instead), while `annotated: 0` can be right (an entry that comments its colours in prose with no hex, as baemin and toss do). Nothing else in the repo prints these numbers, so do not guess them. A row per entry is what lets two catalogue pull requests be open at once without the second one failing on the first one's merge (#324). This file is outside the skill's write scope too: the operator makes the edit by hand, next to the `MATCH_FLOOR` row above. ### The rest of the per-slug rows Four more tables owe this entry a row, and none is reachable from this stage — each fails in CI on someone who never saw it. `CLAUDE.md` 「카탈로그 정책」 carries the full list; the ones not already covered above are: - **`BASELINE_TABLE`** (`src/lib/contrast-baseline.ts`) — four rows per slug, mirrored byte for byte into `docs/preview-contrast-baseline.md`. `pnpm gate:contrast` prints the rows on failure, and it drives a browser, so CI is the authority on the numbers. - **The `NOTICE` asset inventory** — any file Stage 4a placed under `public/logos/` owes a row. `license-notice-consistency.test.ts` compares both directions. - **The missing-primary list** (`src/lib/google-designmd-corpus.test.ts`) — only when the entry has no token literally named `primary`. Do not invent one to avoid the row. - **`KNOWN_SPEC_LIMITATIONS`** (same file) — only when the entry publishes a `%` radius or a multi-stop gradient, the two places the catalog is more expressive than the published spec. It is a **two-way ratchet** on the slug's error count, so a later silent fix fails it too. Record the count; do not flatten the value to satisfy the linter. All are outside this skill's write scope: the operator edits them by hand, the same as the two above. ## Stage 11 — Build OG image ```bash cd "${repo_root}" && pnpm build:og ``` After the command: **If exit non-zero**: capture stderr. Likely cause is frontmatter that `buildDoc` in `src/lib/content-parser.ts` rejects after slipping past the 6a2 gate (e.g. a hand edit at the Stage 7 checkpoint): a `last_updated` / `created_at` that is not a real `YYYY-MM-DD` date, or an `estimated_tokens` that is not a number. An off-enum category does not fail `build:og` (`buildDoc` falls back to `etc`); once the draft is past 6a2, only `pnpm validate:catalog` catches it, so run it before finishing. Surface stderr via text. Offer via `AskUserQuestion`: "frontmatter 직접 수정 후 재시도" (open `${repo_root}/services/{slug}.md` for editing; on user confirmation that they've edited, re-run `pnpm build:og` and re-validate — loop up to 3 retries), "취소 (파일 유지)" (partial state is acceptable since the index works without an OG image — the route falls back per `build-og.ts`). Do NOT auto-rollback the placed .md. After 3 failed retries, fall through to "취소" with a diagnostic message. **If exit zero**: validate the OG output (catches the corrupt-PNG silent failure mode that happens on satori panic): ```bash [ -s "${repo_root}/public/og/{slug}.png" ] || echo "OG_EMPTY" file "${repo_root}/public/og/{slug}.png" | grep -q PNG || echo "OG_NOT_PNG" ``` If either check prints its sentinel, treat as the same error path as a non-zero exit (surface, ask the user, do not auto-rollback). ## Stage 12 — Verification (preview MCP) Start the dev server and confirm the new entry renders correctly. This is the strongest end-to-end check. 1. **Port preflight**: `Bash`: `lsof -i :3000 -t 2>/dev/null`. If the output is non-empty, port 3000 is already in use (the user has a dev server running). `AskUserQuestion`: "포트 3000이 사용 중입니다 — (a) 기존 서버 종료 후 재시작 / (b) 검증 단계 건너뛰기 / (c) 취소". On "건너뛰기", skip to Stage 13 with a `verification_skipped: port_collision` flag in the report. 2. `Bash` (run_in_background): `cd "${repo_root}" && pnpm dev` — runs on port 3000. 3. **Server readiness poll**: `Bash`: `for i in $(seq 1 30); do curl -sf http://localhost:3000 -o /dev/null && echo READY && break; sleep 0.5; done`. The dev server takes a few seconds to bind; without this poll, `preview_start` may hit a connection refused before Vite is up. If the loop completes without printing `READY`, fall through to the `curl` fallback at the end of this stage. 4. `mcp__Claude_Preview__preview_start` with URL `http://localhost:3000/services/{slug}`. 5. `mcp__Claude_Preview__preview_eval`: `document.title` — should contain `{brand_name}` and `ko/design.md`. Then confirm the **token-card section** loaded from the Stage 8 sidecar: `preview_eval`: `document.querySelector('[aria-label="Design tokens"]')?.querySelector('p')?.textContent ?? 'MISSING'` — should return the count badge (`{N} Colors · {N} Type · …`). `MISSING` means `services/{slug}.tokens.json` is absent or failed `coerceServiceTokens`; verify it exists and is valid JSON before continuing. 6. `preview_eval` against the iframe: confirm `document.querySelector('iframe')?.src` contains `/preview/{slug}/preview.html`, and that the iframe document's `data-theme` matches the route default (`src/routes/services/$slug.tsx:97`) — one file serves both themes now. 7. `preview_screenshot` once on the default tab. 8. `preview_eval`: navigate to `?tab=md` and confirm DESIGN.md tab renders the syntax-highlighted markdown. 9. `preview_screenshot` once on the `?tab=md` view. 10. **Agent endpoint check**: `Bash`: `curl -sf -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:3000/services/{slug}/llms.txt` — expect `200 text/plain; charset=utf-8`. This raw-markdown sibling route reads from `services/{slug}.md` directly, so a failure here means either the file wasn't placed correctly or the project's `/services/$slug/llms.txt` route regressed. Surface non-200 output to the user before stopping the server. 11. **Responsive sweep (mobile / tablet / desktop)** — confirm the preview demo doesn't break at narrow widths (the regression class hotfixed in PR #77). Load each demo **top-level**, not through the width-constrained detail-page iframe. Sweep **both themes** of the one file: load it, then run the probe once with `data-theme="light"` and once with `"dark"`. They share layout, but PR #77's lesson is that a fix can miss one theme, and the two scopes can still size text differently. `preview_eval`: `window.location.href = "http://localhost:3000/preview/{slug}/preview.html"`, and set the theme with `document.documentElement.setAttribute('data-theme', '{theme}')` before each probe, then at **375 (mobile) / 768 (tablet) / 976 (detail-page embed width — the historical blind spot where multi-column cells are tightest, see PR #150) / 1440 (desktop)** × 900 run `mcp__Claude_Preview__preview_resize` to `{width} × 900` followed by `preview_eval` of the overflow probe: ```js (() => { const d = document.documentElement; const vw = d.clientWidth; const overflowPx = d.scrollWidth - vw; const broken = overflowPx > 1; // Collect culprits only when the document actually overflows, AND skip any element // inside an overflow-x: hidden/auto/scroll ancestor: an intentional horizontally- // scrollable row (chip row, carousel) reports right > vw without extending the // document, so it is not the break even when another element broke the page. const culprits = !broken ? [] : Array.from(document.querySelectorAll('body *')) .filter(el => { const r = el.getBoundingClientRect(); if (r.right <= vw + 1 && r.left >= -1) return false; for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const ox = getComputedStyle(p).overflowX; if (ox === 'hidden' || ox === 'auto' || ox === 'scroll') return false; } return true; }) .slice(0, 6) .map(el => { // getAttribute('class') works for SVG too (el.className is an SVGAnimatedString there). const cls = (el.getAttribute('class') || '').trim(); return el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\s+/).slice(0, 2).join('.') : ''); }); return { viewport: vw, scrollWidth: d.scrollWidth, overflowPx, broken, culprits }; })() ``` `broken = overflowPx > 1` (1px tolerance) means a horizontal scrollbar — a broken responsive layout. The check keys on the **document** `scrollWidth`, so an element inside an intentional horizontally-scrollable row (a chip row, a carousel) does not trip it. `culprits` (collected only when `broken`, and excluding any element nested in an `overflow-x` scroll/clip ancestor) lists the genuinely page-extending elements (e.g. `.comp-card`, `.ftile`) so the fix can be targeted. Collect every `broken` result as `breaks[] = {file, width, overflowPx, culprits}`. **Auto-fix loop** (≤ 2 attempts) when `breaks` is non-empty — reuses the Stage 9a dispatch and the Stage 10 copy, so there is no new mechanism: 1. Write a synthetic review at `{cache_dir}/preview-review-resp-{attempt}.json` whose `issues[]` carries one `severity: "block"` entry per break: `{"severity":"block","section":"responsive — {theme} @{width}px","fix":"Horizontal overflow {overflowPx}px at {width}px. Offending: {culprits}. Repair per the author's Responsive & mobile-overflow guard — the usual root cause is a bare 1fr grid track flooring at its content min-content, so switch content tracks to minmax(0, 1fr), add a mobile grid-collapse @media rule, and put min-width: 0 on items wrapping fixed-width children. A layout fix belongs in the shared rules, not duplicated per theme scope."}`. 2. Dispatch `preview-html-author` exactly as in **Stage 9a**, with `prior_review_path` = that JSON. The author rewrites `{cache_dir}/preview.html`. 3. Re-copy staging → public with the **Stage 10** `cp` commands (the Stage 10 logo deterministic check still applies). 4. `preview_eval`: `window.location.reload()` (the `/preview/*` `no-cache` header serves the fresh file), then re-run the sweep. Stop when `breaks` is empty or after 2 attempts. **Result**: if `breaks` is empty → `responsive_result = ok` (record the attempt count). If still non-empty after 2 attempts → `preview_screenshot` at the narrowest failing width for evidence and set `responsive_result = warn` with the residual `breaks`. Non-blocking either way (consistent with the non-blocking preview loop). The OG image is derived from design.md, so re-fixed previews do **not** trigger an OG rebuild. (The port-collision path never reaches this step — it returns to Stage 13 at step 1 — so handling for that case lives in the Stage 13 report, not here.) 12. **Design-board cross-check (conditional)** — run when `design_board_paths` from Stage 2 is non-empty; otherwise set `board_result = skipped (no board)` and go to step 13. Stage 4c approved the board *before* anything was generated; this is the other half of that gate — the finished preview measured back against it. The question is **not whether the values are right**. `pnpm audit:oklch` and the drift gate already answered that, and on `remember` they answered it correctly: the board's twenty role colors reached the preview 20/20 unchanged. The question is **where each value landed**, which no gate in this pipeline asks — see **Preview token alias registration** in Stage 10, where that blind spot is stated against `audit:oklch` and the drift gate. `Read` the board frames from `design_board_paths` again. The preview is still loaded top-level from step 11; `preview_resize` back to `1440 × 900` and run this probe once per theme, setting `data-theme` the same way step 11 does: ```js (() => { const seen = new Map(); for (const el of document.querySelectorAll('body *')) { const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) continue; const cs = getComputedStyle(el); for (const prop of ['backgroundColor', 'color', 'borderTopColor']) { const v = cs[prop]; if (!v || v === 'transparent' || v === 'rgba(0, 0, 0, 0)') continue; const key = prop + ' ' + v; const bucket = seen.get(key) ?? []; const cls = (el.getAttribute('class') || '').trim(); bucket.push({ el: el.tagName.toLowerCase() + (cls ? '.' + cls.split(/\s+/).slice(0, 2).join('.') : ''), w: Math.round(r.width), h: Math.round(r.height), radius: cs.borderRadius, }); seen.set(key, bucket); } } return [...seen] .map(([k, v]) => ({ k, count: v.length, sample: v.slice(0, 3) })) .sort((a, b) => b.count - a.count); })() ``` Buckets are keyed on the **serialized computed string, whatever the browser emits for it** — Chromium keeps `oklch()` colors in their own space rather than converting to `rgb()`, and that format is not stable across versions. You are matching buckets to board tokens by reading them, not by string-comparing against the md, so the format does not matter. For each token the board defines, check three things against the board frame and `{cache_dir}/research.md`: **which element carries it**, **how many of them**, and **that element's radius and box size**. A discrepancy is a token whose bucket disagrees on any of the three. That is exactly the shape of the only thing this comparison has ever caught — on `remember` one surface color was painted on **six** cards where the research had observed a single **550px** column, at `radius-md` where the observation said **4px**, while every gate was green. **Record, do not auto-fix.** Set `board_result = ok` when no bucket disagrees, or `board_result = discrepancies` carrying one line per finding (`{token} — 보드/관측: {expected}, 프리뷰: {observed}`). Do not route back into the Stage 9a loop the way the responsive sweep does: that loop's input is a layout rule the author can apply blind, whereas this finding is a judgment about a brand publication the author cannot see, and a wrong auto-fix repaints a correct value onto a different wrong element. Stage 13 reports it and the person decides. 13. **Stop the dev server**: `Bash`: `kill $(lsof -t -i:3000) 2>/dev/null || true`. Killing by port is portable across macOS/Linux and avoids accidentally killing other `pnpm` processes the user might be running. The `|| true` keeps the skill from aborting if the process already exited. If preview MCP tools are unavailable, fall back to `Bash`: `curl -sf http://localhost:3000/services/{slug} | grep -q '<iframe'` — non-zero exit means the page failed to render. The responsive sweep (step 11) requires the preview MCP tools; without them, note `responsive_result = skipped (no preview MCP)` in the report, and the board cross-check (step 12) the same way as `board_result = skipped (no preview MCP)`. ## Stage 13 — Final report and cleanup Print a summary message containing: - Files written (with absolute paths): - `services/{slug}.md` - `services/{slug}.tokens.json` (visual design-token sidecar → detail-page card view) - `public/preview/{slug}/preview.html` - `public/og/{slug}.png` (from `pnpm build:og`) - Surfaced URLs (paths only — host depends on env): - `/services/{slug}` — HTML detail page (Live Preview · Tokens · DESIGN.md tabs; the Tokens tab appears only when the sidecar carries tokens). - `/services/{slug}/llms.txt` — raw `text/plain` design.md (frontmatter + body) for LLMs / agents to fetch directly. Discoverable via `<link rel="alternate" type="text/plain">` on the HTML page. - Final review scores: design `{score}/10`, preview `{score}/10`. - Screenshots taken during verification (paths or inline). - Responsive verification (Stage 12 sweep) — pick the line by state: - `responsive_result = ok` → `반응형: ✅ 375/768/976/1440 가로 오버플로 없음 (자동수정 {attempts}회)` - `responsive_result = warn` → `반응형: ⚠️ 잔여 오버플로 — {file} @{width}px {overflowPx}px, 요소 {culprits} (스크린샷 {path}, 자동수정 2회 후 잔존)` - skipped — set when `verification_skipped: port_collision` (step 1 returned early, so `responsive_result` was never assigned) **or** `responsive_result = skipped` (preview MCP unavailable) → `반응형: ⏭ 검증 건너뜀 (포트 충돌 / preview MCP 없음)` - Design-board cross-check (Stage 12, after the responsive sweep) — pick the line by state: - `board_result = ok` → `보드 대조: ✅ 보드 토큰의 적용 위치·개수·형상이 관측과 일치` - `board_result = discrepancies` → `보드 대조: ⚠️ 적용 위치 불일치 {N}건 — {finding 한 줄씩}` - `board_result = skipped (no board)` → `보드 대조: ⏭ 보드 없음 — 공개 자료만으로 만든 항목이라 대조할 상류가 없습니다` - `board_result = skipped (no preview MCP)`, or step 1 returned early on `verification_skipped: port_collision` so `board_result` was never assigned → `보드 대조: ⏭ 검증 건너뜀 (포트 충돌 / preview MCP 없음)` **No two of the four may print the same line** — that is what the contract test pins. Three of them are where it actually happens: `discrepancies` carries a ⚠️ and is never mistaken for silence, while the other three all look like nothing-to-see. "보드가 없었다" · "대조했고 어긋난 것이 없었다" · "대조가 아예 안 돌았다" 는 서로 다른 사실이고, 이 저장소에서 반복적으로 같은 침묵으로 보고돼 왔다. 특히 `skipped` 를 침묵으로 처리하면 사람은 초록으로 읽는다. - Leftover TODOs: - If the logo values are empty: "Logo asset: `public/logos/{slug}.svg|png|webp|avif` 가 아직 없습니다. 직접 추가한 뒤 frontmatter `logo: https://getdesign.kr/logos/{slug}.{ext}` (절대 URL, 외부 복사 대비) 를 채우고 preview HTML에는 `<img src=\"/logos/{slug}.{ext}\">` (site-relative, iframe 전용) 형식으로 렌더링하세요." - **Every `warn` in the final preview review, whatever the score.** The rubric's three advisory sections — `Mobile overflow`, `Dummy-data labelling`, `Explanatory prose` — add no points by design, so a preview can carry all of them and still pass 9c's `score >= 8` on the first iteration and exit without the author ever seeing the review. Reporting them only when iteration 3 fell short drops them in exactly the case they exist for: `remember` scored 10/10 with 61% of its rendered text restating the design.md. List each one's `section` and `fix` verbatim. If the list is empty, say so — an absent line reads as "none found" whether or not the check ran. - **What is left for the person to look at.** Close the report by saying the loops are already done — the draft gate and review (6a2/6b), the preview gate and review (9a2/9b) and the Stage 12 sweep have all run — and then name what they do not cover, so the user spends their pass on the residue instead of re-checking what a machine just checked. On `remember` every gate was green when the user found four things: the preview was more than half explanat -
trigger-eval-queries.json 3 KB
[ { "query": "토스를 ko-design-md 카탈로그에 추가하고 싶어. design.md랑 preview HTML 만들어줘. https://toss.im 참고로", "should_trigger": true }, { "query": "Stripe의 design system을 catalog에 등록해줘. 참고 URL은 stripe.com/customers", "should_trigger": true }, { "query": "당근의 디자인 시스템 정리해서 catalog에 새 항목으로 만들어줄 수 있어? screenshot 4장 있어 — ~/Desktop/karrot-1.png부터 4까지", "should_trigger": true }, { "query": "ko-design-md에 Linear 추가하자. linear.app, lang ko, category content 정도?", "should_trigger": true }, { "query": "쿠팡으로 새 design.md 만들어야 해. 자료조사부터 preview까지 한 번에 가자. category는 commerce", "should_trigger": true }, { "query": "/design-md", "should_trigger": true }, { "query": "this brand isn't in our catalog yet — can you onboard them? brand: notion, urls: notion.so, notion.so/blog", "should_trigger": true }, { "query": "넷플릭스 카탈로그 등록 진행. services/{slug}.md 자동으로 만들어주는 그거", "should_trigger": true }, { "query": "Spotify를 추가하려는데 design.md 자동 생성하는 워크플로우 있다고 했지? 그거로 해줘", "should_trigger": true }, { "query": "services/baemin.md의 typography 섹션 한 줄만 고쳐줘 — 'Pretendard'를 'Pretendard Variable'로", "should_trigger": false }, { "query": "design.md 파일 형식이 뭔지 설명해줘. Google Stitch에서 만든 거 맞아?", "should_trigger": false }, { "query": "services/toss.md의 last_updated를 오늘 날짜로 갱신만 해줘", "should_trigger": false }, { "query": "preview HTML의 dark mode에서 contrast가 부족해 보이는데 토큰 값 좀 봐줘 — public/preview/demo-courier/preview.html", "should_trigger": false }, { "query": "src/routes/index.tsx에 카테고리 필터 사이드바 추가해줘. 기존 스타일 따라서", "should_trigger": false }, { "query": "build:og 스크립트가 NaN 에러 내면서 실패해. 디버깅해줘. scripts/build-og.ts 봐주면 됨", "should_trigger": false }, { "query": "내 다른 사이드프로젝트(설계 시스템 카탈로그 비슷한 거)에서도 design.md 같은 거 쓰고 싶은데 어떻게 시작해야 해?", "should_trigger": false }, { "query": "design.md 잘 쓰는 법 가이드 같은 거 있어? 책이나 블로그 추천해줘", "should_trigger": false }, { "query": "Stripe design.md 파일을 다운로드해서 우리 프로젝트 docs/ 폴더에 참고 자료로 가져다 놔줘", "should_trigger": false }, { "query": "services/toss.md를 services/toss-copy.md로 이름 바꿔서 복제해줘. 내용은 그대로", "should_trigger": false }, { "query": "ko-design-md 프로젝트 README에 카탈로그 항목 추가하는 방법 한 단락 적어줘", "should_trigger": false } ]
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.