chore(tooling): make the screenshot recipe work with no setup

Two things this session depended on lived only in a scratchpad, so the next
agent would have had to reinvent them from a prose hint:

- scripts/screenshot-fonts.conf, now applied by screenshot.ts itself. Without
  it this container renders every generic font family as Fira Code and the UI
  looks monospaced in a way no user sees — a trap that nearly produced a "fix"
  for a font bug that does not exist.
- scripts/demo-data.ts, which seeds a visibly synthetic dataset. Without it
  every screenshot shows an app full of zeros, because Chrome starts from a
  fresh profile each run.

CLAUDE.md now carries the two-command recipe verbatim instead of "point
FONTCONFIG_FILE at an override", and says why --reload is mandatory. Verified
from a clean shell with no environment set.

Also flags docs/superpowers/plans/2026-09-01-budget-app.md as a historical
record: its snippets still show CURRENCIES and CURRENCY_DIGITS, which no
longer exist, and an agent reading it as current would be misled.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 16:07:09 +02:00
co-authored by Claude Opus 5
parent 151cd530f5
commit bd5a01e03e
5 changed files with 200 additions and 12 deletions
+27 -11
View File
@@ -192,20 +192,36 @@ Adding a "missing" guard here is a regression:
## Verification limits
**There IS a browser here.** `google-chrome` is on PATH, and
`bun scripts/screenshot.ts <url> <out.png>` drives it over CDP: it waits past
the async IndexedDB read (which plain `--screenshot` does not), can seed data
with `--eval-file`, click with `--click`, and reports console errors and
uncaught exceptions. Read the PNG it writes — you can see the app. Use it
before claiming anything about layout.
`scripts/screenshot.ts` drives it over CDP — it waits past the async IndexedDB
read (which Chrome's own `--screenshot` does not, which is why the docs used to
claim no browser existed), and it reports console errors and uncaught
exceptions. Read the PNG it writes. Use it before claiming anything about
layout.
Two things to know before trusting a screenshot:
The whole recipe, from a running `bun run dev`:
```sh
bun scripts/demo-data.ts /tmp/seed.js
bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/shot.png \
--eval-file=/tmp/seed.js --reload
```
Then read `/tmp/shot.png`. Other flags: `--dark`, `--full`, `--w/--h`,
`--click=<selector>`, `--profile=<dir>` (keeps a service worker and IndexedDB
across runs — required for anything stateful), `--eval-after-file` (observe a
flow that ends in a navigation from the far side of it).
Two things that would otherwise waste your time:
- **`--reload` is not optional.** Chrome starts from a fresh profile each run,
so the app re-seeds itself empty; seeding IndexedDB only takes effect on the
next boot. Without it every screenshot shows an app full of zeros.
- **This container's fontconfig resolves every generic family to Fira Code, a
monospace font**, so the whole UI renders monospaced in a way no real user
sees. Point `FONTCONFIG_FILE` at an override that maps the generics to
installed sans/mono faces, or you will "fix" a font bug that does not exist.
- Chrome launches with a fresh profile each run, so IndexedDB starts empty and
the app re-seeds. Pass `--eval-file=<seed.js> --reload` for populated data.
monospace font.** The UI then renders monospaced in a way no real user sees,
and the last agent to look nearly "fixed" a font bug that did not exist.
`scripts/screenshot-fonts.conf` corrects it and the script applies it
automatically — do not undo that, and do not trust a screenshot taken with
`FONTCONFIG_FILE` set to something else.
Still **not** verifiable here, so do not claim it: real offline behaviour, the
install and update prompts, opening the generated `.ods` in a spreadsheet, and
@@ -1,3 +1,10 @@
> **Historical record, not current truth.** This plan was executed and is kept
> for its reasoning. Two things in it have since changed and the code is the
> authority: allocation rules are a dated series (`Post.rules`, schema v2), and
> currencies live in `Dataset.currencies` with `roundMoney` taking a digit
> count rather than a currency code (schema v3). Snippets below still show
> `CURRENCIES` / `CURRENCY_DIGITS`, which no longer exist.
# Budget 2.0 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+110
View File
@@ -0,0 +1,110 @@
/**
* Writes a browser script that seeds a synthetic dataset into IndexedDB, for
* screenshots and for poking at the app with realistic data.
*
* bun scripts/demo-data.ts /tmp/seed.js
* bun scripts/screenshot.ts http://localhost:3000/month/2026-09 /tmp/shot.png \
* --eval-file=/tmp/seed.js --reload
*
* `--reload` is not optional: the app reads its dataset once at boot, so it has
* to boot again to see what was written. Chrome also starts from a fresh
* profile on every run unless you pass --profile, so without this every
* screenshot shows an app full of zeros.
*
* The data is visibly synthetic on purpose. PRODUCT.md: the owner's real budget
* never leaves their browser, so it is never available as example material, and
* illustrative data must not pretend to be real.
*/
import { createSeedDataset } from "../src/domain/seed.ts";
import * as actions from "../src/store/actions.ts";
import { FALLBACK_FX_RATES } from "../src/store/bakedRates.ts";
// Visibly synthetic, per PRODUCT.md: the owner's real budget must never be
// used as example material, and illustrative data must not look real.
const d = createSeedDataset("2026-01", FALLBACK_FX_RATES);
const [games, food, social] = d.posts as [typeof d.posts[0], typeof d.posts[0], typeof d.posts[0]];
const rent = actions.addPost(d, "Rent", "DKK");
const transport = actions.addPost(d, "Transport", "DKK");
const savings = actions.addPost(d, "Savings", "DKK");
for (let m = 1; m <= 9; m++) {
const id = `2026-${String(m).padStart(2, "0")}`;
actions.setIncome(d, id, { amount: m === 7 ? 24500 : 28000, currency: "DKK" });
}
actions.setRuleFrom(d, rent.id, "2026-01", { kind: "fixed", amount: { amount: 8200, currency: "DKK" } });
actions.setRuleFrom(d, food.id, "2026-01", { kind: "percentOfIncome", percent: 12 });
actions.setRuleFrom(d, food.id, "2026-06", { kind: "percentOfIncome", percent: 15 });
actions.setRuleFrom(d, games.id, "2026-01", { kind: "fixed", amount: { amount: 600, currency: "DKK" } });
actions.setRuleFrom(d, social.id, "2026-01", { kind: "percentOfIncome", percent: 6 });
actions.setRuleFrom(d, transport.id, "2026-01", { kind: "fixed", amount: { amount: 1100, currency: "DKK" } });
actions.setRuleFrom(d, savings.id, "2026-03", { kind: "percentOfIncome", percent: 10 });
actions.setRuleOverride(d, "2026-09", social.id, { kind: "fixed", amount: { amount: 2400, currency: "DKK" } });
const buy = (date: string, description: string, amount: number, currency: "DKK" | "EUR" | "USD", postId: string, note?: string) =>
actions.addPurchase(d, {
date, description, note,
total: { amount, currency },
splitMode: "percent",
splits: [{ postId, value: 100, absorbsRemainder: true }],
schedule: null,
});
for (let m = 1; m <= 9; m++) {
const mm = String(m).padStart(2, "0");
buy(`2026-${mm}-01`, "Rent", 8200, "DKK", rent.id);
buy(`2026-${mm}-04`, "Groceries", 1180 + m * 37, "DKK", food.id);
buy(`2026-${mm}-18`, "Groceries", 1420 - m * 12, "DKK", food.id);
buy(`2026-${mm}-06`, "Commuter pass", 1100, "DKK", transport.id);
}
buy("2026-09-03", "Coffee with the team", 184, "DKK", social.id);
buy("2026-09-07", "Board game night", 465, "DKK", social.id, "split the taxi");
buy("2026-09-12", "Indie bundle", 27.5, "EUR", games.id, "sale");
buy("2026-09-15", "Birthday dinner", 1850, "DKK", social.id);
buy("2026-08-22", "Headphones", 89.99, "USD", games.id);
// A split across posts, and a purchase spread over months. Both compose.
actions.addPurchase(d, {
date: "2026-09-09", description: "Weekend away", note: "food + social",
total: { amount: 2600, currency: "DKK" }, splitMode: "percent",
splits: [
{ postId: social.id, value: 65, absorbsRemainder: true },
{ postId: food.id, value: 35, absorbsRemainder: false },
],
schedule: null,
});
actions.addPurchase(d, {
date: "2026-08-14", description: "Bike", note: "paid over 4 months",
total: { amount: 6400, currency: "DKK" }, splitMode: "percent",
splits: [{ postId: transport.id, value: 100, absorbsRemainder: true }],
schedule: { slices: [
{ month: "2026-08", amount: { amount: 1600, currency: "DKK" } },
{ month: "2026-09", amount: { amount: 1600, currency: "DKK" } },
{ month: "2026-10", amount: { amount: 1600, currency: "DKK" } },
{ month: "2026-11", amount: { amount: 1600, currency: "DKK" } },
] },
});
await Bun.write(
process.argv[2]!,
`(async () => {
const data = ${JSON.stringify(d)};
await new Promise((resolve, reject) => {
const open = indexedDB.open("budget2", 1);
open.onupgradeneeded = () => {
const db = open.result;
if (!db.objectStoreNames.contains("state")) db.createObjectStore("state");
};
open.onsuccess = () => {
const db = open.result;
const tx = db.transaction("state", "readwrite");
tx.objectStore("state").put(data, "dataset");
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
open.onerror = () => reject(open.error);
});
return "seeded " + data.posts.length + " posts, " + data.purchases.length + " purchases";
})()`,
);
console.log("wrote eval script to", process.argv[2]);
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<!-- Screenshot-only. This container resolves every generic family to Fira
Code, a monospace font, which makes the whole UI look monospaced in a way
no real user ever sees. Remap the generics to installed fonts that stand
in for a desktop system. Never applied to the app itself. -->
<fontconfig>
<include ignore_missing="yes">/etc/fonts/fonts.conf</include>
<match target="pattern">
<test name="family"><string>sans-serif</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>Inter</string></edit>
</match>
<match target="pattern">
<test name="family"><string>system-ui</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>Inter</string></edit>
</match>
<match target="pattern">
<test name="family"><string>ui-sans-serif</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>Inter</string></edit>
</match>
<match target="pattern">
<test name="family"><string>Segoe UI</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>Inter</string></edit>
</match>
<match target="pattern">
<test name="family"><string>Segoe UI Variable Display</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>Inter</string></edit>
</match>
<match target="pattern">
<test name="family"><string>monospace</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>JetBrains Mono</string></edit>
</match>
<match target="pattern">
<test name="family"><string>ui-monospace</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>JetBrains Mono</string></edit>
</match>
<match target="pattern">
<test name="family"><string>SF Mono</string></test>
<edit name="family" mode="prepend_first" binding="strong"><string>JetBrains Mono</string></edit>
</match>
</fontconfig>
+14 -1
View File
@@ -30,6 +30,19 @@ const width = Number(flag("w", "1280"));
const height = Number(flag("h", "900"));
const waitMs = Number(flag("wait", "1500"));
/**
* This container's fontconfig resolves EVERY generic family — sans-serif,
* system-ui, monospace — to Fira Code, a monospace font. Screenshots then show
* the whole UI monospaced in a way no real user sees, and the last agent to
* look nearly "fixed" a font bug that did not exist. Applied automatically so
* nobody has to know that; set FONTCONFIG_FILE yourself to override.
*/
const fontConfig = new URL("./screenshot-fonts.conf", import.meta.url).pathname;
const fontEnv =
process.env.FONTCONFIG_FILE || !(await Bun.file(fontConfig).exists())
? {}
: { FONTCONFIG_FILE: fontConfig };
const chrome = Bun.spawn(
[
process.env.CHROME ?? "google-chrome",
@@ -49,7 +62,7 @@ const chrome = Bun.spawn(
...(has("dark") ? ["--force-dark-mode", "--enable-features=WebContentsForceDark"] : []),
"about:blank",
],
{ stdout: "ignore", stderr: "pipe" },
{ stdout: "ignore", stderr: "pipe", env: { ...process.env, ...fontEnv } },
);
/** Chrome announces its debugging endpoint on stderr, once, at startup. */