oh-my-design-cli 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.ko.md +12 -0
  3. package/README.md +49 -0
  4. package/data/reference-fingerprints.json +957 -2
  5. package/dist/bin/oh-my-design.js +4 -3
  6. package/dist/bin/oh-my-design.js.map +1 -1
  7. package/dist/{install-skills-IETT2TBJ.js → install-skills-6QFSN5BN.js} +108 -42
  8. package/dist/install-skills-6QFSN5BN.js.map +1 -0
  9. package/package.json +9 -3
  10. package/scripts/postinstall.cjs +6 -6
  11. package/skills/claude-design/SKILL.md +385 -0
  12. package/skills/claude-design/references/claude-design-flow.md +425 -0
  13. package/skills/claude-design/references/codebase-analysis.md +373 -0
  14. package/skills/claude-design/scripts/analyze_codebase.py +1369 -0
  15. package/skills/claude-design/scripts/clickable_link.sh +48 -0
  16. package/skills/claude-design/scripts/collect_source.py +178 -0
  17. package/skills/claude-design/scripts/drive_claude_design.cjs +378 -0
  18. package/skills/claude-design/scripts/gather_references.py +437 -0
  19. package/web/references/91app/DESIGN.md +151 -0
  20. package/web/references/airtable/DESIGN.md +16 -2
  21. package/web/references/bithumb/DESIGN.md +170 -0
  22. package/web/references/bunjang/DESIGN.md +20 -1
  23. package/web/references/cakeresume/DESIGN.md +162 -0
  24. package/web/references/catchtable/DESIGN.md +19 -0
  25. package/web/references/classting/DESIGN.md +251 -0
  26. package/web/references/classum/DESIGN.md +19 -0
  27. package/web/references/coinone/DESIGN.md +218 -0
  28. package/web/references/dabang/DESIGN.md +19 -0
  29. package/web/references/devsisters/DESIGN.md +253 -0
  30. package/web/references/dji/DESIGN.md +0 -1
  31. package/web/references/drnow/DESIGN.md +331 -0
  32. package/web/references/fastcampus/DESIGN.md +19 -0
  33. package/web/references/flex/DESIGN.md +19 -0
  34. package/web/references/flo/DESIGN.md +306 -0
  35. package/web/references/fugle/DESIGN.md +250 -0
  36. package/web/references/gmarket/DESIGN.md +19 -0
  37. package/web/references/gogolook/DESIGN.md +131 -0
  38. package/web/references/grip/DESIGN.md +250 -0
  39. package/web/references/hahow/DESIGN.md +158 -0
  40. package/web/references/hogangnono/DESIGN.md +308 -0
  41. package/web/references/hyundaicard/DESIGN.md +177 -0
  42. package/web/references/inflearn/DESIGN.md +19 -0
  43. package/web/references/jkopay/DESIGN.md +249 -0
  44. package/web/references/jobkorea/DESIGN.md +310 -0
  45. package/web/references/kbank/DESIGN.md +18 -0
  46. package/web/references/kdan/DESIGN.md +160 -0
  47. package/web/references/kkbox/DESIGN.md +114 -0
  48. package/web/references/krafton/DESIGN.md +230 -0
  49. package/web/references/kream/DESIGN.md +18 -0
  50. package/web/references/laftel/DESIGN.md +253 -0
  51. package/web/references/lezhin/DESIGN.md +301 -0
  52. package/web/references/lunit/DESIGN.md +19 -0
  53. package/web/references/melon/DESIGN.md +153 -0
  54. package/web/references/momoshop/DESIGN.md +279 -0
  55. package/web/references/mustit/DESIGN.md +282 -0
  56. package/web/references/nhncloud/DESIGN.md +174 -0
  57. package/web/references/oliveyoung/DESIGN.md +19 -0
  58. package/web/references/payco/DESIGN.md +227 -0
  59. package/web/references/piccollage/DESIGN.md +277 -0
  60. package/web/references/rayark/DESIGN.md +132 -0
  61. package/web/references/riiid/DESIGN.md +228 -0
  62. package/web/references/sendbird/DESIGN.md +285 -0
  63. package/web/references/socar/DESIGN.md +18 -0
  64. package/web/references/toss-securities/DESIGN.md +19 -0
  65. package/web/references/trenbe/DESIGN.md +252 -0
  66. package/web/references/tving/DESIGN.md +18 -0
  67. package/web/references/upbit/DESIGN.md +19 -0
  68. package/web/references/upstage/DESIGN.md +18 -0
  69. package/web/references/velog/DESIGN.md +168 -0
  70. package/web/references/voicetube/DESIGN.md +227 -0
  71. package/web/references/wadiz/DESIGN.md +19 -0
  72. package/web/references/webflow/DESIGN.md +16 -2
  73. package/web/references/yeogiotte/DESIGN.md +19 -0
  74. package/data/architecture-proposals/2026-05-13-thin-install-fresh-fetch.md +0 -189
  75. package/data/issues/2026-05-13-multi-surface-schema-rfc.md +0 -67
  76. package/data/reference-audits/2026-05-13-kr10.md +0 -132
  77. package/data/reference-audits/2026-05-14-kr10.md +0 -72
  78. package/data/reference-audits/2026-05-15-kr10.md +0 -124
  79. package/data/research/2026-05-18-agent-landscape.md +0 -69
  80. package/data/research/2026-05-18-kr-style-presets.md +0 -572
  81. package/dist/install-skills-IETT2TBJ.js.map +0 -1
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # clickable_link.sh — emit a terminal-clickable OSC-8 hyperlink for a URL.
3
+ #
4
+ # Part of the `claude-design` skill. Used at the end of the flow to surface the
5
+ # resulting claude.ai/design canvas/result link in the terminal as a clickable
6
+ # hyperlink, with a plain-URL fallback line for terminals that don't support OSC-8.
7
+ #
8
+ # Usage: bash scripts/clickable_link.sh "<url>" ["<label>"]
9
+ # <url> required — the link to make clickable
10
+ # <label> optional — visible text for the hyperlink (defaults to the url)
11
+ #
12
+ # Behavior:
13
+ # - If stdout is a TTY, prints the OSC-8 hyperlink AND a plain-url fallback line.
14
+ # - If stdout is NOT a TTY, prints only the plain url (avoids junk escapes in pipes/files).
15
+ # - The plain-url fallback line is ALWAYS printed (even if the hyperlink line fails),
16
+ # so the URL is never lost.
17
+ # - Exits 2 with a usage message on stderr if no url argument is given.
18
+
19
+ set -u
20
+
21
+ if [ "$#" -lt 1 ] || [ -z "${1:-}" ]; then
22
+ printf 'usage: %s "<url>" ["<label>"]\n' "${0##*/}" >&2
23
+ exit 2
24
+ fi
25
+
26
+ url=$1
27
+ label=${2:-$url}
28
+
29
+ # Strip control characters (incl. ESC) from url/label so a malformed value cannot
30
+ # break out of the OSC-8 sequence or inject terminal escapes. Treat the inputs as
31
+ # data, not as terminal control. Uses tr; falls back to the raw value if tr fails.
32
+ strip_ctrl() {
33
+ # Delete bytes 0x00-0x1F and 0x7F (C0 controls + DEL).
34
+ printf '%s' "$1" | tr -d '\000-\037\177' 2>/dev/null || printf '%s' "$1"
35
+ }
36
+ url=$(strip_ctrl "$url")
37
+ label=$(strip_ctrl "$label")
38
+
39
+ # ESC]8;;URL ESC\ LABEL ESC]8;;ESC\ — OSC-8 hyperlink sequence.
40
+ # Only when stdout is a TTY (otherwise escapes would be junk in pipes/files).
41
+ # `|| true` so a transient write error here never prevents the plain-url fallback below.
42
+ if [ -t 1 ]; then
43
+ printf '\033]8;;%s\033\\%s\033]8;;\033\\\n' "$url" "$label" || true
44
+ fi
45
+
46
+ # Plain-url fallback on its own line — ALWAYS printed (the only output when non-TTY,
47
+ # and the safety net when the OSC-8 line is unsupported or failed).
48
+ printf '%s\n' "$url"
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """collect_source.py — gather the landing/page-critical SOURCE files of a project
3
+ and (optionally) concatenate them into ONE attachable bundle for Claude Design.
4
+
5
+ WHY: a private GitHub URL is not referenceable by Claude Design. The robust way to
6
+ let it "see the code" is to attach the actual local source. This picks the landing
7
+ entry file + its locally-imported components + the main stylesheet, bounded.
8
+
9
+ Usage:
10
+ python3 collect_source.py [--root DIR] [--entry FILE] [--max-files N]
11
+ [--max-kb N] [--json] [--bundle OUT.md]
12
+ Defaults: root=cwd, max-files=16, max-kb=220.
13
+ - --json : print {entry, files:[{path,bytes}], total_bytes, note}
14
+ - --bundle : write a single markdown bundle (each file under a `// ==== path ====`
15
+ header) suitable for one upload; prints the bundle path.
16
+ Regex-based, stdlib only, never crashes.
17
+ """
18
+ import argparse, os, re, sys, json
19
+
20
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", "out", ".venv",
21
+ "venv", "__pycache__", ".cache", "coverage", ".turbo", ".vercel"}
22
+ ENTRY_CANDIDATES = [
23
+ "src/app/page.tsx", "src/app/page.jsx", "app/page.tsx", "app/page.jsx",
24
+ "src/pages/index.tsx", "src/pages/index.jsx", "pages/index.tsx", "pages/index.jsx",
25
+ "src/App.tsx", "src/App.jsx", "src/main.tsx", "app/page.js",
26
+ ]
27
+ CSS_CANDIDATES = [
28
+ "src/app/globals.css", "app/globals.css", "src/index.css", "src/styles/globals.css",
29
+ "styles/globals.css", "src/app/global.css", "src/main.css",
30
+ ]
31
+ IMPORT_RE = re.compile(r"""(?:import|export)[^'"]*from\s*['"]([^'"]+)['"]""")
32
+ CODE_EXTS = (".tsx", ".jsx", ".ts", ".js", ".vue", ".svelte", ".astro")
33
+
34
+
35
+ def find_root_entry(root, entry):
36
+ if entry:
37
+ p = entry if os.path.isabs(entry) else os.path.join(root, entry)
38
+ return p if os.path.isfile(p) else None
39
+ for c in ENTRY_CANDIDATES:
40
+ p = os.path.join(root, c)
41
+ if os.path.isfile(p):
42
+ return p
43
+ return None
44
+
45
+
46
+ def resolve_import(root, spec):
47
+ """Resolve a local import spec to candidate file paths (best-effort)."""
48
+ if not (spec.startswith("@/") or spec.startswith("./") or spec.startswith("../") or spec.startswith("@/")):
49
+ return []
50
+ if spec.startswith("@/"):
51
+ base = os.path.join(root, "src", spec[2:])
52
+ if not os.path.exists(os.path.dirname(base)):
53
+ base = os.path.join(root, spec[2:]) # some configs map @/ to root
54
+ else: # relative — resolved by caller against the importer dir
55
+ base = spec
56
+ return [base]
57
+
58
+
59
+ def collect(root, entry, max_files, max_kb):
60
+ files, seen = [], set()
61
+ note = []
62
+ entry_path = find_root_entry(root, entry)
63
+
64
+ def add(p):
65
+ try:
66
+ p = os.path.realpath(p)
67
+ if p in seen or not os.path.isfile(p):
68
+ return
69
+ sz = os.path.getsize(p)
70
+ seen.add(p)
71
+ files.append({"path": p, "bytes": sz})
72
+ except OSError:
73
+ pass
74
+
75
+ if entry_path:
76
+ add(entry_path)
77
+ try:
78
+ txt = open(entry_path, encoding="utf-8", errors="ignore").read()
79
+ except OSError:
80
+ txt = ""
81
+ importer_dir = os.path.dirname(entry_path)
82
+ for spec in IMPORT_RE.findall(txt):
83
+ cands = []
84
+ if spec.startswith("@/"):
85
+ cands = resolve_import(root, spec)
86
+ elif spec.startswith("./") or spec.startswith("../"):
87
+ cands = [os.path.normpath(os.path.join(importer_dir, spec))]
88
+ else:
89
+ continue
90
+ for base in cands:
91
+ # base may be a file (with/without ext), or a dir (barrel) -> include its code files
92
+ hit = False
93
+ for ext in ("",) + CODE_EXTS:
94
+ fp = base + ext
95
+ if os.path.isfile(fp):
96
+ add(fp); hit = True; break
97
+ if not hit and os.path.isdir(base):
98
+ note.append("barrel:" + os.path.relpath(base, root))
99
+ try:
100
+ for fn in sorted(os.listdir(base)):
101
+ if fn.endswith(CODE_EXTS):
102
+ add(os.path.join(base, fn))
103
+ except OSError:
104
+ pass
105
+ elif not hit:
106
+ # maybe an index file in a dir
107
+ for idx in ("index.tsx", "index.ts", "index.jsx", "index.js"):
108
+ fp = os.path.join(base, idx)
109
+ if os.path.isfile(fp):
110
+ add(fp)
111
+ else:
112
+ note.append("no_entry_found")
113
+
114
+ # main stylesheet / tokens
115
+ for c in CSS_CANDIDATES:
116
+ p = os.path.join(root, c)
117
+ if os.path.isfile(p):
118
+ add(p); break
119
+
120
+ # bound by count then by cumulative size
121
+ files = files[:max_files]
122
+ out, total = [], 0
123
+ for f in files:
124
+ if total + f["bytes"] > max_kb * 1024 and out:
125
+ note.append("size_capped"); break
126
+ out.append(f); total += f["bytes"]
127
+ return {"root": root, "entry": entry_path, "files": out, "total_bytes": total,
128
+ "note": ", ".join(note) or "ok"}
129
+
130
+
131
+ def write_bundle(res, out_path):
132
+ parts = ["# Source bundle — Claude Design 참고용 (로컬 코드)\n",
133
+ "> 이 프로젝트의 레포가 private 일 수 있어 URL 로 접근이 안 될 수 있습니다.",
134
+ "> 아래는 랜딩 페이지의 **실제 로컬 소스**(진입 page + import 컴포넌트 + 스타일/토큰)입니다.\n"]
135
+ root = res["root"]
136
+ for f in res["files"]:
137
+ rel = os.path.relpath(f["path"], root)
138
+ try:
139
+ body = open(f["path"], encoding="utf-8", errors="ignore").read()
140
+ except OSError:
141
+ body = "// (read error)"
142
+ lang = "tsx" if f["path"].endswith((".tsx", ".jsx")) else (
143
+ "ts" if f["path"].endswith((".ts", ".js")) else (
144
+ "css" if f["path"].endswith(".css") else ""))
145
+ parts.append(f"\n// ===== {rel} =====\n```{lang}\n{body}\n```\n")
146
+ with open(out_path, "w", encoding="utf-8") as fh:
147
+ fh.write("\n".join(parts))
148
+ return out_path
149
+
150
+
151
+ def main():
152
+ ap = argparse.ArgumentParser(description="Collect landing source files / bundle for Claude Design.")
153
+ ap.add_argument("--root", default=os.getcwd())
154
+ ap.add_argument("--entry", default=None)
155
+ ap.add_argument("--max-files", type=int, default=16)
156
+ ap.add_argument("--max-kb", type=int, default=220)
157
+ ap.add_argument("--json", action="store_true")
158
+ ap.add_argument("--bundle", default=None, metavar="OUT")
159
+ a = ap.parse_args()
160
+ root = os.path.realpath(a.root)
161
+ res = collect(root, a.entry, a.max_files, a.max_kb)
162
+ if a.bundle:
163
+ res["bundle"] = write_bundle(res, a.bundle)
164
+ if a.json:
165
+ print(json.dumps(res, ensure_ascii=False, indent=1))
166
+ else:
167
+ print(f"entry: {res['entry']}")
168
+ print(f"files ({len(res['files'])}, {res['total_bytes']//1024}KB):")
169
+ for f in res["files"]:
170
+ print(f" {os.path.relpath(f['path'], root)} ({f['bytes']}B)")
171
+ if res.get("bundle"):
172
+ print(f"bundle -> {res['bundle']}")
173
+ print(f"note: {res['note']}")
174
+ return 0
175
+
176
+
177
+ if __name__ == "__main__":
178
+ sys.exit(main())
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * drive_claude_design.cjs — Playwright-based driver for claude.ai/design.
4
+ *
5
+ * WHY: claude-in-chrome's click/screenshot layer depends on OS foreground focus
6
+ * (a chronic bug: "Cannot access a chrome-extension:// URL of different extension").
7
+ * Playwright drives its OWN Chrome instance over CDP — no foreground dependency,
8
+ * and a dedicated profile means no conflicting extensions.
9
+ *
10
+ * AUTH: uses a persistent profile dir, so the user logs into claude.ai ONCE
11
+ * (headed) and every later run is fully automated.
12
+ *
13
+ * Run: NODE_PATH=$(npm root -g) node drive_claude_design.cjs <config.json>
14
+ * config.json: { projectName, fidelity, prompt, assets[], profileDir, outFile,
15
+ * shotDir, loginTimeoutMs, headless, questionAnswers[],
16
+ * awaitAnswersMs, answersFile, questionGraceMs, maxQuestionRounds,
17
+ * genTimeoutMs, questionsOut }
18
+ *
19
+ * claude.ai/design SOMETIMES interjects a "Quick questions" clarifying panel
20
+ * before generating (multiple-choice chip groups + a Continue button), usually a
21
+ * MINUTE+ in (after a collapsed "Reading files" phase) and in a "Questions" TAB
22
+ * that's closed by default. The panel renders in several brittle layouts, so the
23
+ * driver acts LAYOUT-INDEPENDENTLY: it watches for a visible Continue button and
24
+ * opens the tab. How it answers:
25
+ * - AGENT-REASONED (best, when cfg.awaitAnswersMs is set): the driver dumps the
26
+ * questions to cfg.questionsOut and emits QUESTIONS_AWAITING, then polls
27
+ * cfg.answersFile. The agent running the skill reads the questions, picks the
28
+ * APPROPRIATE option per question from the codebase brief, and writes a JSON
29
+ * array of option-text substrings; the driver clicks exactly those.
30
+ * - AUTONOMOUS fallback (no agent / timeout): clicks EVERY "Decide for me"/escape
31
+ * chip (delegates each group to the context-aware design model).
32
+ * - cfg.questionAnswers pre-supplies picks: [{ pick: "<substr>" | ["a","b"] }].
33
+ * Continue is clicked only AFTER answering, and RESULT_URL only after the design
34
+ * settles — so it's never the parked questions page.
35
+ *
36
+ * Resolve markers on stdout (grep-friendly): [cd] LOGIN_NEEDED / LOGGED_IN /
37
+ * CREATED / PROMPT_SET / ASSETS / SUBMITTED / QUESTIONS_DETECTED / QUESTIONS_AWAITING /
38
+ * ANSWERS_RECEIVED / QUESTIONS_ANSWERED / CONTINUE_CLICKED / NO_QUESTIONS /
39
+ * QUESTIONS_HANDLED / SETTLED / RESULT_URL=<url> / ERROR=<x> / FATAL=<x>.
40
+ * Browser is left OPEN after success so the user can watch.
41
+ */
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+ const os = require('os');
45
+ const { chromium } = require('playwright');
46
+
47
+ function log(...a) { console.log('[cd]', ...a); }
48
+ const cfg = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
49
+ const HOME = os.homedir();
50
+ const PROFILE = cfg.profileDir || path.join(HOME, '.claude/skills/claude-design/.runtime/chrome-profile');
51
+ const SHOTDIR = cfg.shotDir || '/tmp/cd-shots';
52
+ const OUT = cfg.outFile || '/tmp/cd-result.json';
53
+ fs.mkdirSync(PROFILE, { recursive: true });
54
+ fs.mkdirSync(SHOTDIR, { recursive: true });
55
+
56
+ let SHOT = 0;
57
+ async function shot(page, name) {
58
+ try { const p = path.join(SHOTDIR, `${String(++SHOT).padStart(2, '0')}-${name}.png`); await page.screenshot({ path: p, fullPage: false }); log('SHOT=' + p); } catch (e) {}
59
+ }
60
+
61
+ // Attach reference files via the composer's "Add to chat" → "Attach file" menu,
62
+ // which triggers a native file picker (handled by Playwright's filechooser event).
63
+ async function attachViaAddToChat(page, files) {
64
+ const add = page.locator('button[title="Add to chat"], [aria-label="Add to chat"]').first();
65
+ if (!(await add.count().catch(() => 0))) { log('attach: no Add-to-chat button'); return 0; }
66
+ async function onePass(list) {
67
+ await add.click().catch(() => {});
68
+ await page.waitForTimeout(500);
69
+ const item = page.locator('button:has-text("Attach file"), [role=menuitem]:has-text("Attach file")').first();
70
+ if (!(await item.count().catch(() => 0))) { await page.keyboard.press('Escape').catch(() => {}); return false; }
71
+ const [chooser] = await Promise.all([
72
+ page.waitForEvent('filechooser', { timeout: 8000 }).catch(() => null),
73
+ item.click().catch(() => {}),
74
+ ]);
75
+ if (!chooser) { await page.keyboard.press('Escape').catch(() => {}); return false; }
76
+ await chooser.setFiles(list).catch((e) => log('setFiles_warn=' + (e.message || '').split('\n')[0]));
77
+ return true;
78
+ }
79
+ // try all-at-once, else fall back to one-by-one
80
+ if (await onePass(files)) { await page.waitForTimeout(1800); return files.length; }
81
+ let n = 0;
82
+ for (const f of files) { if (await onePass([f])) { n++; await page.waitForTimeout(1400); } }
83
+ return n;
84
+ }
85
+
86
+ // Visible page-text length — the one reliable activity/settle signal. During the
87
+ // file-reading phase, the clarifying panel, and design generation the main
88
+ // document text keeps changing; when it stops changing for a sustained window the
89
+ // run has settled. (claude.ai's Stop control has no stable selector, and the chat
90
+ // keeps "reading/thinking/code" wording around forever, so neither is usable.)
91
+ async function bodyLen(page) {
92
+ return await page.evaluate(() => (document.body.innerText || '').length).catch(() => -1);
93
+ }
94
+
95
+ // Layout-independent answering — robust where structural grouping is not.
96
+ // claude.ai/design renders the panel in several layouts (with/without title,
97
+ // varied nesting) and the chips live in a "Questions" TAB that's CLOSED by default.
98
+ // So we don't parse groups; we just open the tab and click EVERY "Decide for me" /
99
+ // escape chip top-to-bottom (each delegates its group to Claude — the appropriate
100
+ // autonomous answer), then Continue. The reliable "a panel awaits" signal is a
101
+ // VISIBLE Continue button (it clears once answered); the chat notice "Claude has
102
+ // some questions" is permanent scrollback and must NOT drive the loop.
103
+
104
+ // One round-trip snapshot of panel-relevant state.
105
+ async function panelState(page) {
106
+ return await page.evaluate(() => {
107
+ const norm = s => (s || '').replace(/\s+/g, ' ').trim();
108
+ const vis = el => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none'; };
109
+ const B = [...document.querySelectorAll('button,[role=button]')].filter(vis);
110
+ return {
111
+ contVisible: B.some(b => /^continue$/i.test(norm(b.innerText))),
112
+ decideCount: B.filter(b => /^decide for me$/i.test(norm(b.innerText))).length,
113
+ chipCount: B.filter(b => { const t = norm(b.innerText); return t && !/^(share|send|design system|design files|questions|continue|present|sj)$/i.test(t) && !/^claude opus/i.test(t); }).length,
114
+ generating: /generating (more )?questions|generating options|thinking…|writing questions/i.test(document.body.innerText || ''),
115
+ notice: /claude has some questions|quick questions about|a few (quick )?questions/i.test(document.body.innerText || ''),
116
+ };
117
+ }).catch(() => ({ contVisible: false, decideCount: 0, notice: false }));
118
+ }
119
+
120
+ // Open the Questions tab ONLY if its chips aren't already visible (don't toggle an
121
+ // open tab shut).
122
+ async function ensureQuestionsOpen(page) {
123
+ if ((await panelState(page)).contVisible) return true;
124
+ const tab = page.getByText(/^Questions$/);
125
+ if (await tab.count().catch(() => 0)) { await tab.first().click().catch(() => {}); await page.waitForTimeout(1500); return true; }
126
+ return false;
127
+ }
128
+
129
+ // Dump the open panel's questions for the agent to reason over: the visible option
130
+ // chip labels (what the agent picks from) plus the readable panel text for context.
131
+ async function extractQuestions(page) {
132
+ return await page.evaluate(() => {
133
+ const norm = s => (s || '').replace(/\s+/g, ' ').trim();
134
+ const vis = el => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden'; };
135
+ const isGlobal = t => /^(share|send|design system|design files|questions|sj|new chat|copy|download|export|present|continue)$/i.test(t) || /^claude opus/i.test(t);
136
+ const chips = [...document.querySelectorAll('button,[role=button]')].filter(vis).map(b => norm(b.innerText)).filter(t => t && !isGlobal(t));
137
+ const cont = [...document.querySelectorAll('button,[role=button]')].filter(vis).find(b => /^continue$/i.test(norm(b.innerText)));
138
+ let panelText = '';
139
+ if (cont) { let x = cont; for (let i = 0; i < 8 && x.parentElement; i++) { x = x.parentElement; if ([...x.querySelectorAll('button,[role=button]')].filter(b => chips.includes(norm(b.innerText))).length >= Math.min(4, chips.length)) break; } panelText = norm(x.innerText).slice(0, 2000); }
140
+ return { chips, panelText };
141
+ }).catch(() => ({ chips: [], panelText: '' }));
142
+ }
143
+
144
+ // Apply answers to the open panel. `picks` = option-text substrings, clicked
145
+ // strictly top-to-bottom by absolute Y (so duplicate labels across groups resolve
146
+ // in order). If `sweepDecide`, also click every Decide/escape chip that ISN'T near
147
+ // an already-picked one (fills groups the agent didn't answer; won't override one
148
+ // it did). Returns chips clicked.
149
+ async function answerVisiblePanel(page, picks, sweepDecide) {
150
+ let clicked = 0, lastAbsY = -1;
151
+ const pickedYs = [];
152
+ for (const sub of (picks || [])) {
153
+ const c = await page.evaluate(({ sub, lastAbsY }) => {
154
+ const norm = s => (s || '').replace(/\s+/g, ' ').trim();
155
+ const vis = el => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden'; };
156
+ const cands = [...document.querySelectorAll('button,[role=button]')].filter(vis)
157
+ .filter(b => !/^continue$/i.test(norm(b.innerText)) && norm(b.innerText).toLowerCase().includes(String(sub).toLowerCase()))
158
+ .map(el => ({ el, absY: el.getBoundingClientRect().top + window.scrollY })).sort((a, b) => a.absY - b.absY);
159
+ const next = cands.find(c => c.absY > lastAbsY + 4) || cands[0];
160
+ if (!next) return null;
161
+ next.el.scrollIntoView({ block: 'center', behavior: 'instant' });
162
+ const r = next.el.getBoundingClientRect();
163
+ return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), absY: next.absY };
164
+ }, { sub, lastAbsY }).catch(() => null);
165
+ if (c) { await page.mouse.click(c.x, c.y).catch(() => {}); lastAbsY = c.absY; pickedYs.push(c.absY); clicked++; await page.waitForTimeout(200); }
166
+ }
167
+ if (sweepDecide) {
168
+ lastAbsY = -1;
169
+ for (let guard = 0; guard < 30; guard++) {
170
+ const c = await page.evaluate(({ lastAbsY, pickedYs }) => {
171
+ const norm = s => (s || '').replace(/\s+/g, ' ').trim();
172
+ const vis = el => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden'; };
173
+ const isPick = b => { const t = norm(b.innerText); return /^decide for me$/i.test(t) || /^(don'?t need|no tweaks?|none|skip|no preference|keep exactly|keep it mostly)\b/i.test(t); };
174
+ const cands = [...document.querySelectorAll('button,[role=button]')].filter(vis).filter(isPick)
175
+ .map(el => ({ el, absY: el.getBoundingClientRect().top + window.scrollY })).sort((a, b) => a.absY - b.absY);
176
+ const next = cands.find(c => c.absY > lastAbsY + 4 && !pickedYs.some(py => Math.abs(py - c.absY) < 110));
177
+ if (!next) return null;
178
+ next.el.scrollIntoView({ block: 'center', behavior: 'instant' });
179
+ const r = next.el.getBoundingClientRect();
180
+ return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), absY: next.absY };
181
+ }, { lastAbsY, pickedYs }).catch(() => null);
182
+ if (!c) break;
183
+ await page.mouse.click(c.x, c.y).catch(() => {});
184
+ lastAbsY = c.absY; clicked++;
185
+ await page.waitForTimeout(200);
186
+ }
187
+ }
188
+ return clicked;
189
+ }
190
+
191
+ (async () => {
192
+ const ctx = await chromium.launchPersistentContext(PROFILE, {
193
+ channel: 'chrome',
194
+ headless: !!cfg.headless,
195
+ viewport: null,
196
+ args: ['--disable-blink-features=AutomationControlled', '--start-maximized', '--no-first-run', '--no-default-browser-check'],
197
+ });
198
+ let page = ctx.pages()[0] || await ctx.newPage();
199
+ page.setDefaultTimeout(45000);
200
+
201
+ const PROJ = 'input[placeholder="Project name"]';
202
+
203
+ log('goto claude.ai/design');
204
+ await page.goto('https://claude.ai/design', { waitUntil: 'domcontentloaded' }).catch(() => {});
205
+ await page.waitForTimeout(2500);
206
+ await shot(page, 'initial');
207
+
208
+ // ---- LOGIN WAIT: poll ALL pages for the create panel. CRITICAL: do NOT
209
+ // force-navigate during login — that resets the user's login flow.
210
+ // claude.ai auto-redirects to /design after login (returnTo). Crash-proof
211
+ // via setTimeout (page.waitForTimeout throws if the page closes). ----
212
+ const deadline = Date.now() + (cfg.loginTimeoutMs || 300000);
213
+ let ready = false, warned = false;
214
+ while (Date.now() < deadline) {
215
+ try {
216
+ for (const pg of ctx.pages()) {
217
+ const vis = await pg.locator(PROJ).first().isVisible({ timeout: 600 }).catch(() => false);
218
+ if (vis) { page = pg; ready = true; break; }
219
+ }
220
+ } catch (e) { /* transient (a page is navigating/closing) — ignore and retry */ }
221
+ if (ready) break;
222
+ if (!warned) {
223
+ log('LOGIN_NEEDED — Playwright 가 연 그 Chrome 창에서 claude.ai 에 로그인하세요. 그 창 그대로 두면(닫지 말 것) 자동으로 이어집니다. 한 번만 하면 프로필에 저장됩니다.');
224
+ try { await ctx.pages()[0].screenshot({ path: path.join(SHOTDIR, '02-login.png') }); } catch {}
225
+ warned = true;
226
+ }
227
+ await new Promise(r => setTimeout(r, 2500));
228
+ }
229
+ if (!ready) { log('ERROR=login_timeout url=' + page.url()); fs.writeFileSync(OUT, JSON.stringify({ error: 'login_timeout' })); return; }
230
+ log('LOGGED_IN');
231
+ await shot(page, 'panel');
232
+
233
+ // ---- Fill project name ----
234
+ await page.fill(PROJ, cfg.projectName || 'claude-design');
235
+ log('name set:', cfg.projectName);
236
+
237
+ // ---- Fidelity (e.g. "High fidelity") ----
238
+ if (cfg.fidelity) {
239
+ const fb = page.getByRole('button', { name: cfg.fidelity, exact: true });
240
+ if (await fb.count().catch(() => 0)) { await fb.first().click().catch(() => {}); log('fidelity:', cfg.fidelity); }
241
+ else { const alt = page.getByText(cfg.fidelity, { exact: true }); if (await alt.count().catch(() => 0)) { await alt.first().click().catch(() => {}); log('fidelity(text):', cfg.fidelity); } }
242
+ }
243
+ await shot(page, 'precreate');
244
+
245
+ // ---- Create ----
246
+ const createBtn = page.getByRole('button', { name: 'Create', exact: true });
247
+ await createBtn.first().click();
248
+ log('CREATED clicked');
249
+ await page.waitForTimeout(3500);
250
+ await shot(page, 'aftercreate');
251
+
252
+ // ---- Find composer (editable prompt) on the project page ----
253
+ let composer = null;
254
+ const cd = Date.now() + 45000;
255
+ const cand = ['div[contenteditable="true"]', 'textarea', '[role="textbox"]'];
256
+ while (Date.now() < cd && !composer) {
257
+ for (const sel of cand) {
258
+ const loc = page.locator(sel).last();
259
+ if (await loc.count().catch(() => 0) && await loc.isVisible().catch(() => false) && await loc.isEditable().catch(() => false)) { composer = loc; break; }
260
+ }
261
+ if (!composer) await page.waitForTimeout(1500);
262
+ }
263
+ if (!composer) { log('ERROR=composer_not_found url=' + page.url()); await shot(page, 'no-composer'); }
264
+ else {
265
+ await composer.click().catch(() => {});
266
+ try { await composer.fill(cfg.prompt); } catch { await composer.type(cfg.prompt, { delay: 0 }); }
267
+ log('PROMPT_SET');
268
+ await shot(page, 'prompt');
269
+ }
270
+
271
+ // ---- Attach reference files (brand images + local source bundle) ----
272
+ const attachList = (cfg.attachFiles || cfg.assets || []).filter(a => { try { return fs.existsSync(a); } catch { return false; } });
273
+ if (attachList.length) {
274
+ let n = 0;
275
+ try { n = await attachViaAddToChat(page, attachList); } catch (e) { log('attach_err=' + (e.message || '').split('\n')[0]); }
276
+ log('ASSETS=' + n + '/' + attachList.length);
277
+ await page.waitForTimeout(2000);
278
+ await shot(page, 'assets');
279
+ } else { log('ASSETS=0 (no files)'); }
280
+
281
+ // ---- Submit ----
282
+ if (composer) {
283
+ const send = page.getByRole('button', { name: /^(send|submit|generate)$/i });
284
+ if (await send.count().catch(() => 0)) { await send.last().click().catch(() => {}); }
285
+ else { await composer.press('Enter').catch(() => {}); }
286
+ log('SUBMITTED');
287
+ await page.waitForTimeout(3000);
288
+ await shot(page, 'submitted');
289
+ }
290
+
291
+ // ---- Settle on the project URL (/design/p/<uuid>) ----
292
+ let url = page.url();
293
+ { const ud = Date.now() + 30000; while (Date.now() < ud) { if (/\/design\/p\//.test(page.url())) { url = page.url(); break; } await page.waitForTimeout(1500); } }
294
+
295
+ // ---- Watch from SUBMITTED until the design settles. The clarifying panel often
296
+ // appears a MINUTE+ in (after a collapsed "Reading files" phase) and its chips
297
+ // live in a CLOSED "Questions" tab — so each iteration we (1) detect the chat
298
+ // notice "Claude has some questions", open that tab, and answer; else (2) track
299
+ // page-text stability to decide we've settled. A minWatch floor stops us settling
300
+ // during the quiet reading phase before the panel renders (the old premature bug).
301
+ const hardDeadline = Date.now() + (cfg.genTimeoutMs || 480000);
302
+ const minWatchUntil = Date.now() + (cfg.questionGraceMs || 165000); // ≥ time for a late panel to appear
303
+ const maxRounds = cfg.maxQuestionRounds || 8;
304
+ const submitLen = await bodyLen(page);
305
+ let round = 0, lastLen = -1, stable = 0, peakLen = submitLen, everAnswered = false, noticeOpens = 0;
306
+ while (Date.now() < hardDeadline) {
307
+ let st = await panelState(page);
308
+ // A panel is waiting if a Continue button is visible. Before the first answer,
309
+ // the persistent chat notice can also bootstrap one tab-open (bounded so it
310
+ // can't spin once answered).
311
+ if (!st.contVisible && st.notice && !everAnswered && noticeOpens < 3) { await ensureQuestionsOpen(page); noticeOpens++; st = await panelState(page); }
312
+ if (st.contVisible && round < maxRounds) {
313
+ // Wait for the panel to FULLY render before answering — claude.ai streams the
314
+ // questions in ("Generating questions…"), so we hold until that's gone AND the
315
+ // chip count is stable, else we'd answer a half-loaded panel.
316
+ let prev = -1, same = 0;
317
+ for (let k = 0; k < 18; k++) { const s = await panelState(page); if (!s.contVisible) break; const settled = !s.generating && s.chipCount === prev && s.chipCount > 0; if (settled) { if (++same >= 2) break; } else { same = 0; prev = s.chipCount; } await page.waitForTimeout(1500); }
318
+ await ensureQuestionsOpen(page);
319
+ round++;
320
+ log('QUESTIONS_DETECTED round=' + round + ' decideChips=' + (await panelState(page)).decideCount);
321
+
322
+ // Gather answers. Priority: agent-reasoned (await) > caller pre-supplied >
323
+ // autonomous "Decide for me". `picks` = option-text substrings.
324
+ const qOut = cfg.questionsOut || path.join(SHOTDIR, 'questions.json');
325
+ const answersFile = cfg.answersFile || qOut.replace(/\.json$/i, '') + '.answers.json';
326
+ const extracted = await extractQuestions(page);
327
+ let picks = (cfg.questionAnswers || []).flatMap(a => Array.isArray(a.pick) ? a.pick : (a.pick ? [a.pick] : (typeof a === 'string' ? [a] : [])));
328
+ let sweepDecide = true;
329
+ try { fs.writeFileSync(qOut, JSON.stringify({ round, answersFile, chips: extracted.chips, panelText: extracted.panelText }, null, 2)); } catch {}
330
+ if (cfg.awaitAnswersMs) {
331
+ // Hand the questions to the agent: it reads qOut, reasons over the brief,
332
+ // and writes answersFile (a JSON array of chosen option-text substrings).
333
+ try { if (fs.existsSync(answersFile)) fs.unlinkSync(answersFile); } catch {}
334
+ log('QUESTIONS_AWAITING file=' + qOut + ' answers=' + answersFile + ' chips=' + extracted.chips.length);
335
+ const dl = Date.now() + cfg.awaitAnswersMs;
336
+ while (Date.now() < dl) {
337
+ if (fs.existsSync(answersFile)) {
338
+ // Apply the agent's picks; KEEP the Decide-for-me sweep on so any group
339
+ // that streamed in after the agent answered still gets delegated (the
340
+ // pickedY-skip prevents the sweep from overriding an answered group).
341
+ try { const a = JSON.parse(fs.readFileSync(answersFile, 'utf8')); const got = Array.isArray(a) ? a : (a.picks || a.answers || []); if (got.length) { picks = got; log('ANSWERS_RECEIVED n=' + picks.length); } } catch (e) { log('answers_parse_warn=' + (e.message || '').split('\n')[0]); }
342
+ break;
343
+ }
344
+ await page.waitForTimeout(2000);
345
+ }
346
+ if (sweepDecide && Date.now() >= dl) log('ANSWERS_TIMEOUT (falling back to Decide-for-me)');
347
+ }
348
+ const n = await answerVisiblePanel(page, picks, sweepDecide);
349
+ log('QUESTIONS_ANSWERED=' + n + ' chips' + (sweepDecide ? (picks.length ? ' (picks+decide-sweep)' : ' (decide-sweep)') : ' (agent picks)'));
350
+ await shot(page, 'answered' + round);
351
+ const cont = page.getByRole('button', { name: /^continue$/i });
352
+ if (await cont.count().catch(() => 0)) { await cont.first().click({ timeout: 8000 }).catch((e) => log('continue_warn=' + (e.message || '').split('\n')[0])); log('CONTINUE_CLICKED'); }
353
+ everAnswered = true; lastLen = -1; stable = 0;
354
+ await page.waitForTimeout(5000);
355
+ continue;
356
+ }
357
+ // No panel waiting → track page-text stability to decide we've settled.
358
+ const len = await bodyLen(page);
359
+ if (len > peakLen) peakLen = len;
360
+ if (len === lastLen) stable++; else { stable = 0; lastLen = len; }
361
+ const activity = everAnswered || (peakLen - submitLen > 300);
362
+ // Don't settle before minWatch (a late panel may still be coming); after that,
363
+ // 6 stable samples (~30s unchanged) with real activity seen = settled.
364
+ if (stable >= 6 && activity && Date.now() > minWatchUntil) break;
365
+ await page.waitForTimeout(5000);
366
+ }
367
+ if (round === 0) log('NO_QUESTIONS'); else log('QUESTIONS_HANDLED rounds=' + round);
368
+ log(Date.now() >= hardDeadline ? 'GENERATION_TIMEOUT (capturing URL anyway)' : 'SETTLED');
369
+
370
+ // ---- Capture the real result URL ----
371
+ if (/\/design\/p\//.test(page.url())) url = page.url();
372
+ log('RESULT_URL=' + url);
373
+ fs.writeFileSync(OUT, JSON.stringify({ url, questionRounds: round }, null, 2));
374
+ await shot(page, 'final');
375
+
376
+ log('DONE — 브라우저는 열어 둡니다(생성 진행을 볼 수 있게). 닫으려면 이 창을 직접 닫으세요.');
377
+ // Intentionally do NOT close ctx: keep the browser open. Process stays alive.
378
+ })().catch(e => { console.log('[cd] FATAL=' + (e && e.message ? e.message.split('\n')[0] : e)); try { fs.writeFileSync(OUT, JSON.stringify({ error: String(e && e.message || e) })); } catch {} process.exit(1); });