diff --git a/.claude/skills/questdb-docs-writer/SKILL.md b/.claude/skills/questdb-docs-writer/SKILL.md index 1b3c42ab9..728d145c3 100644 --- a/.claude/skills/questdb-docs-writer/SKILL.md +++ b/.claude/skills/questdb-docs-writer/SKILL.md @@ -710,6 +710,24 @@ in `src/theme/MDXComponents.js`). Store screenshots as `.webp` in cwebp -lossless -z 9 -m 6 -mt input.png -o output.webp ``` +Commit only the WebP. The repo's lint-staged hook quantizes staged PNG and +JPEG files lossily (pngquant, mozjpeg), and leaves WebP alone. + +#### Capture at 2x + +A screenshot shown at `width={800}` needs to be at least 1600 px wide to stay +sharp on a retina screen, captured as PNG and kept lossless until the WebP. +Screenshots from the Claude in Chrome `screenshot` action are downscaled 1x +JPEGs and look blurry; so does a wide window shrunk into the column. Capture +Web Console and other UI screenshots with headless Chrome at +`deviceScaleFactor: 2` instead. Read +[references/ui-screenshots/README.md](references/ui-screenshots/README.md) +before taking any: it covers loading notebooks from the user's browser into a +throwaway profile, running every cell, cleaning the frame, showing "MCP +connected" without opening the user's browser, and the encode check. The +driver script `references/ui-screenshots/cdp-shot.mjs` needs only Node 22 and +Chrome. + ## SQL parser sync check When documenting new SQL features, check whether the `questdb/sql-parser` diff --git a/.claude/skills/questdb-docs-writer/references/ui-screenshots/README.md b/.claude/skills/questdb-docs-writer/references/ui-screenshots/README.md new file mode 100644 index 000000000..d507a6a5a --- /dev/null +++ b/.claude/skills/questdb-docs-writer/references/ui-screenshots/README.md @@ -0,0 +1,98 @@ +# Sharp UI screenshots (Web Console and other browser UIs) + +How to capture screenshots of a browser UI that stay sharp on a retina +screen, for blog posts and docs alike. The driver script `cdp-shot.mjs` sits +next to this file. + +## Why screenshots come out blurry + +Two things make a screenshot blurry, and either one is enough: + +1. **It was captured at 1x.** A browser-automation screenshot (the Claude in + Chrome `screenshot` action, or a window on a non-retina monitor) is 1x, and + the extension also downscales and saves it as JPEG. Text then gets + upscaled on every retina screen. Converting that JPEG to WebP adds a + second lossy step. +2. **It shows too wide a window.** A 1500 CSS px console shown in a column + 880 px wide is shrunk to 58%, so its text ends up at about 7 px. + +Rule of thumb: the image must be at least **twice the width it is displayed +at** (the blog column is 880 CSS px and docs screenshots usually render with +`width={800}`, so 1600 to 1760 px or more), captured as PNG, and kept lossless +until the final WebP. + +## What does not work + +- **The Claude in Chrome screenshot action.** It saves downscaled 1x JPEGs. + While the extension controls a tab, Chrome also shows a "started debugging + this browser" bar and an overlay on that window, so an OS screenshot of the + user's window includes them. +- **macOS `screencapture` from the shell.** It needs Screen Recording + permission for the terminal. Don't change that setting; use headless + Chrome instead. +- **Asking the user to move windows or take the shots.** Headless Chrome needs + nothing from them. + +## The workflow: headless Chrome at 2x over CDP + +1. **Start a headless Chrome with a throwaway profile** (command at the top of + `cdp-shot.mjs`). Device metrics are overridden to `deviceScaleFactor: 2`, + so a 1512 CSS px viewport captures at 3024 px, independent of any monitor. + +2. **Open the page** with `node cdp-shot.mjs nav `. + +3. **Bring over browser-local state.** Web Console notebooks live in the + browser's IndexedDB (database `web-console`, store `buffers`), per origin. + A fresh profile has none. Export the records from the browser that has + them, by reading `buffers` with `getAll()` in a tab on the same origin + (records with a `notebookViewState` are notebooks). Then, at the same + origin in headless Chrome, run `node cdp-shot.mjs inject export.json` and + navigate again so the console picks them up. Exported notebooks are worth + keeping anyway: they are the reproducible record of what an agent built. + +4. **Open the notebook and run it.** Query results are not part of the + record, so table cells show their SQL until they run. The notebook's + refresh button only re-runs charts. Run every cell: + + ```bash + node cdp-shot.mjs eval "(async () => { + const bs = [...document.querySelectorAll('button[aria-label=\"Run cell\"]')]; + for (const b of bs) { b.click(); await new Promise(r => setTimeout(r, 4000)) } + return bs.length })()" + ``` + +5. **Clean the frame.** Dismiss what isn't the subject: the + `vm.max_map_count` warning banner, the "Work with your coding agent via + MCP" promo card, and the schema side panel. Use real clicks + (`cdp-shot.mjs click x y`); a DOM `.click()` on a console tab doesn't + switch tabs. Dismissing a banner moves everything below it, so take a + fresh shot before computing the next click position. + +6. **Frame by element, not by guesswork.** Measure panels with + `getBoundingClientRect()`, scroll the panel into view with + `scrollIntoView({ block: 'start' })`, then nudge the scrolling container + back a little for margin. Raise `VH` until the panels you want fit, and + crop with the `shot` clip (`shot out.png 0 0 1512 940`). Keep the same `VW` + and `VH` for the measuring step and the shot, because each command + re-applies the viewport. + +7. **Showing "MCP connected" in the status bar.** Start an MCP session for + that console origin and call `get_pairing_credentials` with + `auto_open_browser: false`; otherwise the server opens the deep link in the + user's default browser. Open the deep link in headless Chrome, click + **Connect** with the permission the story needs, and stop the session after + the shot. + +8. **Encode and check.** Encode UI screenshots as lossless WebP, which for flat + UI is both the best quality and the smallest file (q92 lossy came out + about twice the size): + + ```bash + cwebp -lossless -z 9 -mt shot.png -o image.webp + sips -g pixelWidth -g pixelHeight image.webp # expect >= 2x display width + ``` + + Then read the image back and check that the text is crisp, the panels + have finished loading, and nothing is half-rendered. + +Keep the source PNGs out of the repo; commit only the WebP. diff --git a/.claude/skills/questdb-docs-writer/references/ui-screenshots/cdp-shot.mjs b/.claude/skills/questdb-docs-writer/references/ui-screenshots/cdp-shot.mjs new file mode 100644 index 000000000..4c19d316a --- /dev/null +++ b/.claude/skills/questdb-docs-writer/references/ui-screenshots/cdp-shot.mjs @@ -0,0 +1,108 @@ +// Drive a headless Chrome over CDP to capture sharp 2x UI screenshots. +// No npm dependencies: Node 22+ (global WebSocket and fetch). +// +// Start Chrome first, with a throwaway profile: +// "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ +// --headless=new --remote-debugging-port=9333 \ +// --user-data-dir="$TMPDIR/shot-profile" --hide-scrollbars \ +// --no-first-run --no-default-browser-check about:blank +// +// Commands (VW/VH env vars set the viewport in CSS px, default 1512x860): +// node cdp-shot.mjs nav navigate, wait WAIT ms (5000) +// node cdp-shot.mjs eval print the JSON result +// node cdp-shot.mjs evalfile +// node cdp-shot.mjs click [ ..] real mouse clicks, CSS px +// node cdp-shot.mjs inject load Web Console notebooks +// node cdp-shot.mjs shot [x y w h] PNG at 2x, optional CSS clip +// +// Every command reconnects and re-applies the 2x device metrics, so keep VW +// and VH the same between a layout step and the shot that follows it. +import fs from "node:fs" + +const PORT = process.env.CDP_PORT || 9333 +const [cmd, ...args] = process.argv.slice(2) +const base = `http://127.0.0.1:${PORT}` +const targets = await (await fetch(`${base}/json/list`)).json() +let page = targets.find((t) => t.type === "page") +if (!page) page = await (await fetch(`${base}/json/new?about:blank`, { method: "PUT" })).json() + +const ws = new WebSocket(page.webSocketDebuggerUrl) +await new Promise((r) => ws.addEventListener("open", r, { once: true })) +let seq = 0 +const pending = new Map() +ws.addEventListener("message", (e) => { + const m = JSON.parse(e.data) + if (m.id && pending.has(m.id)) { + pending.get(m.id)(m) + pending.delete(m.id) + } +}) +const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++seq + pending.set(id, (m) => (m.error ? reject(new Error(JSON.stringify(m.error))) : resolve(m.result))) + ws.send(JSON.stringify({ id, method, params })) + }) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const evaluate = async (expr) => { + const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }) + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text) + return r.result.value +} + +await send("Emulation.setDeviceMetricsOverride", { + width: Number(process.env.VW || 1512), + height: Number(process.env.VH || 860), + deviceScaleFactor: 2, + mobile: false, +}) + +if (cmd === "nav") { + await send("Page.enable") + await send("Page.navigate", { url: args[0] }) + await sleep(Number(process.env.WAIT || 5000)) + console.log(await evaluate("document.title + ' ' + location.href")) +} else if (cmd === "eval") { + console.log(JSON.stringify(await evaluate(args.join(" ")), null, 1)) +} else if (cmd === "evalfile") { + console.log(JSON.stringify(await evaluate(fs.readFileSync(args[0], "utf8")), null, 1)) +} else if (cmd === "click") { + for (let i = 0; i < args.length; i += 2) { + for (const type of ["mousePressed", "mouseReleased"]) + await send("Input.dispatchMouseEvent", { type, x: +args[i], y: +args[i + 1], button: "left", clickCount: 1 }) + await sleep(Number(process.env.WAIT || 800)) + } + console.log("clicked") +} else if (cmd === "inject") { + // Accepts an array of `buffers` records, or { notebooks: [...] }. + const data = JSON.parse(fs.readFileSync(args[0], "utf8")) + const recs = Array.isArray(data) ? data : data.notebooks + console.log( + JSON.stringify( + await evaluate(`(async () => { + const recs = ${JSON.stringify(recs)}; + const db = await new Promise((res, rej) => { + const r = indexedDB.open('web-console'); + r.onsuccess = () => res(r.result); + r.onerror = () => rej(r.error); + }); + const tx = db.transaction('buffers', 'readwrite'); + for (const rec of recs) tx.objectStore('buffers').put(rec); + await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = () => rej(tx.error); }); + db.close(); + return recs.map((r) => r.label); + })()`), + ), + ) +} else if (cmd === "shot") { + const [out, x, y, w, h] = args + const params = { format: "png" } + if (w) params.clip = { x: +x, y: +y, width: +w, height: +h, scale: 1 } + const r = await send("Page.captureScreenshot", params) + fs.writeFileSync(out, Buffer.from(r.data, "base64")) + console.log("saved", out) +} else { + console.error("unknown command:", cmd) + process.exitCode = 1 +} +ws.close()