Ship Data Apps with AI

🧱 Build it small·Lesson 8 of 26·~30 min·Beginner

✅ Check the AI's work

One self-testing logic file, a parse check that reads the JS inside your HTML, real screenshots before every ship, and a live test you clean up after.

You'll leave with

  • Your app's rules in engine.js, proven by a self-check that runs in about a second
  • A pre-ship routine: node --check, the self-check, then screenshots you actually look at
  • A live test that walks your real URL, then deletes what it created

Claude writes code faster than I can read it, and most of it works. The rest fails quietly. Acento's first preview passed its syntax check (a test that code is valid JavaScript), yet showed ... and 1 / 1 where the first flashcard belonged: one line used a value before the line that created it, and the page died. This lesson is the routine that catches that on your laptop first. No test framework: only Node (from Set up your workshop) and Chrome or Edge, on a computer rather than a phone.

⚡ The 60-second version

  1. Move your rules into engine.js: data in, answer out. node engine.js checks them in about a second.
  2. Before every ship, node check.mjs: parse check, self-check, then screenshots at phone and laptop widths, light and dark.
  3. Open the screenshots and look.
  4. node walk.mjs walks a new user's first minute: on your laptop now, on your real address once you've deployed.
1

The product is the engine

Grease and Handstand look like pages. The real product is a small file of plain math behind each (plan.js, progression.js) that turns your inputs into a training plan. The page is a skin.

Your app has rules too: what counts as done, how a streak counts, what to suggest, how a total is worked out. They're probably tangled into the page from Build static-first. Move them into one file of pure functions, engine.js.

What's a pure function?

A function is a named piece of code that takes inputs and returns an answer. A pure one does nothing else: no drawing, no clock, no saving, no internet. Same inputs, same answer, so it can be tested.

  • Pass the time in. A function that reads the clock can only be tested today. Take now as an input, and a test can pretend it's any day.
  • One file, loaded twice. The page loads engine.js; Node runs the same file. Copies in a test file drift: Acento's early one mirrored its scoring functions under a "keep in sync" comment, while its later accent scorer is one copy that the page loads and Node tests.
  • Store events, derive everything. Save what happened (each feeding, each practice) and compute every status, streak and chart from that list, so no two numbers on screen can disagree. In Baby Signs, a sign is known after 3 signs on at least 2 different days: one fluke isn't a sign. First Bites' streak check runs under three time zones, because grouping days in UTC (the world clock most code defaults to) once put evening entries on the next day.
What are Node and a module?

Node runs JavaScript outside the browser. A module is a file other code can load; module.exports lists what it offers. module exists only under Node, which is how the file knows where it's running.

With npx serve running as usual, start Claude Code in your myapp folder and ask:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouMove my app's rules into engine.js as pure functions: data in, answer out, no page code, with now passed in instead of read from the clock. Load it in index.html before my script, with a self-check at the bottom that runs only under node and sweeps every input combination.
ClaudeMoved four rules (knows, streak, weekTotals, suggest) into engine.js; index.html loads it first and calls engine.streak(events, Date.now()). node engine.js prints engine ok. Reload http://localhost:3000 and check your numbers match what you saw before.↳ Write · engine.js↳ Edit · index.html↳ Bash · node engine.js

Expect this shape, shown with Baby Signs' real rule (yours will hold your rules). The bottom half is the self-check, a list of assertions:

What's an assertion?

A line that says "this must be true" and stops the program, loudly, if it isn't: assert.equal(a, b, 'message') fails with your message when a and b differ. It's built into Node.

// engine.js: your app's rules. Data in, answer out: no page, no clock, no network.
const engine = (() => {
  const KNOWN_AT = 3, KNOWN_DAYS = 2;

  function dayKey(ts) {                  // the LOCAL calendar day, never UTC
    const d = new Date(ts);
    return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate();
  }
  function knows(events, itemId) {       // known = 3 signs on at least 2 different days
    const mine = events.filter(e => e.itemId === itemId);
    return mine.length >= KNOWN_AT && new Set(mine.map(e => dayKey(e.ts))).size >= KNOWN_DAYS;
  }
  function streak(events, now) {         // days in a row with something logged, ending today
    const days = new Set(events.map(e => dayKey(e.ts)));
    const d = new Date(now);
    let n = 0;
    while (days.has(dayKey(d))) { n++; d.setDate(d.getDate() - 1); }
    return n;
  }
  return { knows, streak };
})();

// The self-check. Only Node has `module`, so the browser skips everything below.
if (typeof module !== 'undefined') {
  module.exports = engine;
  if (require.main === module) {         // true only when you type `node engine.js`
    const assert = require('node:assert');
    const DAY = 24 * 60 * 60 * 1000;
    const sign = ts => ({ itemId: 'milk', ts });
    for (const tz of ['America/Phoenix', 'Asia/Tokyo', 'UTC']) {
      process.env.TZ = tz;
      const NOW = new Date(2026, 9, 2, 21, 30).getTime();      // 9:30 pm on Oct 2 (months count from 0)
      const AFTERNOON = new Date(2026, 9, 2, 14, 0).getTime();  // 2 pm the same day
      assert.equal(engine.knows([sign(NOW), sign(NOW), sign(NOW)], 'milk'), false, '3 on one day is not yet');
      assert.equal(engine.knows([sign(NOW - DAY), sign(NOW), sign(NOW)], 'milk'), true, '3 over 2 days is known');
      assert.equal(engine.streak([sign(AFTERNOON)], NOW), 1, 'an afternoon entry still counts tonight in ' + tz);
      for (let count = 0; count <= 6; count++) {             // sweep: 0 to 6 signs over 1 to 4 days
        for (let spread = 1; spread <= 4; spread++) {
          const evts = Array.from({ length: count }, (_, i) => sign(NOW - (i % spread) * DAY));
          assert.equal(engine.knows(evts, 'milk'), count >= 3 && spread >= 2, `${count} signs over ${spread} days`);
        }
      }
    }
    console.log('engine ok');
  }
}

What's happening underneath: in the browser, the file creates one thing, engine, for your page to call, and skips the bottom half because module doesn't exist there. Under node engine.js it does, so the assertions run, handing the rules whatever day, time zone and history they need.

Reload http://localhost:3000: it should behave exactly as before. Blank? Press F12 (Mac: Cmd+Option+I), open the Console tab, and paste the red line into Claude.

Safety lives in code, not copy

Decide what your app must never suggest, and enforce it in the engine before any ranking. First Bites never suggests a food that had a doctor-level reaction, and its meal planner never introduces one above the baby's age. When someone asked why it suggested bread and pasta for a 6-month-old, the fix was a default plus a small override table (every food from 6 months, bread and pasta from 9), tested both ways: a 6-month-old gets no bread, a 10-month-old can.

When an expert breaks your model, change the model, not the numbers. On one of my side projects, a river-flow tracker, a boater pointed out that some rapids are deadly only in a middle band of flow. So runs gained hazard windows: exceptions laid over the base rule, each citing a source, that can force a warning even when the flow reads ideal.

🦥 Skipped: a test framework. A file that tests itself with Node's built-in assert is my fleet's convention. Reach for a framework once several people share the code.

2

One self-check, run before every ship

Run it yourself, in a free terminal: Claude holds one tab and npx serve another, so in VS Code choose Terminal → New Terminal. It opens in your myapp folder, in PowerShell, Git Bash or Mac Terminal:

node engine.js
engine ok

About a second. Now break it on purpose to see what failure looks like: change KNOWN_AT = 3 to 2 (or any number in your own rules), save (Ctrl+S, Mac: Cmd+S), and rerun:

AssertionError [ERR_ASSERTION]: 2 signs over 2 days

The message names the broken case (ignore the at ... lines under it). Undo (Ctrl+Z, Mac: Cmd+Z), save, rerun: engine ok.

What makes it worth having

  • Sweep the grid, not one example. Grease's check loops over every max from 2 to 100 reps, intensity 40 to 60%, 1 to 10 sets and 1 to 7 days, asserting what must always hold ("a set is never as many reps as your max"). It found that a 1-rep max at 50% gave 0 reps, so the engine now refuses a max below 2. Make yours throw on impossible input too.
  • Every fixed bug gets the assertion that would have caught it. The List's checks grew from 39 to 49 that way. If you noted an official figure in Numbers you can defend, it becomes one: your total must land in a sane band around it.
  • Test and code disagree? Work the example by hand first. Once in First Bites, the test's expectation was the thing that was wrong.
  • Lists that must match. Baby Signs asserts that every motion has still frames and a direction arrow in their separate tables, and that each of its 110 signs has exactly two steps. A forgotten half fails on your laptop, not in front of a parent.
  • Rules that must stay alive. Acento sweeps its 14 pronunciation rules over about 14,458 real syllables and fails if any rule never matches: a rule that never fires is a bug.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouGrow the self-check: for every rule, assert what must always hold across every realistic input, throw on impossible input, and add an assertion for every bug we fix from now on.
ClaudeAdded a sweep per rule; suggest now throws on a negative age, and a test expects that. node engine.js prints engine ok.↳ Edit · engine.js↳ Bash · node engine.js

What's happening underneath: Node feeds your rules hundreds of inputs nobody would type and stops at the first answer that breaks a rule you wrote down. Ask Claude to list its assertions in plain English: a rule with no line isn't tested.

3

Parse check: one bad escape kills everything

Your page's own JavaScript probably sits in one <script> block in index.html, an inline script. One syntax error anywhere in it and none of it runs.

What do "parse" and "syntax error" mean?

Before running a script, the browser reads it through to check it's valid JavaScript: that's parsing. A syntax error is code that can't be read, like a string with no closing quote, and the browser then refuses the whole block.

📍 An edit script once reached a file through the shell as a heredoc (text pasted into a terminal command), and the two characters \n in a JavaScript string, an escape meaning "new line", became a real line break. On healthcare.traqqit.com the inline script stopped parsing: search was dead and the state picker wouldn't switch, for about four hours before anyone noticed.

Two defenses. Edit code with real edit tools (VS Code, or Claude's own); if Claude proposes changing code through a shell command full of quotes and backslashes, like sed (a find-and-replace command) or a heredoc, say no and ask for its edit tool. And check that the page parses before every ship:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouCreate check.mjs. For now it does one thing: run node --check on every inline script in index.html, with error line numbers matching index.html, and exit with code 1 if anything fails.
ClaudeCreated check.mjs. node check.mjs prints parse ok: 1 inline script(s). Delete a closing quote in index.html and rerun to watch it fail.↳ Write · check.mjs↳ Bash · node check.mjs

Claude's version will look roughly like this; the next step adds to the same file:

// check.mjs, part 1: does every inline script in index.html parse?
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const html = readFileSync('index.html', 'utf8');
const dir = mkdtempSync(join(tmpdir(), 'parse-'));
let n = 0;
for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
  const [tag, attrs, code] = m;
  const type = (attrs.match(/\btype=["']?([^"'\s>]+)/i)?.[1] ?? '').toLowerCase();
  if (/\bsrc=/i.test(attrs) || !['', 'text/javascript', 'module'].includes(type)) continue;  // skip files, JSON, import maps
  const lines = html.slice(0, m.index + tag.indexOf('>') + 1).split('\n').length - 1;
  const file = join(dir, `inline-${++n}.${type === 'module' ? 'mjs' : 'js'}`);
  writeFileSync(file, '\n'.repeat(lines) + code);   // padded, so error line numbers match index.html
  try { execFileSync(process.execPath, ['--check', file], { stdio: 'inherit' }); }
  catch { console.error(`FAIL: inline script ${n} in index.html does not parse (see the line number above)`); process.exit(1); }
}
console.log(`parse ok: ${n} inline script(s)`);

What's happening underneath: a search pattern (a regular expression) finds each <script> block, skipping files loaded with src= (node engine.js covers yours) and data blocks such as JSON. Each goes into a temporary file, padded so its line numbers match index.html, and node --check reads it without running it.

What's an exit code?

The number a command ends with: 0 means it worked, anything else means it failed. A check script fails by exiting with 1, and && between two terminal commands runs the second only if the first ended with 0.

Run node check.mjs: you should see parse ok. Now delete a closing quote in your inline script, save, and rerun. Something like this appears (your line and code will differ):

…\inline-1.js:10 const msg = 'Streak: ^^^^^^^^ SyntaxError: Invalid or unexpected token FAIL: inline script 1 in index.html does not parse (see the line number above)

The 10 is the line in index.html. Undo, save, rerun: parse ok.

⚠️ Parsing isn't proof the page works. Acento's dead preview passed exactly this kind of check: using a value before its line is valid syntax that crashes when the page loads. Catching that takes a picture.

4

Screenshot check: look at the real page

Tests check numbers, not what a person sees. Screenshots taken before shipping caught Grease showing 60% when you picked 50%, Handstand's Pro panels drawn unlocked, and First Bites' 8-week chart as flat 3-pixel bars, before any user saw them.

What's a headless browser?

A real Chrome or Edge running with no window. A script can open a page in it, resize it, switch on dark mode, click, collect errors and save screenshots.

This needs Chrome or Edge (Windows has Edge; on a Mac, install Chrome first, since Safari can't be driven this way). Keep npx serve running in its own terminal so the app answers at http://localhost:3000.

⚠️ Serve over http://; never open the file itself. First Bites' screenshots once came out blank from a file:// address: /engine.js never loaded, and the app died with FB is not defined.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouExtend check.mjs (Node only, no npm packages): after the parse check, run node engine.js. Then open http://localhost:3000 in headless Chrome or Edge with a fresh temporary profile, seed five weeks of realistic data, and screenshot every tab, sheet and pop-up at 390 and 1280 wide, light and dark, into shots/. Fail on any JavaScript error, sideways scrolling, or a missing or empty PNG; close the browser even on failure; end with PASS or FAIL.
Claudecheck.mjs now runs all three parts; node check.mjs ends with PASS, with 16 screenshots in shots/.↳ Edit · check.mjs↳ Bash · node check.mjs

Run node check.mjs yourself; something like this means it worked:

parse ok: 1 inline script(s) engine ok shots: 16 saved in shots/ (4 screens x 2 widths x 2 themes) JavaScript errors: 0, sideways scrolling: none PASS

What's happening underneath: the script starts your browser windowless with a throwaway profile and drives it over the DevTools Protocol, the connection DevTools itself uses. For each width (390 pixels is a typical phone, 1280 a laptop) and theme, it resizes, flips dark mode, writes the sample data into localStorage, reloads and saves a PNG, recording the Console's red errors. A page wider than the screen means sideways scrolling, the most common phone bug.

Then look. In VS Code's Explorer (the file list on the left; Ctrl+Shift+E, Mac: Cmd+Shift+E), open shots and click each PNG: titles, numbers, anything cut off, anything that should be locked. Claude can read them too, but you still look:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouRead every PNG in shots/ and list anything that looks wrong.
ClaudeI read all 16. In stats-390-light.png and stats-390-dark.png the weekly bars are all about 3 px tall, though the numbers under them run from 2 to 9. Nothing else stood out.↳ Read · shots/ (16 PNG files)

Make the pictures honest

  • Seed realistic data. An empty app hides most layout bugs; Baby Signs' harness seeds five weeks of events. Seed the saved "terms accepted" setting too, or a first-run sheet like First Bites' covers every shot.
  • One page of every item. If your app draws many similar things, ask for contact.html: every item in a small labeled tile, one screenshot. That's how Baby Signs' clipped and misplaced hands surfaced.
  • Force the rare screens. Habooby's first real air-quality alert printed "(Unknown · until 9:00 PM)" (the severity field literally said Unknown), and the banner covered the controls. Now a URL switch forces that screen any day: add ?debugState=advisory to the address, and the page reads the text after the ? with new URLSearchParams(location.search).get('debugState').
  • Hidden pop-ups. Copy index.html to _qa_dict.html, add one line that opens the pop-up on load, screenshot it, delete the copy. All four of Acento's new overlays were checked that way.

A PASS with a broken picture is still broken.

5

A walk-through you'll reuse live

Screenshots show screens. A walk-through proves a brand-new user gets through the first minute: first screen, onboarding (the welcome screens), log something, reload, still there.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouWrite walk.mjs: walk the new-user flow at the address I pass (default http://localhost:3000), fail on any JavaScript error, and print PASS or FAIL.
ClaudeCreated walk.mjs; node walk.mjs ends with PASS and 0 JavaScript errors.↳ Write · walk.mjs↳ Bash · node walk.mjs
node walk.mjs
http://localhost:3000 first screen ok · onboarding ok · logged 1 item · still there after reload JavaScript errors: 0 PASS

What's happening underneath: each run starts the browser with an empty profile, so your app sees a stranger on day one. The script clicks like a person, reloads, reads the page again, and exits with 1 at the first thing that's wrong. If npx serve picked another port, pass that address, replacing PORT: node walk.mjs http://localhost:PORT.

After your first deploy: test on production, then clean up

Write it once; the optional address does the rest. After Deploy behind one front door puts your app on your own server, run node walk.mjs https://myapp.yourdomain.com (with your real address) against production, the live copy real people use. Do it after every deploy, even when your change seems unrelated: it catches what your laptop can't, like a file you forgot to upload (engine.js, now that your page loads it). Baby Signs' live walk-through caught the app promising three signs and showing two.

After Accounts without building auth, the walk-through signs up a throwaway user, so it also deletes its test users and re-checks public pages in the same session. After each live run on Acento, the test user was deleted and the leaderboard checked clean again, so a stranger never sees "test123" there.

Bugs AI edits love

Each of these runs without crashing, which is what makes it dangerous. Before you commit an edit, ask Claude: "Show me from git diff every if/else you changed, every single | or & that isn't between numbers, every empty catch and every new innerHTML." (git diff shows exactly which lines changed. An empty catch is an error handler that hides the error; innerHTML puts text into the page as HTML.)

Control flow that quietly moves

Code inserted between an if and its else takes the else for itself. In one of my side projects, a browser party game, every automated check then sat on the menu screen. Early returns (leaving the function the moment a case is handled) make that impossible:

// before: the inserted check steals the else
if (AUTO) { start(); }
if (DEBUG) { showDebugPanel(); }   // the line an edit added
else showMenu();                    // now runs whenever DEBUG is off, even in AUTO mode

// after: early returns, so every case stands alone
if (AUTO) { start(); return; }
if (DEBUG) showDebugPanel();
showMenu();

The same game shows why the walk-through runs after "unrelated" changes too: an automated rewrite turned every || (or) into |, an operator that turns text into numbers. Solo play still worked, so it shipped, with phone multiplayer dead. Only the full multiplayer test failed.

Units and places, converted once

Grease's slider showed a percent (40 to 60); the engine expected a fraction (0.40 to 0.60). The raw 50 got silently clamped (capped at the nearest allowed value) to 0.60, so the app showed 60% and 6 reps while the slider said 50%. Convert once, where the page calls the engine, and name variables with their unit (intensityPct). Make the engine throw above 1 (Handstand's now throws on a raw percent), and add a self-check case that expects the error.

Places drift too. In a private build of my price tool, a hidden form field kept the first region, so a ZIP code in one state was priced against another and the page confidently found nothing within 25 miles. Infer the region from what the user typed, and test from every region.

Failures nobody sees

  • Empty catch blocks. After an optimization, an old fallback on Habooby threw an error, a catch swallowed it, and the green "all clear" banner quietly vanished. Every catch gets at least console.error(err).
  • Drawing after waiting. On the river-flow tracker, a chart drew into a panel that a re-render had replaced while its data loaded, so it never appeared. After an await, look the element up again.
  • Locks added after drawing. Handstand locked its Pro panels after drawing them, then a re-render drew fresh, unlocked ones. Now panels are drawn locked from the start.
What are try/catch and await?

try { … } catch (err) { … } runs the first block and, if it throws an error, runs the second instead of crashing; an empty second block hides the error forever. await pauses a function until something slow, like a download, finishes, and the page can change meanwhile.

Results pages

One audit of that private variant found several of these at once:

  • Break ties (price, then name), or "#1" changes on refresh.
  • Pick badges like "cheapest" after filtering, not before.
  • Never decide whether to show a column from the first row alone.
  • Clamp numbers people type: a percentage typed as 0.2 or 200 once flowed straight into a patient's cost estimate.
  • Show text with textContent, never innerHTML: 23 of 310 facility names on one map had an apostrophe or an ampersand.
What are innerHTML and textContent?

Two ways to put text into part of a page. innerHTML reads it as HTML, so an & or a < in a name can break the page; textContent shows it exactly as written.

🛑 innerHTML with text from users or data files is also how attackers slip their own script into your page (cross-site scripting, XSS). Ask Claude to find every innerHTML = that carries data and replace it.

Data that looks right

  • In that private variant, 19 facilities once showed another facility's address and phone; a check now fails when two distant places share either one.
  • My price tool's bill reader won't read two bare numbers on a pasted line (2400 900) as a charge, since one may be a code: money needs a $, a comma or a decimal point. No answer beats a confident wrong one.
  • The List picks the value it taxes from a priority list of field names, so sale prices live in last_sale_price, a name that list can't grab. A test can prove it: a huge last_sale_price must not move the taxed value.

When a bug comes back

Stop accepting plausible fixes and measure, as in Work with Claude Code: count requests and status codes outside the app, or record movement per frame before blaming "choppy". One stutter in the party game needed fixed physics steps plus interpolation (drawing the in-between positions).

When you outgrow eyeballing (advanced, skip on a first read)

Come back when check.mjs grows, or misbehaves and you want to know why.

What's the DevTools Protocol?

The connection DevTools uses to control a page, also called CDP. Start Chrome or Edge with a debug port, connect over a WebSocket (a connection that stays open both ways), and any program can send commands like "load this page" or "take a screenshot".

  • No packages. Node 22 and newer (you installed 24) has WebSocket built in, so one small script can launch the browser, load pages, collect errors and failed requests, and screenshot. The party game's URL mode, ?drive=park, plays itself and prints one predictable line a script waits for, locally and live.
  • Leftover browsers. Failed runs can leave headless browsers behind. Once 81 piled up, and every screenshot "succeeded" (exit code 0) with no file written: hence the missing-PNG check. The commands below close only headless ones.
  • Black maps. 3D globes and vector maps draw with WebGL, which headless browsers usually can't do without a graphics chip. Screenshot the map library's own demo with the same settings: black too means it's the machine, not your app.
  • Long pages. Headless Edge caps a capture around 4 megapixels, so take long pages in bands.
  • Not a speed test. On the river-flow tracker, headless Edge's own background requests filled the browser's 6 connections to the site: the page looked 30 seconds slow while the server answered in about a third of a second. Time the server with curl (below), and never judge smoothness headless.
  • Don't burn your quota. APIs cap how often you may call them (a rate limit; go over and you get errors, often status 429). Feed most runs fake data instead of real fetch calls, then do one real session: Habooby's rate-limit fix ended with 13 real calls and zero rate-limit errors. Once you have logins, load a saved token (kept in an env file, never typed into the script) instead of logging in on every run.
  • More checks, same idea. A footer pill once covered Habooby's brand card on phones; an overlap check at real phone sizes now catches that. A green-or-red test page covers browser-only logic, and a server-rendered app gets a template-only harness: my price tool took 85 to 200 seconds to import on my Windows laptop, so its templates are checked alone, in seconds.
Get-CimInstance Win32_Process -Filter "Name='msedge.exe' OR Name='chrome.exe'" | Where-Object { $_.CommandLine -like '*--headless*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
pkill -f headless
curl -s -o /dev/null -w '%{time_total}\n' http://localhost:3000/

That prints seconds, like 0.004 (in PowerShell, type curl.exe, and NUL for /dev/null). None of these files ship: a deploy sends a named list of files, and test files aren't on it.

Ask Claude, then look underneath

The whole routine is three prompts:

  1. "Move my app's rules into engine.js as pure functions with a self-check at the bottom that runs under node and sweeps every input combination."
  2. "Write check.mjs: extract the inline script and run node --check on it, run the self-check, then screenshot every screen at 390 and 1280 wide in light and dark and print console errors."
  3. "Write walk.mjs: walk the new-user flow at the address I pass (default http://localhost:3000), fail on any JavaScript error, and print PASS or FAIL."

What's happening underneath: Node runs your engine like a calculator full of assertions, and a headless browser is a real Chrome or Edge with no window that a script can load, click and photograph. Neither knows what your app should do; they check what you wrote down. Once you add analytics (Know if it's working), the self-hosted kind I use drops these headless visits as bots, so checks don't inflate your numbers.

When Claude says "all checks pass", run both scripts yourself and read the last line. A check you've never seen fail may check nothing, so break each one once. Then put the routine in your NOTES.md (from Work with Claude Code):

## Before every ship
node check.mjs   -> parse check, self-check, screenshots in shots/ (then open them and look)
node walk.mjs    -> PASS or FAIL
Rules for Claude: keep node engine.js passing; add an assertion for every new rule and every fixed bug;
edit code with your edit tool, never sed or a heredoc; ship engine.js with index.html, never check.mjs, walk.mjs, shots/ or _qa_ files.

## After every deploy (once I've done the Deploy lesson)
node walk.mjs https://myapp.yourdomain.com
After Accounts: delete the test users, then re-check the public pages.

Your turn

  • node engine.js prints engine ok in about a second, and your app at http://localhost:3000 works exactly as before, with its rules loaded from engine.js.
  • Break one rule on purpose, watch the self-check name the failing case, then undo it and see engine ok again.
  • node check.mjs ends with PASS, and you've seen it fail once on a string you broke on purpose, pointing at the right line of index.html.
  • Open every PNG in shots/ (390 and 1280 wide, light and dark), and fix or write down in NOTES.md the first thing that looks off.
  • node walk.mjs prints PASS against your laptop copy.
  • Your NOTES.md holds the before-every-ship routine and the after-deploy lines from the block above.

Next, Make it look right everywhere: those 390-pixel screenshots in dark mode become its to-do list.