Skip to content
Back to skills

Run Splotch

ASecurity

Launch, run, and screenshot the Splotch web app to confirm a change works in the real running app (not just tests). Use when asked to run/start Splotch, open it in a browser, take a screenshot, draw on the canvas, or visually verify a UI change. Covers the / drawing app, /admin, and /privacy routes. For Android/iOS native builds use the `mobile` skill instead.

  • 7 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 29, 2026
developmentgobashnodeawstestinggitapi

Works with

  • claude code
  • cli
  • api

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 2 files and shows the line behind each finding

Scanned October 6, 2026

npx -y skills add KyleMit/Splotch --skill run-splotch --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Run Splotch?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Run Splotch
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kylemit-run-splotch/badge)](https://www.skillsdirectory.com/skills/kylemit-run-splotch)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: run-splotch
description: Launch, run, and screenshot the Splotch web app to confirm a change works in the real running app (not just tests). Use when asked to run/start Splotch, open it in a browser, take a screenshot, draw on the canvas, or visually verify a UI change. Covers the / drawing app, /admin, and /privacy routes. For Android/iOS native builds use the `mobile` skill instead.
---

# Run Splotch (web)

Splotch is a SvelteKit web app (`/` drawing canvas, `/admin` console, `/privacy`). There is no
`chromium-cli` on this project, so the driver is [`driver.mjs`](driver.mjs) — it launches a
`vite dev` server, opens Playwright's bundled Chromium, optionally draws a stroke, and saves a
screenshot. Playwright is already a devDependency; the same Chromium the E2E suite uses.

**All paths below are relative to the repo root.**

## Prerequisites

Deps + the Playwright Chromium binary (one-time):

```bash
pnpm install
npm run test:e2e:install
```

(On a Linux box you'd also need `npx playwright install-deps` for Chromium's system libs — not
required on macOS, where this was verified.)

## Run (agent path) — driver.mjs

Launch the app, draw a stroke on the canvas, and screenshot the home route:

```bash
node .claude/skills/run-splotch/driver.mjs --port 5199 --route / --draw --out screenshots/splotch-home.png
```

Verify that `5199` is unused first, or replace it with another explicit unused port. Worktrees share
the host network.

Screenshot another route (no `--draw`):

```bash
node .claude/skills/run-splotch/driver.mjs --port 5199 --route /admin --out screenshots/splotch-admin.png
```

The driver starts its own server, waits for the route to become interactive, writes the PNG, then
tears the server down. **Open the PNG and look at it** — `screenshots/splotch-home.png` shows the
color palette down the left, the Settings Button bottom-right, and (with `--draw`) a purple zig-zag
stroke on the canvas. A blank canvas with no stroke means the draw flow regressed.

Options (see the header of `driver.mjs`):

| Flag              | Effect                                                                          |
| ----------------- | ------------------------------------------------------------------------------- |
| `--route <path>`  | Route to open — `/`, `/admin`, `/privacy`, `/dev/engine` (default `/`)          |
| `--draw`          | Drag a stroke across the canvas before the shot (route `/` only)                |
| `--out <file>`    | Screenshot path (default `screenshots/splotch.png`)                             |
| `--headed`        | Show the browser window instead of headless                                     |
| `--keep`          | Leave the dev server running afterward and print its URL (it then logs nowhere) |
| `--url <baseURL>` | Drive an already-running server instead of launching one                        |
| `--port <n>`      | Dev server port (default `5199`)                                                |

Output (`screenshots/`) is gitignored. **Write your shots there** (or another gitignored dir) — a
PNG dropped elsewhere in the repo shows up as an untracked file and trips the stop-hook git check.

## Capturing a specific tool / state (beyond a default stroke)

`--draw` only drags one **default-pen** stroke. There is no flag to select a tool (magic brush,
eraser), apply a coloring page, or draw a custom path — so to screenshot any other state you drive
it with your own short Playwright script. Don't reinvent the driver's setup; **reuse its server**
and copy its three non-obvious pieces:

```bash
# 1. Leave the driver's dev server running and note the URL it prints.
node .claude/skills/run-splotch/driver.mjs --port 5199 --route / --keep
```

```js
// 2. Your own script: connect to that server and drive the UI.
import { existsSync, readdirSync } from 'node:fs';
import { chromium } from 'playwright';

// Cloud Chromium can drift from Playwright's pinned build, so a bare
// chromium.launch() fails with "Executable doesn't exist". This is driver.mjs's
// chromiumExecutablePath() inlined so the script runs verbatim: PLAYWRIGHT_CHROMIUM
// wins; else the pinned build if present; else the newest chromium-<rev> under the
// browsers path (the versioned dir — NOT the bare `chromium` entry). undefined =
// let Playwright use its own pinned binary.
function chromiumExecutablePath() {
  if (process.env.PLAYWRIGHT_CHROMIUM) return process.env.PLAYWRIGHT_CHROMIUM;
  try {
    if (existsSync(chromium.executablePath())) return undefined;
  } catch {}
  const base = process.env.PLAYWRIGHT_BROWSERS_PATH || '/opt/pw-browsers';
  try {
    const builds = readdirSync(base)
      .filter((d) => /^chromium-\d+$/.test(d))
      .sort((a, b) => Number(b.slice(9)) - Number(a.slice(9)));
    for (const build of builds) {
      for (const sub of ['chrome-linux', 'chrome-linux64']) {
        const p = `${base}/${build}/${sub}/chrome`;
        if (existsSync(p)) return p;
      }
    }
  } catch {}
  return undefined;
}

const browser = await chromium.launch({ executablePath: chromiumExecutablePath() });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto('http://localhost:5199/', { waitUntil: 'commit' });

// Readiness: the canvas is in the DOM before it's wired. Poll until the engine
// changes the browser-default backing store and the CSS hit target has bounds.
await page.waitForFunction(() => {
  const c = document.getElementById('drawingCanvas');
  if (!(c instanceof HTMLCanvasElement)) return false;
  const rect = c.getBoundingClientRect();
  return (c.width !== 300 || c.height !== 150) && rect.width > 0 && rect.height > 0;
});

// The tool buttons live in the COLLAPSED action drawer — expand it first.
await page.locator('button[aria-label="Expand controls"]').click();
await page.locator('#undoButton').waitFor({ state: 'visible' });

// The brushes (pen, crayon, magic, eraser) live in the Brush Menu flyout:
// open it via its trigger, then pick the entry (selection closes the menu).
await page.locator('#brushButton').click();
await page.locator('#magicBrushButton').click();
```

> **Save the script file inside the repo, not the session scratchpad.** Node resolves
> `import ... 'playwright'` by walking up from the script's own directory, so a `.mjs` under the
> session scratchpad (per `CLAUDE.md`'s temp-file rule) dies with
> `ERR_MODULE_NOT_FOUND: Cannot find package 'playwright'`. Put it in the gitignored `screenshots/`
> dir — inside the tree so imports resolve, and gitignored so the file doesn't trip the stop-hook
> git check (same dir your shots go in). Delete it when done.
>
> `NODE_PATH=…/node_modules` does **not** fix this for these scripts — `NODE_PATH` is a
> CommonJS-only resolver hint and is ignored by the ESM `import` loader.

To apply a coloring page: click `#coloringBookButton`, then in the `dialog` pick a book and a page,
and wait for `#coloringOverlay` to be visible. A full worked example (all these steps) is the
`applyFarmPage` helper in `web/tests/flows-harness.ts`, which the magic-brush E2E spec
(`web/tests/flows-magic-brush.spec.ts`) drives.

> **Never hand-roll the dev server in a throwaway script.** `spawn('npx', ['vite', 'dev', …])` +
> `server.kill('SIGTERM')` does **not** work: `npx` exits but the real `vite` keeps running, and
> because its stdout is piped to your script the Node event loop never drains — the script hangs on
> exit and leaves an **orphaned `vite dev` holding the port for hours**. Instead, pick one:
>
> * **Reuse the driver's server** (preferred): `driver.mjs … --keep`, record the process-group PID
>   it prints, and connect your own Playwright script with `--url`/`page.goto`. Your script should
>   only manage the *browser*, never the server. When done, stop only that recorded process group
>   with the printed `kill -- -<pid>` command.
> * **If you truly must spawn one**, use `spawnViteServer(port, { env, stdout, stderr })` from
>   `tools/lib/vite-server.mjs` — it launches vite in a **detached process group** and its `stop()`
>   kills the whole group (`process.kill(-pid)`), so nothing is orphaned. `release()` hands a
>   still-running server to the OS (what `--keep` does) so your script can exit without killing it.
>   If its strict-port start collides, choose another port; do not call `freePort`. **A server you
>   intend to release needs a durable OS sink on *both* streams — `'ignore'` or a file descriptor**,
>   because the alternatives fail in opposite directions: `'inherit'` hands the survivor a dup of
>   your own fd, so it holds your caller's pipe open and the command that ran you never returns,
>   while `'pipe'` is a handle `release()` must drop, and the survivor's next log line then dies of
>   EPIPE — a few HMR reloads on stdout, a single filesystem-allowlist 403 on stderr. `release()`
>   throws rather than accept anything else. `driver.mjs`'s `startServer` / `finishServer` are the
>   worked example — copy those, not a fresh `spawn`.
>
> Either way, **reap only what this invocation spawned before ending**: close its browser and call
> its recorded server handle's `stop()`, or stop the process group recorded by `--keep`. Never use
> `npm run dev:stop`, `kill-port`, or another port-wide cleanup command. A leaked server reads as a
> "task running for hours".

## Run (human path)

```bash
npm run dev -- --port 5199 --strictPort
```

Replace `5199` with an explicit unused port. This serves no `/api/*` functions (image generation,
admin auth). For those, `npm run dev:netlify` runs the fixed-port Netlify serverless workflow and is
host-exclusive. Useless headless — it just waits for a browser.

## Test / direct invocation

* **Full E2E** (production build + Playwright, what CI runs): `npm run test:e2e`
* **Unit / internal functions** (Vitest, happy-dom — for PRs that touch one module, not the UI):
  `npm run test:unit`
* **API contract smoke** (self-contained, no model/Blobs credentials): `npm run test:api:smoke`

The `/dev/engine` route is an in-app harness for the drawing engine (gated behind
`PUBLIC_ENABLE_DEV_HARNESS`, which the driver sets automatically).

## Gotchas

* **The canvas exists before it's interactive.** `#drawingCanvas` is in the prerendered DOM before
  any script runs; the engine boots at module-evaluation time (before hydration, ADR-0072) and binds
  the pointer listeners right after changing the backing store off its 300×150 browser default.
  Production tiled mode deliberately uses a 1×1 input bitmap (ADR-0089), so a large bitmap is not a
  readiness signal. Wait for a non-default backing size and positive CSS bounds, as the driver does,
  before scripting a draw.
* **Cold `vite dev` re-optimizes deps** on the first hit, briefly 504-ing modules and
  auto-reloading. The driver *polls* for readiness instead of re-navigating (same trick as
  `web/tests/global-setup.ts`); a plain `goto` + immediate screenshot can catch the transient error
  page.
* **Pass an explicit unused `--port`.** The fallback is 5199, but concurrent worktrees share the
  host network. Use `--url` to drive a server you intentionally started elsewhere; a collision on
  `--port` means choose another port and retry.
* **The action drawer is collapsed by default**, so the tool buttons (brush menu, undo, coloring,
  screenshot) aren't visible until you click `button[aria-label="Expand controls"]`. The eraser and
  magic brush are entries in the Brush Menu flyout, another level down — a custom script that goes
  straight for `#magicBrushButton` fails with "element is not visible": expand the drawer, click
  `#brushButton`, then the entry (the E2E suite's `openDrawer` + `pickBrush` helpers do the same).
* **Clearing the canvas is a drag gesture, not a click.** `#clearButton` is wired to `dragToClear` —
  you have to press on it and drag past its accept threshold (`0.4 × min(innerWidth, innerHeight)`)
  toward the screen center, then release. A plain `.click()` does nothing.
* **`window.__engine` exists only on `/dev/engine`**, not on `/`. That harness (imperative
  `clearCanvas`, `undo`, pixel readers) is the easy way to manipulate and assert engine state — but
  on the main app you must drive the real UI instead.

## Troubleshooting

| Symptom                                                                                                  | Fix                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Executable doesn't exist … playwright` (local)                                                          | `npm run test:e2e:install`                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `Executable doesn't exist … playwright` (Claude Code Cloud session)                                      | The env's cached Chromium revision drifted from the one this Playwright version wants. `driver.mjs` now self-heals — it falls back to any Chromium under `PLAYWRIGHT_BROWSERS_PATH` (default `/opt/pw-browsers`). Override with `PLAYWRIGHT_CHROMIUM=/path/to/chrome`. Never run `npx playwright install` in Claude Code Cloud. See `docs/CLOUD/Claude-Code.md`.                                                                                                                          |
| `server never came up` / `dev server could not start`                                                    | Port collision or startup failure — select another explicit unused `--port` and retry; do not stop the existing listener                                                                                                                                                                                                                                                                                                                                                                  |
| `<route> never became interactive`                                                                       | Route 404s or crashes — check it loads at `npm run dev` first                                                                                                                                                                                                                                                                                                                                                                                                                             |
| Blank canvas in the `--draw` screenshot                                                                  | Drawing engine regressed; reproduce at `npm run dev`                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| Want one E2E spec, not the whole suite / `Cannot navigate to invalid URL` from raw `npx playwright test` | The config + `baseURL` live in `web/`, and raw `npx` from the repo root also loses the Chromium fallback. Filter through the npm script instead: `npm run test:e2e -- flows-undo-persistence.spec.ts -g "<title>"` — `tools/run-web-tool.mjs` sets the `web/` cwd and Chromium path and forwards the args to Playwright. The positional arg is a path *pattern*, so a filename that no longer exists matches nothing and Playwright exits with `No tests found`. See the `testing` skill. |

Files in this skill

  • SKILL.md16.6 KB
  • driver.mjs9.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…