Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/skills/questdb-docs-writer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <url>`.

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.
Original file line number Diff line number Diff line change
@@ -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 <url> navigate, wait WAIT ms (5000)
// node cdp-shot.mjs eval <js-expression> print the JSON result
// node cdp-shot.mjs evalfile <file.js>
// node cdp-shot.mjs click <x> <y> [<x> <y>..] real mouse clicks, CSS px
// node cdp-shot.mjs inject <export.json> load Web Console notebooks
// node cdp-shot.mjs shot <out.png> [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()
Loading