litclaude-ai 0.3.3 → 0.3.6
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.
- package/CHANGELOG.md +54 -0
- package/README.md +5 -5
- package/README_ko-KR.md +5 -5
- package/RELEASE_CHECKLIST.md +8 -6
- package/cover.png +0 -0
- package/generate_cover.py +1 -1
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-hook.js +34 -4
- package/plugins/litclaude/commands/dynamic-workflow.md +6 -6
- package/plugins/litclaude/commands/review-work.md +5 -5
- package/plugins/litclaude/hooks/hooks.json +12 -0
- package/plugins/litclaude/lib/litgoal/autoloop.mjs +78 -0
- package/plugins/litclaude/lib/litgoal/cli.mjs +8 -0
- package/plugins/litclaude/skills/lit-loop/SKILL.md +12 -10
- package/plugins/litclaude/skills/lit-plan/SKILL.md +9 -10
- package/plugins/litclaude/skills/litgoal/SKILL.md +41 -5
- package/plugins/litclaude/skills/lsp-setup/SKILL.md +151 -0
- package/plugins/litclaude/skills/lsp-setup/references/bash/README.md +63 -0
- package/plugins/litclaude/skills/lsp-setup/references/c-cpp/README.md +72 -0
- package/plugins/litclaude/skills/lsp-setup/references/csharp/README.md +81 -0
- package/plugins/litclaude/skills/lsp-setup/references/dart/README.md +53 -0
- package/plugins/litclaude/skills/lsp-setup/references/elixir/README.md +57 -0
- package/plugins/litclaude/skills/lsp-setup/references/go/README.md +59 -0
- package/plugins/litclaude/skills/lsp-setup/references/haskell/README.md +64 -0
- package/plugins/litclaude/skills/lsp-setup/references/java/README.md +66 -0
- package/plugins/litclaude/skills/lsp-setup/references/julia/README.md +62 -0
- package/plugins/litclaude/skills/lsp-setup/references/kotlin/README.md +68 -0
- package/plugins/litclaude/skills/lsp-setup/references/lua/README.md +64 -0
- package/plugins/litclaude/skills/lsp-setup/references/php/README.md +56 -0
- package/plugins/litclaude/skills/lsp-setup/references/python/README.md +68 -0
- package/plugins/litclaude/skills/lsp-setup/references/ruby/README.md +62 -0
- package/plugins/litclaude/skills/lsp-setup/references/rust/README.md +61 -0
- package/plugins/litclaude/skills/lsp-setup/references/swift/README.md +60 -0
- package/plugins/litclaude/skills/lsp-setup/references/terraform/README.md +61 -0
- package/plugins/litclaude/skills/lsp-setup/references/typescript/README.md +77 -0
- package/plugins/litclaude/skills/lsp-setup/references/yaml/README.md +60 -0
- package/plugins/litclaude/skills/lsp-setup/references/zig/README.md +55 -0
- package/plugins/litclaude/skills/lsp-setup/scripts/detect-lsp.ts +220 -0
- package/plugins/litclaude/skills/lsp-setup/scripts/lsp-server-table.ts +146 -0
- package/plugins/litclaude/skills/lsp-setup/scripts/tsconfig.json +18 -0
- package/plugins/litclaude/skills/lsp-setup/scripts/verify-lsp.ts +242 -0
- package/plugins/litclaude/skills/start-work/SKILL.md +12 -13
- package/plugins/litclaude/skills/visual-qa/SKILL.md +259 -0
- package/plugins/litclaude/skills/visual-qa/scripts/ansi.ts +17 -0
- package/plugins/litclaude/skills/visual-qa/scripts/cli.ts +96 -0
- package/plugins/litclaude/skills/visual-qa/scripts/east-asian-width.ts +72 -0
- package/plugins/litclaude/skills/visual-qa/scripts/image-diff.ts +109 -0
- package/plugins/litclaude/skills/visual-qa/scripts/png-crc.ts +27 -0
- package/plugins/litclaude/skills/visual-qa/scripts/png-decode.ts +206 -0
- package/plugins/litclaude/skills/visual-qa/scripts/png-synth.ts +57 -0
- package/plugins/litclaude/skills/visual-qa/scripts/tui-grid.ts +88 -0
- package/plugins/litclaude/skills/visual-qa/scripts/types.ts +54 -0
- package/scripts/qa-portable-install.sh +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// verify-lsp.ts <file> [--timeout=ms] [--config=<path>] — perform a real LSP
|
|
3
|
+
// diagnostics roundtrip for <file>: resolve the server for the file extension
|
|
4
|
+
// (from plugins/litclaude/.lsp.json when present, else the embedded table),
|
|
5
|
+
// spawn it, run the JSON-RPC initialize -> initialized -> didOpen handshake over
|
|
6
|
+
// stdio, wait for textDocument/publishDiagnostics, and report OK / FAIL / SKIP.
|
|
7
|
+
//
|
|
8
|
+
// Dependency-free: uses only node:child_process and node:fs. No monorepo imports.
|
|
9
|
+
//
|
|
10
|
+
// Runtime: Node 22.6+ with `node --experimental-strip-types`, or `bun`.
|
|
11
|
+
// node --experimental-strip-types scripts/verify-lsp.ts <file>
|
|
12
|
+
// bun scripts/verify-lsp.ts <file>
|
|
13
|
+
//
|
|
14
|
+
// Exit codes: 0 OK, 1 FAIL (server error / not installed), 2 usage, 3 SKIP
|
|
15
|
+
// (no server known for this extension).
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process"
|
|
18
|
+
import { existsSync, readFileSync, statSync } from "node:fs"
|
|
19
|
+
import { extname, isAbsolute, resolve } from "node:path"
|
|
20
|
+
import { pathToFileURL } from "node:url"
|
|
21
|
+
import process from "node:process"
|
|
22
|
+
|
|
23
|
+
import { LANGUAGES, LSP_CONFIG_FILE } from "./lsp-server-table.ts"
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 60_000
|
|
26
|
+
|
|
27
|
+
interface ResolvedServer {
|
|
28
|
+
readonly language: string
|
|
29
|
+
readonly command: readonly string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isStringArray(value: unknown): value is string[] {
|
|
37
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve the server command for `ext`, preferring a LitClaude .lsp.json entry
|
|
41
|
+
// whose `extensionToLanguage` maps the extension, then the embedded table.
|
|
42
|
+
function resolveServer(ext: string, configPath: string): ResolvedServer | null {
|
|
43
|
+
if (existsSync(configPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed: unknown = JSON.parse(readFileSync(configPath, "utf-8"))
|
|
46
|
+
if (isRecord(parsed)) {
|
|
47
|
+
for (const [language, entry] of Object.entries(parsed)) {
|
|
48
|
+
if (!isRecord(entry)) continue
|
|
49
|
+
const map = entry["extensionToLanguage"]
|
|
50
|
+
const command = entry["command"]
|
|
51
|
+
if (isRecord(map) && Object.keys(map).includes(ext) && isStringArray(command)) {
|
|
52
|
+
return { language, command }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// fall through to embedded table
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const server of LANGUAGES) {
|
|
62
|
+
if (server.extensions.includes(ext)) {
|
|
63
|
+
return { language: server.language, command: [...server.command] }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface JsonRpcMessage {
|
|
70
|
+
readonly method?: string
|
|
71
|
+
readonly id?: number
|
|
72
|
+
readonly params?: unknown
|
|
73
|
+
readonly result?: unknown
|
|
74
|
+
readonly error?: unknown
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function encode(message: unknown): Buffer {
|
|
78
|
+
const body = Buffer.from(JSON.stringify(message), "utf-8")
|
|
79
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Incremental LSP framing parser: pulls complete Content-Length messages off a
|
|
83
|
+
// growing buffer.
|
|
84
|
+
function makeReader(onMessage: (message: JsonRpcMessage) => void): (chunk: Buffer) => void {
|
|
85
|
+
let buffer = Buffer.alloc(0)
|
|
86
|
+
return (chunk: Buffer): void => {
|
|
87
|
+
buffer = Buffer.concat([buffer, chunk])
|
|
88
|
+
while (true) {
|
|
89
|
+
const headerEnd = buffer.indexOf("\r\n\r\n")
|
|
90
|
+
if (headerEnd === -1) return
|
|
91
|
+
const header = buffer.subarray(0, headerEnd).toString("ascii")
|
|
92
|
+
const match = /content-length:\s*(\d+)/i.exec(header)
|
|
93
|
+
if (!match) {
|
|
94
|
+
buffer = buffer.subarray(headerEnd + 4)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
const length = Number.parseInt(match[1] ?? "0", 10)
|
|
98
|
+
const start = headerEnd + 4
|
|
99
|
+
if (buffer.length < start + length) return
|
|
100
|
+
const body = buffer.subarray(start, start + length).toString("utf-8")
|
|
101
|
+
buffer = buffer.subarray(start + length)
|
|
102
|
+
try {
|
|
103
|
+
onMessage(JSON.parse(body) as JsonRpcMessage)
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore malformed frame
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function languageId(ext: string): string {
|
|
112
|
+
const server = LANGUAGES.find((entry) => entry.extensions.includes(ext))
|
|
113
|
+
return server?.language ?? ext.replace(/^\./, "")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function run(filePath: string, timeoutMs: number, configPath: string): Promise<number> {
|
|
117
|
+
const absolute = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath)
|
|
118
|
+
const ext = extname(absolute).toLowerCase()
|
|
119
|
+
const server = resolveServer(ext, configPath)
|
|
120
|
+
|
|
121
|
+
if (server === null) {
|
|
122
|
+
process.stderr.write(`SKIP ${absolute}: no language server known for "${ext}". See references/.\n`)
|
|
123
|
+
return 3
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [bin, ...binArgs] = server.command
|
|
127
|
+
if (bin === undefined) {
|
|
128
|
+
process.stderr.write(`SKIP ${absolute}: empty command for ${server.language}.\n`)
|
|
129
|
+
return 3
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const text = readFileSync(absolute, "utf-8")
|
|
133
|
+
const uri = pathToFileURL(absolute).href
|
|
134
|
+
|
|
135
|
+
return await new Promise<number>((resolveResult) => {
|
|
136
|
+
let child
|
|
137
|
+
try {
|
|
138
|
+
child = spawn(bin, binArgs, { stdio: ["pipe", "pipe", "pipe"] })
|
|
139
|
+
} catch {
|
|
140
|
+
process.stdout.write(`FAIL ${absolute}: language server not installed (${bin})\n`)
|
|
141
|
+
resolveResult(1)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let settled = false
|
|
146
|
+
let stderr = ""
|
|
147
|
+
const finish = (code: number, line: string): void => {
|
|
148
|
+
if (settled) return
|
|
149
|
+
settled = true
|
|
150
|
+
clearTimeout(timer)
|
|
151
|
+
try {
|
|
152
|
+
child.kill("SIGKILL")
|
|
153
|
+
} catch {
|
|
154
|
+
/* already gone */
|
|
155
|
+
}
|
|
156
|
+
process.stdout.write(`${line}\n`)
|
|
157
|
+
resolveResult(code)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
finish(1, `FAIL ${absolute}: timed out after ${timeoutMs}ms waiting for diagnostics`)
|
|
162
|
+
}, timeoutMs)
|
|
163
|
+
|
|
164
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
165
|
+
if (error.code === "ENOENT") {
|
|
166
|
+
finish(1, `FAIL ${absolute}: language server not installed (${bin})`)
|
|
167
|
+
} else {
|
|
168
|
+
finish(1, `FAIL ${absolute}: ${error.message}`)
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
child.on("exit", (code) => {
|
|
172
|
+
if (!settled) {
|
|
173
|
+
finish(1, `FAIL ${absolute}: server exited (code ${code ?? "?"})\n${stderr.trim()}`)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
177
|
+
stderr += chunk.toString("utf-8")
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const send = (message: unknown): void => {
|
|
181
|
+
child.stdin.write(encode(message))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const read = makeReader((message) => {
|
|
185
|
+
if (message.id === 1 && message.result !== undefined) {
|
|
186
|
+
send({ jsonrpc: "2.0", method: "initialized", params: {} })
|
|
187
|
+
send({
|
|
188
|
+
jsonrpc: "2.0",
|
|
189
|
+
method: "textDocument/didOpen",
|
|
190
|
+
params: { textDocument: { uri, languageId: languageId(ext), version: 1, text } },
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
if (message.method === "textDocument/publishDiagnostics" && isRecord(message.params)) {
|
|
194
|
+
const diagnostics = message.params["diagnostics"]
|
|
195
|
+
const count = Array.isArray(diagnostics) ? diagnostics.length : 0
|
|
196
|
+
if (message.params["uri"] === uri) {
|
|
197
|
+
finish(0, `OK ${absolute}: LSP roundtrip succeeded (${count} diagnostic(s)) via ${server.language}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
child.stdout.on("data", read)
|
|
202
|
+
|
|
203
|
+
send({
|
|
204
|
+
jsonrpc: "2.0",
|
|
205
|
+
id: 1,
|
|
206
|
+
method: "initialize",
|
|
207
|
+
params: {
|
|
208
|
+
processId: process.pid,
|
|
209
|
+
rootUri: pathToFileURL(process.cwd()).href,
|
|
210
|
+
capabilities: { textDocument: { publishDiagnostics: {} } },
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseTimeout(args: readonly string[]): number {
|
|
217
|
+
const flag = args.find((arg) => arg.startsWith("--timeout="))
|
|
218
|
+
if (flag === undefined) return DEFAULT_TIMEOUT_MS
|
|
219
|
+
const parsed = Number.parseInt(flag.slice("--timeout=".length), 10)
|
|
220
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function main(): Promise<void> {
|
|
224
|
+
const args = process.argv.slice(2)
|
|
225
|
+
const configFlag = args.find((arg) => arg.startsWith("--config="))
|
|
226
|
+
const configPath = configFlag ? configFlag.slice("--config=".length) : LSP_CONFIG_FILE
|
|
227
|
+
const filePath = args.find((arg) => !arg.startsWith("--"))
|
|
228
|
+
|
|
229
|
+
if (filePath === undefined) {
|
|
230
|
+
process.stderr.write("Usage: verify-lsp.ts <file> [--timeout=ms] [--config=<path>]\n")
|
|
231
|
+
process.exit(2)
|
|
232
|
+
}
|
|
233
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
234
|
+
process.stderr.write(`verify-lsp: not a file: ${filePath}\n`)
|
|
235
|
+
process.exit(2)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const code = await run(filePath, parseTimeout(args), configPath)
|
|
239
|
+
process.exit(code)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await main()
|
|
@@ -25,19 +25,18 @@ Before the first edit:
|
|
|
25
25
|
|
|
26
26
|
## Native Goal + Dynamic Workflow
|
|
27
27
|
|
|
28
|
-
Before the first checkbox,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
help the user keep the session bounded.
|
|
28
|
+
Before the first checkbox, bind the native goal. Claude Code's `/goal` (v2.1.139+) is a
|
|
29
|
+
**user-typed slash command**, not a model-facing tool, and a hook/skill **cannot invoke it**:
|
|
30
|
+
|
|
31
|
+
- For a long plan, offer the user one ready-to-paste `/goal <plan completion condition>` line —
|
|
32
|
+
this is the real way to bind the native session goal — and keep the Boulder/ledger discipline.
|
|
33
|
+
- Forward-compat only: if a future Claude Code build exposes model-facing goal tools, call
|
|
34
|
+
`get_goal` if exposed, `create_goal` with the plan objective only when no matching active goal
|
|
35
|
+
exists, and `update_goal` only after all top-level checkboxes and final gates are complete (or
|
|
36
|
+
when the plan is genuinely blocked). These are not exposed today.
|
|
37
|
+
- Do not auto-type or send `/goal` text from a skill; if the user invokes `/goal`, respect it.
|
|
38
|
+
|
|
39
|
+
If model-facing goal tools are absent (the case today), continue with Boulder/ledger discipline.
|
|
41
40
|
If goal tools are not exposed, do not print repeated fallback status; just keep
|
|
42
41
|
the plan ledger accurate and continue with verified evidence.
|
|
43
42
|
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: visual-qa
|
|
3
|
+
description: "Rigorous visual QA for any UI you built or changed, across BOTH web/page UIs and TUI/terminal UIs. MUST USE after building or changing any UI to verify it visually before declaring it done. Captures objective reference evidence with a bundled diff script (image-diff for screenshots, tui-check for terminal captures), then runs two parallel read-only oracle passes (design-system and functional integrity; visual fidelity and CJK precision) and synthesizes one good/bad verdict. Triggers: visual QA, visual regression, screenshot diff, pixel diff, image comparison, UI looks wrong, design system check, alpha channel breakage, responsive check, CJK text, Korean/Japanese/Chinese text clipping, glyph drop, TUI alignment, terminal UI, tmux capture, box-drawing border misalignment, wide-character column drift."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Visual QA - Dual-Oracle Web and TUI Verification
|
|
7
|
+
|
|
8
|
+
The LitClaude visual specialist for Claude Code. Verify a rendered UI against
|
|
9
|
+
intent using objective script evidence plus two parallel read-only oracle
|
|
10
|
+
passes, then synthesize one good/bad verdict. The script numbers focus the
|
|
11
|
+
reviewers. They are not the verdict.
|
|
12
|
+
|
|
13
|
+
## Runtime
|
|
14
|
+
|
|
15
|
+
The bundled scripts are dependency-free TypeScript with zero external packages
|
|
16
|
+
(no install step). Run them with either:
|
|
17
|
+
|
|
18
|
+
- Node 22.6+: `node --experimental-strip-types "$SKILL_DIR/scripts/cli.ts" ...`
|
|
19
|
+
- bun: `bun "$SKILL_DIR/scripts/cli.ts" ...`
|
|
20
|
+
|
|
21
|
+
PNG decoding, CRC, deflate/inflate, ANSI stripping, and East-Asian width all use
|
|
22
|
+
only Node built-ins (`node:fs`, `node:zlib`, `node:buffer`). `$SKILL_DIR` is this
|
|
23
|
+
skill's own directory (the folder containing this SKILL.md).
|
|
24
|
+
|
|
25
|
+
## Purpose and when to use
|
|
26
|
+
|
|
27
|
+
- Use after you build or change any UI, before calling it done. Covers web/page
|
|
28
|
+
UIs and TUI/terminal UIs.
|
|
29
|
+
- Use when output must match a mock, a baseline, or a stated design intent; when
|
|
30
|
+
you suspect a regression; when CJK (Korean/Japanese/Chinese) text may clip,
|
|
31
|
+
misalign, or wrap awkwardly; when a claimed design system might actually be a
|
|
32
|
+
flat image; when a terminal layout may overflow or its borders may break.
|
|
33
|
+
- Skip when there is no rendered surface (pure backend or library logic with no
|
|
34
|
+
visual or terminal output). For broad post-implementation review use
|
|
35
|
+
review-work; this skill is the visual specialist.
|
|
36
|
+
|
|
37
|
+
## Step 1 - Detect the surface
|
|
38
|
+
|
|
39
|
+
- Web/page UI: renders in a browser (HTML/CSS/JS, components, canvas, SVG).
|
|
40
|
+
Evidence is screenshots.
|
|
41
|
+
- TUI/terminal UI: renders as text in a terminal (box-drawing, panes, status
|
|
42
|
+
lines, REPL/TUI apps). Evidence is terminal captures.
|
|
43
|
+
|
|
44
|
+
If the change touches both, run both capture tracks and feed both into the passes.
|
|
45
|
+
|
|
46
|
+
## Step 2 - Capture objective reference evidence
|
|
47
|
+
|
|
48
|
+
### Web
|
|
49
|
+
|
|
50
|
+
1. Capture a REFERENCE image: the user's mock/target, or a known-good baseline.
|
|
51
|
+
Save as PNG.
|
|
52
|
+
2. Capture the ACTUAL rendered screenshot at the same viewport size using the
|
|
53
|
+
project's browser tooling (Claude Code's browser/Playwright tooling). Save as
|
|
54
|
+
PNG.
|
|
55
|
+
3. Run the diff and keep the JSON:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
node --experimental-strip-types "$SKILL_DIR/scripts/cli.ts" image-diff <reference.png> <actual.png>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Key fields: `dimensionsMatch`, `diffRatio` (0..1), `similarityScore` (0..100),
|
|
62
|
+
`alphaChannelIntact`, `hotspots[]` (grid regions ranked by `diffRatio`).
|
|
63
|
+
|
|
64
|
+
### TUI
|
|
65
|
+
|
|
66
|
+
1. Capture plain text and an ANSI-preserving copy:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
tmux capture-pane -p > capture.txt
|
|
70
|
+
tmux capture-pane -e -p > capture-ansi.txt
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
2. Run the check with the REAL terminal width and keep the JSON:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
node --experimental-strip-types "$SKILL_DIR/scripts/cli.ts" tui-check capture.txt --cols <N>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Key fields: `maxWidth`, `overflowLines[]`, `borderMisaligned`,
|
|
80
|
+
`wideCharColumns[]`, `hasAnsi`.
|
|
81
|
+
|
|
82
|
+
This JSON (diff ratio, similarity score, hotspots or overflow lines, border
|
|
83
|
+
alignment, wide-char columns, alpha) is REFERENCE evidence to aim the reviewers.
|
|
84
|
+
It is not the verdict by itself.
|
|
85
|
+
|
|
86
|
+
## Step 3 - Dispatch two read-only QA subagents in parallel
|
|
87
|
+
|
|
88
|
+
Spawn BOTH oracle passes as background subagents in a single message using
|
|
89
|
+
Claude Code's `Task` tool with `run_in_background: true`, so they run
|
|
90
|
+
concurrently. Each oracle is read-only: it reviews and reports, and must not
|
|
91
|
+
modify files. Each returns PASS, REVISE, or FAIL with concrete, located
|
|
92
|
+
findings. Pass A proves the surface is a real design-system implementation, not
|
|
93
|
+
a mock-only or faked-image substitute. Pass B directly opens screenshots and
|
|
94
|
+
inspects source/content for visual and CJK defects. The main session waits for
|
|
95
|
+
both background subagents to finish, then synthesizes the verdict.
|
|
96
|
+
|
|
97
|
+
Paste evidence directly into each prompt: source code, the plain-text TUI
|
|
98
|
+
captures, the script JSON, and the screenshot paths plus your described
|
|
99
|
+
observations for web. The two passes differ in depth by charter, not by any
|
|
100
|
+
model or effort setting.
|
|
101
|
+
|
|
102
|
+
### Pass A - Design-system and functional integrity (deeper, strict)
|
|
103
|
+
|
|
104
|
+
Launch with the `Task` tool, `run_in_background: true`, description
|
|
105
|
+
"Visual QA pass A: design-system and functional integrity", prompt:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
REVIEW TYPE: DESIGN-SYSTEM AND FUNCTIONAL INTEGRITY (read-only)
|
|
109
|
+
TIER INTENT: Treat this as the deeper, stricter pass. Reason exhaustively before concluding. Assume a plausible-looking surface may be faked or mock-only until the source proves otherwise. Do not modify any file.
|
|
110
|
+
|
|
111
|
+
INTENT:
|
|
112
|
+
{What the user asked for, the mock or baseline, and the constraints.}
|
|
113
|
+
|
|
114
|
+
SURFACE: {web | tui | both}
|
|
115
|
+
|
|
116
|
+
SOURCE CODE:
|
|
117
|
+
{Full source of the UI: components, styles/tokens, layout, render code. Include neighboring files that show existing patterns.}
|
|
118
|
+
|
|
119
|
+
CAPTURES:
|
|
120
|
+
{Web: actual screenshot path(s) plus your described observations. TUI: paste capture.txt and capture-ansi.txt inline.}
|
|
121
|
+
|
|
122
|
+
SHARED SCRIPT EVIDENCE (reference, not verdict):
|
|
123
|
+
{Paste the image-diff or tui-check JSON. Use alphaChannelIntact for the transparency check.}
|
|
124
|
+
|
|
125
|
+
CHECK EACH:
|
|
126
|
+
1. Real design system vs ad-hoc/mock-only: are styles driven by coherent design tokens and reused primitives, or one-off hardcoded values scattered per element? Treat mock-only screens, static compositions, or one-page hardcoded styling with no reusable system as BLOCKING unless the user explicitly requested a throwaway mock.
|
|
127
|
+
2. Faked-with-an-image anti-pattern: is the UI a real DOM/component tree, or a pasted raster/screenshot or background-image standing in for live elements? For TUI: a real layout that reflows, or hardcoded pre-rendered text at fixed widths?
|
|
128
|
+
3. Alpha and transparency: handled correctly, with no unexpected opaque or black fills and correct PNG/CSS alpha? Cross-check alphaChannelIntact.
|
|
129
|
+
4. Code style and implementation quality.
|
|
130
|
+
5. Responsive and resize behavior across viewport sizes (web) or terminal resize (TUI).
|
|
131
|
+
6. Do the user-intended FEATURES actually work: interactions, states, navigation (web); input handling, resize, scroll (TUI)? Trace the code paths.
|
|
132
|
+
|
|
133
|
+
OUTPUT:
|
|
134
|
+
VERDICT: PASS | REVISE | FAIL
|
|
135
|
+
CONFIDENCE: HIGH | MEDIUM | LOW
|
|
136
|
+
SUMMARY: 1-3 sentences
|
|
137
|
+
FINDINGS: for each, [dimension] [severity] what is wrong, where (file/line or capture region), and the concrete fix
|
|
138
|
+
WHAT IS GOOD: correct aspects that must not regress
|
|
139
|
+
BLOCKING: items that must be fixed; empty if PASS
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Pass B - Visual fidelity and CJK precision (focused)
|
|
143
|
+
|
|
144
|
+
Launch with the `Task` tool, `run_in_background: true`, description
|
|
145
|
+
"Visual QA pass B: visual fidelity and CJK precision", prompt:
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
REVIEW TYPE: VISUAL FIDELITY AND CJK PRECISION (read-only)
|
|
149
|
+
TIER INTENT: Treat this as the focused visual pass. Directly open the screenshots with Claude Code's image-reading tool (the Read tool on the PNG path) before judging. Anchor every claim to the script evidence, source code, and captures. Do not modify any file.
|
|
150
|
+
|
|
151
|
+
INTENT:
|
|
152
|
+
{What the user requested and the mock or baseline to match.}
|
|
153
|
+
|
|
154
|
+
SURFACE: {web | tui | both}
|
|
155
|
+
|
|
156
|
+
CAPTURES:
|
|
157
|
+
{Web: actual and reference screenshot paths plus your described observations. TUI: paste capture.txt and capture-ansi.txt inline.}
|
|
158
|
+
|
|
159
|
+
SOURCE CODE:
|
|
160
|
+
{For web: include the rendered text/content, components, typography, layout, and style code. For TUI: include render code that controls wrapping, width, and wide-character handling.}
|
|
161
|
+
|
|
162
|
+
SCRIPT EVIDENCE (required, consume every field):
|
|
163
|
+
{Paste the image-diff or tui-check JSON.}
|
|
164
|
+
|
|
165
|
+
USE THE EVIDENCE:
|
|
166
|
+
- Web (image-diff): start from diffRatio and similarityScore, then directly open every screenshot path and inspect every hotspots[] entry (gridX, gridY, x, y, width, height, diffRatio). Explain the visual cause of each flagged region from the pixels and source/content together.
|
|
167
|
+
- TUI (tui-check): inspect maxWidth vs expectedColumns, every overflowLines[] entry, borderMisaligned, and wideCharColumns[].
|
|
168
|
+
|
|
169
|
+
CHECK:
|
|
170
|
+
1. Does the rendered output match what the user requested: layout, spacing, color, type, alignment?
|
|
171
|
+
2. CJK precision:
|
|
172
|
+
- Web: natural CJK line breaking for display and body text. Flag oversized headings that create orphaned one-character or final-syllable lines, split Korean/Japanese/Chinese semantic phrases unnaturally, detach labels such as `[Image #1]` from their content, clip baselines/descenders, drop glyphs (tofu), or show font metric mismatch.
|
|
173
|
+
- TUI: wide-character column drift (CJK cells counted as 1 instead of 2), box-drawing border misalignment, content overflowing past the terminal width.
|
|
174
|
+
|
|
175
|
+
OUTPUT:
|
|
176
|
+
VERDICT: PASS | REVISE | FAIL
|
|
177
|
+
CONFIDENCE: HIGH | MEDIUM | LOW
|
|
178
|
+
SUMMARY: 1-3 sentences
|
|
179
|
+
EVIDENCE TRACE: each hotspot or overflow line mapped to its visual cause
|
|
180
|
+
FINDINGS: for each, [severity] what is wrong, where (hotspot grid or capture line:col), and the concrete fix
|
|
181
|
+
BLOCKING: items that must be fixed; empty if PASS
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Step 4 - Synthesize one verdict
|
|
185
|
+
|
|
186
|
+
When both background subagents return, merge them into a single report. Per
|
|
187
|
+
dimension, mark good or bad with evidence. For each bad item, state what is
|
|
188
|
+
wrong, where (file/line, hotspot grid, or capture line), and the concrete fix.
|
|
189
|
+
Call out what is genuinely good so it is not regressed later.
|
|
190
|
+
|
|
191
|
+
Completion gate: do not declare the UI done until both passes are satisfied, OR
|
|
192
|
+
the remaining gaps are explicitly listed and accepted by the user. A high
|
|
193
|
+
`similarityScore` with an open Pass A finding, for example a faked-image layout
|
|
194
|
+
or a broken feature, is still a FAIL.
|
|
195
|
+
|
|
196
|
+
```markdown
|
|
197
|
+
# Visual QA - Verdict: GOOD | NEEDS WORK
|
|
198
|
+
|
|
199
|
+
| Dimension | Pass | Verdict | Evidence |
|
|
200
|
+
|---|---|---|---|
|
|
201
|
+
| Design system real vs faked | A | good/bad | ... |
|
|
202
|
+
| Features work | A | good/bad | ... |
|
|
203
|
+
| Responsive / resize | A | good/bad | ... |
|
|
204
|
+
| Alpha / transparency | A+B | good/bad | ... |
|
|
205
|
+
| Visual fidelity to intent | B | good/bad | ... |
|
|
206
|
+
| CJK precision | B | good/bad | ... |
|
|
207
|
+
|
|
208
|
+
## Must fix
|
|
209
|
+
[Blocking items, each with location and fix, in priority order]
|
|
210
|
+
|
|
211
|
+
## Good, keep it
|
|
212
|
+
[Correct aspects that must not regress]
|
|
213
|
+
|
|
214
|
+
## Completion gate
|
|
215
|
+
[Satisfied, or the exact remaining gaps and who accepted them]
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Reference evidence is not the verdict
|
|
219
|
+
|
|
220
|
+
The script quantifies pixels and columns. It cannot judge whether the result is
|
|
221
|
+
a real design system, whether features work, or whether intent was met. A 99/100
|
|
222
|
+
`similarityScore` can still hide a pasted-image fake, a broken interaction, or
|
|
223
|
+
clipped CJK descenders. Use the numbers to aim the oracles, then trust the
|
|
224
|
+
synthesized review.
|
|
225
|
+
|
|
226
|
+
Illustrative output (locked field names):
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"command": "image-diff",
|
|
231
|
+
"dimensionsMatch": true,
|
|
232
|
+
"reference": { "width": 1440, "height": 900 },
|
|
233
|
+
"actual": { "width": 1440, "height": 900 },
|
|
234
|
+
"totalPixels": 1296000,
|
|
235
|
+
"diffPixels": 38880,
|
|
236
|
+
"diffRatio": 0.03,
|
|
237
|
+
"similarityScore": 97,
|
|
238
|
+
"alphaChannelIntact": true,
|
|
239
|
+
"hotspots": [
|
|
240
|
+
{ "gridX": 2, "gridY": 0, "x": 960, "y": 0, "width": 480, "height": 300, "diffRatio": 0.21 }
|
|
241
|
+
],
|
|
242
|
+
"summary": "97/100 similarity; one hotspot in the top-right header region."
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"command": "tui-check",
|
|
249
|
+
"expectedColumns": 80,
|
|
250
|
+
"lineCount": 24,
|
|
251
|
+
"lineWidths": [80, 80, 82, 80],
|
|
252
|
+
"maxWidth": 82,
|
|
253
|
+
"overflowLines": [ { "line": 3, "width": 82 } ],
|
|
254
|
+
"borderMisaligned": true,
|
|
255
|
+
"wideCharColumns": [12, 13],
|
|
256
|
+
"hasAnsi": false,
|
|
257
|
+
"summary": "Line 3 overflows 80 cols by 2; borders misaligned at wide-char columns 12-13."
|
|
258
|
+
}
|
|
259
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const ESC = String.fromCharCode(0x1b)
|
|
2
|
+
const CSI = String.fromCharCode(0x9b)
|
|
3
|
+
|
|
4
|
+
// Matches CSI/escape sequences (colors, cursor moves) without embedding raw
|
|
5
|
+
// control characters in the regex source.
|
|
6
|
+
const ANSI_PATTERN = new RegExp(
|
|
7
|
+
`[${ESC}${CSI}][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`,
|
|
8
|
+
"g",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export function stripAnsi(input: string): string {
|
|
12
|
+
return input.replace(ANSI_PATTERN, "")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hasAnsi(input: string): boolean {
|
|
16
|
+
return stripAnsi(input) !== input
|
|
17
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import process from "node:process"
|
|
5
|
+
|
|
6
|
+
import { diffImages } from "./image-diff.ts"
|
|
7
|
+
import { decodePng } from "./png-decode.ts"
|
|
8
|
+
import { checkTui } from "./tui-grid.ts"
|
|
9
|
+
import type { ImageDiffResult, TuiCheckResult } from "./types.ts"
|
|
10
|
+
|
|
11
|
+
export class CliError extends Error {
|
|
12
|
+
readonly name = "CliError"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_COLUMNS = 80
|
|
16
|
+
const COLS_FLAG = "--cols"
|
|
17
|
+
|
|
18
|
+
export function runImageDiff(args: readonly string[]): ImageDiffResult {
|
|
19
|
+
const referencePath = args[0]
|
|
20
|
+
const actualPath = args[1]
|
|
21
|
+
if (referencePath === undefined || actualPath === undefined) {
|
|
22
|
+
throw new CliError("usage: image-diff <reference.png> <actual.png>")
|
|
23
|
+
}
|
|
24
|
+
const reference = decodePng(readFileSync(referencePath))
|
|
25
|
+
const actual = decodePng(readFileSync(actualPath))
|
|
26
|
+
return diffImages(reference, actual)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseColumns(args: readonly string[]): number {
|
|
30
|
+
for (let index = 0; index < args.length; index++) {
|
|
31
|
+
const arg = args[index] ?? ""
|
|
32
|
+
if (arg === COLS_FLAG) {
|
|
33
|
+
const parsed = Number(args[index + 1])
|
|
34
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
35
|
+
throw new CliError(`${COLS_FLAG} requires a positive integer`)
|
|
36
|
+
}
|
|
37
|
+
return parsed
|
|
38
|
+
}
|
|
39
|
+
if (arg.startsWith(`${COLS_FLAG}=`)) {
|
|
40
|
+
const parsed = Number(arg.slice(COLS_FLAG.length + 1))
|
|
41
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
42
|
+
throw new CliError(`${COLS_FLAG} requires a positive integer`)
|
|
43
|
+
}
|
|
44
|
+
return parsed
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return DEFAULT_COLUMNS
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function runTuiCheck(args: readonly string[]): TuiCheckResult {
|
|
51
|
+
const capturePath = args[0]
|
|
52
|
+
if (capturePath === undefined || capturePath.startsWith("--")) {
|
|
53
|
+
throw new CliError("usage: tui-check <capture.txt> [--cols N]")
|
|
54
|
+
}
|
|
55
|
+
const text = readFileSync(capturePath, "utf8")
|
|
56
|
+
return checkTui(text, parseColumns(args.slice(1)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function run(argv: readonly string[]): ImageDiffResult | TuiCheckResult {
|
|
60
|
+
const command = argv[0]
|
|
61
|
+
const rest = argv.slice(1)
|
|
62
|
+
switch (command) {
|
|
63
|
+
case "image-diff":
|
|
64
|
+
return runImageDiff(rest)
|
|
65
|
+
case "tui-check":
|
|
66
|
+
return runTuiCheck(rest)
|
|
67
|
+
default:
|
|
68
|
+
throw new CliError(`unknown command "${command ?? ""}"; expected "image-diff" or "tui-check"`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function main(argv: readonly string[]): void {
|
|
73
|
+
try {
|
|
74
|
+
const result = run(argv)
|
|
75
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
78
|
+
process.stderr.write(`visual-qa error: ${message}\n`)
|
|
79
|
+
process.exitCode = 1
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Entry guard that works under both bun and Node (`--experimental-strip-types`).
|
|
84
|
+
function isMainModule(): boolean {
|
|
85
|
+
const entry = process.argv[1]
|
|
86
|
+
if (entry === undefined) return false
|
|
87
|
+
try {
|
|
88
|
+
return resolve(fileURLToPath(import.meta.url)) === resolve(entry)
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (isMainModule()) {
|
|
95
|
+
main(process.argv.slice(2))
|
|
96
|
+
}
|