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
+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. */