Ledger is a private, local-first money tracker that today ships as a single-file PWA
(index.html, ~2,483 lines of dependency-free vanilla JS, plus sw.js,
manifest.webmanifest, icons). It is feature-rich and cohesive: accounts, transactions,
loans, templates, goals, IOUs, trends, multi-currency, themes, privacy mode, JSON/CSV
backup, and opt-in end-to-end-encrypted sync (AES-GCM/PBKDF2) to GitHub-compatible
hosts.
The goal is to ship it as a native app — Android first, iOS later — and, in the process, modernize it into a maintainable, typed, component-based codebase, without losing any features and without diluting its identity or its privacy-first, local-first, no-server character. The work is done in ordered phases so every step is shippable and reversible.
Companion docs: CLAUDE.md (working guide) and DESIGN.md (visual system).
-
Packaging: Capacitor — bundles the app inside the APK (fully offline, no web host, no domain), reuses the existing code, and exposes a native bridge for later.
-
Frontend: Svelte + Vite + TypeScript + Vitest. Not SvelteKit (SPA, no server — SvelteKit's server features are unusable under Capacitor and its routing adds back-button/history complexity). No heavy UI framework.
-
Navigation: keep the current in-memory tab state (no URL router) — simpler and friendlier to the Android hardware back button.
-
Design: keep Ledger's visual identity on every platform; achieve "native" via ergonomics + real system components, driven by a
data-platformtoken layer (seeDESIGN.md). -
Sync: stays backendless / bring-your-own-storage (adapter model, client-side E2E encryption unchanged). GitHub sync ships in v1, then is replaced by Google Drive sync (native Google Sign-In +
drive.appdata, no backend) as the top post-ship priority, and retired once Drive lands. Rationale: a GitHub account + PAT is too technical for an average user; Drive is more accessible. -
App identity:
appIdio.friendsnone.ledger— a permanent reverse-DNS-style identifier (no domain purchase required; must be unique on the Play Store). -
Distribution: Web → GitHub Pages via a build step — Pages Source = GitHub Actions building
dist/(the repo root is now unbuilt source, so serving from branch root no longer works); set Vitebase: '/ledger/'(project-page URLfriendsnone.github.io/ledger/). No SPA 404 fallback needed (navigation is in-memory tab state); no custom domain required. Android → sideload APK + optional Play Store; iOS → TestFlight / Ad Hoc (requires the $99/yr Apple Developer Program + an Xcode/cloud-Mac build; no free-form sideloading). -
When we first ship: after Phase 4 — not before. P1 and P2 are technically shippable, and that is deliberate rather than accidental; we are choosing not to. By the end of P4 the migration is actually finished (P3 rebuilds the screens, P4 lands the native capabilities) and the app is stable enough to use daily, which is the bar for handing it to anyone.
- Until then the legacy single-file PWA stays hosted, and it is what real users are on. The native build's entire audience is the dev devices in the matrix below.
- This is what makes the accepted carve-outs survivable, and they are only survivable because of it: GitHub sync's push does not land (P1), and the native build has no way to export data at all — backup download and CSV export both produce no file (P1, P2). Shipping either of those to a real user would be indefensible. Both must be closed by P4, since first ship is the moment they stop being survivable.
- Corollary for the
sw.jskill switch: "keep it until every install has been through it" is a tractable condition precisely because nothing shipped — the install population is the dev devices, not the public. It can go once the last of them has been updated — which happened in P3e: the Huawei (the last device held on P1) went through the kill switch in the P1→P3 upgrade test, so the condition is now met and the retirement is scheduled in P4c. This says nothing about the web build's service worker, which is a different origin and scope and stays. - Practical consequence, worth repeating where it hurts: do not keep a real ledger in the native build before P4 fixes export.
-
Storage: stays client-side/local; E2E crypto parameters unchanged.
-
Platform support: the real floor is the WebView, not the OS.
minSdkVersiononly gates installation; Android System WebView updates through the Play Store independently of the OS, so the OS version predicts almost nothing. Measured against the code, the app needs Chromium ≥ 105::has()(105) and the independenttranslate:property (104, and load-bearing — it centres and animates.toast), plus:focus-visible(86) and flexgap(84, used 49 times). Below that you do not get graceful degradation, you get a half-off-screen toast and 49 flex rows losing their spacing. The JS is far more conservative (ES5-style; onlyasync functionneeds ≥55).- Declared / tested floor: Android 9 (API 28). Confirmed on a Huawei SHT-AL09 running Chrome 138 as its WebView provider — all four feature probes pass and the layout is correct, so old Android is cheap to support when Play Services keep WebView current.
minSdkVersionstays 24. Changing it buys nothing today and it lets Android 7–8 devices with a current WebView keep working. Treat 7–8 as "probably fine, untested", not as supported. Revisit only if a Phase 4 native API forces it.- The runtime capability probe landed in Phase 2 — a feature probe, not UA sniffing:
CSS.supports("translate", "-50% 0") && CSS.supports("selector(:has(*))"), at the top ofwww/native.js. On failure it says so plainly (update Android System WebView, or use the web build) instead of rendering a subtly broken layout. - Anyone the native build excludes still has the web/PWA build, which is what makes a higher floor cheap.
-
Assets & styling: replace the base64-inline fonts + inline SVG icon sprite with Vite-packaged, self-hosted fonts (
woff2, weights 400/600/700 only) and tree-shaken Phosphor icons — local, offline, CSP-safe (DESIGN.md§6). Styling is scoped CSS + tokens with no utility framework — each primitive owns its scoped styles; Tailwind and UnoCSS were evaluated and set aside (DESIGN.md§7). -
P4+ is native-first, built on Capacitor plugins — one implementation, not two. From Phase 4 on, features are built on Capacitor's plugins (official: Filesystem, Share,
CapacitorHttp, Preferences; vetted community: Google auth, secure storage, SQLite) and lean on their web fallbacks rather than hand-maintaining separate web and native code paths — two versions drift and breed inconsistency. This deliberately accepts two things the earlier phases avoided:- Migrations (e.g. web
localStorage→ IndexedDB / Filesystem). Fine, provided they are done safely and accurately: idempotent per-key copy on first boot, the old data left in place as a fallback, backup/restore as the ultimate net. - Native-appropriate deviations from byte-identical reference parity — a native share
sheet instead of
<a download>, Google Drive instead of GitHub, native storage instead of the WebView's. These are the point of going native, not regressions.
The reference stays the identity + feature baseline — the app must still do everything it does and be unmistakably Ledger — but it is no longer a byte-identical contract for the features P4 replaces. The golden rules still bind (private/local-first, no backend, crypto unchanged; every plugin is a justified, audited dependency). Guardrail: deviate only as far as the native capability warrants — chasing full byte-parity would defeat the purpose of Capacitor, but never deviate so far that Ledger becomes a different app.
- Migrations (e.g. web
- Agree the guidelines; write
CLAUDE.md,DESIGN.md, and keep this plan as the roadmap of record. - Repo/branch setup (clean slate, non-destructive): rename
main→legacyto archive the old history, push the fresh scaffold as a newdevelopbranch (clean, unrelated history — that's fine on GitHub) and set it default, and createproductionfor stable Pages snapshots. SeeCLAUDE.md→ Git workflow. - Scaffold in an empty dir:
npm create vite@latest ledger-native -- --template svelte-ts cd ledger-native && npm install npm install @capacitor/core && npm install -D @capacitor/cli npx cap init "Ledger" "io.friendsnone.ledger" --web-dir www npm install @capacitor/android && npx cap add androidwebDiriswwwfor now (Phase 1 literal copy); it flips todistat Phase 3. - Carry the keepers into the initial commit: the three docs (
CLAUDE.md,DESIGN.md,MIGRATION_PLAN.md) and today'sindex.html→reference/index.html(the parity oracle for every later phase).
- Sanity check first: confirm the scaffold builds before changing anything. Note
www/must contain anindex.htmlfornpx cap sync/cap open androidto work — an emptywww/errors (missingassets/,capacitor.settings.gradle); the copy step below provides it. After copying,npx cap doctorshould be green andnpx cap open androidshould build a debug APK. - Copy the existing app (
index.html,sw.js,manifest.webmanifest, icons) into the Capacitor web dirwww/unchanged — no Vite build step yet;src/stays dormant until Phase 3. Produce a buildable debug APK. - Minimal shell wiring only —
@capacitor/appand@capacitor/status-bar. What landed:-
Portrait lock via
android:screenOrientation="portrait"in the manifest — no plugin needed. -
Status bar — two mechanisms, because Android split in half at API 35. Three mechanisms, and on-device testing proved all three are load-bearing — each covers a configuration the other two do not. Do not "simplify" this to one.
Config What paints the bar Covered by API ≤34 (realme A13, Huawei A9) system, from android:statusBarColorstatusBarColorin the theme +StatusBar.setBackgroundColorat runtimeAPI ≥35, WebView <140 (A15 emu, WebView 124) decor view — statusBarColoris ignored, and Capacitor pads the WebView instead of passing insets throughandroid:windowBackground— nothing else reaches itAPI ≥35, WebView ≥140 (A17 emu, WebView 149) the page itself, edge-to-edge SystemBars+ the safe-area insets below- The original grey band was the API ≤34 case: with
statusBarColorunset the platform falls back to its greycolorPrimaryDark. All three colour attributes now point at@color/ledgerPaper(values/+values-night/). - The runtime
StatusBar.setBackgroundColorexists because a theme resource can only follow the device's night mode. The runtime call makes the bar follow the app's theme setting — verified with the app in dark on a light-mode device. - Icon contrast is
SystemBars.setStyleeverywhere, driven by aMutationObserverondata-theme. The colour is read from the live--papertoken so it cannot drift. - Icon contrast is
SystemBars.setStyleon both, driven by aMutationObserverondata-theme(whatapplyTheme()resolvessystemto). The colour is read from the live--papertoken so it cannot drift from the stylesheet.
- The original grey band was the API ≤34 case: with
-
Safe areas.
viewport-fit=coverpluswww/native.css, which adds the top and side insets tobodyat both breakpoints; the bottom was already handled by the original app. Insets readvar(--safe-area-inset-*, env(safe-area-inset-*, 0px))— the custom properties are what SystemBars injects,env()is the web/PWA fallback. Both halves are verified on-device:- Android 13 (API 33): neither is ever populated — SystemBars only injects the
properties from API 35 up, and
env()stays 0 because the window is not edge-to-edge. Harmless: nothing needs insetting when the window already sits below the bars, so thecalc()adds 0 and the layout is correct. - Android 17 (API 37):
--safe-area-inset-top: 52pxis injected,--sa-topresolves to it,bodypadding-top lands on18px + 52px = 70px, the header's client rect starts at exactly y=70,innerHeight === screen.height, and the bottom inset flows into the app's existing rules (nav.tabspadding-bottom10 + 24).
- Android 13 (API 33): neither is ever populated — SystemBars only injects the
properties from API 35 up, and
-
Back button.
App.addListener("backButton")inwww/native.js, unwinding modal → Settings/Help overlay → non-Overview tab →exitApp(). It drives the app's own buttons through the DOM, soindex.htmlneeds no hooks. Gesture-back, 3-button back and a hardware key are all the same Android event, so one listener covers all three. Verified end-to-end with realKEYCODE_BACKevents: Trends → Overview; modal over Settings closes the modal alone; the next back leaves Settings for the tab it was opened from (Track); from Overview the app exits to the launcher. -
allowNavigation: ['api.github.com']incapacitor.config.ts.
-
- No bundler is involved.
www/is a plain static directory, so the shell reaches Capacitor through the global build of@capacitor/core(window.Capacitor+registerPlugin).npm run prepare:wwwstages it aswww/capacitor.jsfromnode_modules— gitignored, so it can never drift from the installed version — andnpm run cap:syncruns that beforecap sync. - Outcome: the entire app ships (triage buckets A + B) — but see the sync caveat below. Verify parity, offline, persistence, and a sync round-trip on-device.
- 🔴 GitHub sync is BROKEN — treat it as non-functional and leave it switched off.
Pull works; push does not reliably land. This is pre-existing, not a migration
regression: it reproduces identically on the currently hosted single-file version, so
the feature appears never to have been finished. Deliberately not fixed — it is
scheduled for deletion in Phase 4a (replaced by Drive), so any fix is throwaway work on
code with a known end date, and nothing ships to users before P4 — the hosted legacy
version remains the one real users are on (see Locked decisions → When we first
ship).
- The failure is not visible by inspection:
syncPush,GitHubBackend.pushand themarkDirty()trigger viacommit()all read correctly. Diagnosing it needs a live round-trip against a real repo and token. - Evidence contradicts a clean "never pushes": across two reinstalls an entry created locally reappeared after a relaunch and the transaction count went 51 → 52, which only a pull can do — so the remote did receive it at some point. The failure may be intermittent or conditional. Do not assume either that it works or that it never works.
- Consequence for P1 sign-off: the "sync round-trip works on-device" criterion is
not met and must not be ticked. Auto-sync pulls on launch, and while
syncPulldoes guard against clobbering (it returns a conflict whendirtyis set) that guard is not worth trusting against a real ledger while the push side is not understood.
- The failure is not visible by inspection:
- 🔴 CONFIRMED ON DEVICE: backup download is silently dead. Download backup
(.json) produces no file and no error.
download()builds ablob:URL and clicks an<a download>; Capacitor's Android bridge sets noDownloadListener(the class appears nowhere in@capacitor/android), and Android will not route a blob URL to the DownloadManager. Export CSV shares the helper and was confirmed to fail the same way. Restore still works — it uses<input type=file>, which the WebView does handle, and restoring the fixture on-device is how P1 parity was checked. (CSV import also worked at P1; P2 dropped it. Restore is now the only way in — and, with download dead, data only travels one way until P4.)- This is a genuine regression against the web build, where the download works. It is accepted deliberately and deferred to Phase 4's native share/save (bucket B's "backup download → native Share/Filesystem"), not treated as parity.
⚠️ Combined with sync being broken, native currently has no way to get data off the device at all. That is survivable only because nothing ships before P4 (see Locked decisions → When we first ship). Do not let P4 slip past this without fixing it, and do not treat the native build as a place to keep a real ledger until it is.
-
Dropped: the
window.storageClaude-artifact adapter. Thestorechain is now localStorage → IndexedDB → memory. Settings' Saved in row reads "this browser" on native, which is inherited wording, not a P2 change — it goes when the storage layer moves to native storage (P4c, now@capacitor/filesystem). -
Dropped: the service worker in the native build — the registration is gone from
www/index.html, and the web build keeps its own (reference/sw.js, untouched).The SW is not just redundant natively, it actively bites while iterating on Phase 1: it caches
index.htmlunderhttps://localhost, and reinstalling an updated APK does not invalidate that cache unlesssw.jsitself changed. Symptom: the new APK shows the old UI.- Deleting the registration is not enough, and this is the part worth remembering:
a device that installed an earlier APK still has the old worker registered, and it
serves the stale
index.htmlcache-first, so the new page never runs and can never unregister anything. The one file that does reach those devices issw.jsitself — the stale page still callsregister("sw.js"), and the browser fetches it and installs it on any byte change. - So
www/sw.jsis now a self-destructing kill switch: it deletes every cache, unregisters itself, and reloads open windows, which then load from the APK. No fetch handler — an unregistering worker must not answer from the cache it is deleting. It is inert on a fresh install (nothing registers it). Keep it until every install has been through it. - Verified in the browser harness against a deliberately staged stale registration:
old worker controlling +
index.htmlcached → registersw.js→ caches[], registration gone,navigator.serviceWorker.controller === null, app re-renders. Only its own scope was cleared (a/reference/registration survived untouched).
- Deleting the registration is not enough, and this is the part worth remembering:
a device that installed an earlier APK still has the old worker registered, and it
serves the stale
-
Dropped: CSV import. Button,
#fileCSVinput + handler, andimportCSV/parseCSV/acctIdByNameare gone; the Backup & data hint no longer offers it. A CSV round-trip was always lossy — it never carried goals, IOUs, templates, tags or ids — so it was never a backup, and the backup file is. Revisit only under a general "Import from…" feature. -
Softened the backup-due nag. It used to appear at 5 records and again after 7 days or 20 changes, and "Not now" set a flag that was never persisted — so it came back on the next launch. A reminder that returns every time you open the app is one you stop reading. Now: first nag at 25 records, repeat after 30 days or 50 changed records, and "Not now" is a persisted 14-day snooze (
nagSnooze, saved in settings and cleared by any backup or restore).nagDismissedis gone. -
Keep: CSV export — but it produces no file on native (confirmed on device; same
download()helper as the JSON backup, see Phase 1). Fixing that is Phase 4's job. -
Added: the WebView capability probe (
www/native.js, ahead of the Capacitor early-return so it runs even if the bridge is missing):CSS.supports("translate", "-50% 0") && CSS.supports("selector(:has(*))"). On failure it inserts a themedrole="alert"banner as the first child of<main>— above#backupNag, and outside#app, sorender()never wipes it — saying the engine is out of date, the data is safe, and to update Android System WebView or use a browser. A feature probe, not UA sniffing, so it self-corrects when the WebView updates. Rationale and the measured Chromium ≥ 105 floor are in Locked decisions. -
GitHub sync: 🔴 broken, not working (push does not land — pre-existing; see Phase 1). Deliberately not fixed and not dropped here either, because Drive (Phase 4a) is the replacement and there is no point rewriting code with a known end date. Leave the feature switched off.
-
P2 verification — web harness (full fixture).
npm run checkclean; both shell files and the app's inline script parse. Seven of the eight screens are byte-identical toreference/index.htmlin text and structural signature; Settings differs by exactly the two intended edits (the Import CSV button removed, the hint reworded) and nothing else. Backup/CSV export still build their blobs and filenames; the snooze was exercised end-to-end (dismiss → survives reload → returns once aged past 14 days → cleared by taking a backup); the probe banner was forced on and renders themed, in the right place, surviving a re-render. -
P2 verification — on-device, two emulators + the realme. The upgrade path was tested for real: build the pre-P2 APK (from
HEADwith the P2 changes stashed), install it, seed the fixture, confirm the stale state, thenadb install -rthe P2 APK without uninstalling — the exact scenario the kill switch exists for.Device API WebView Stale state before After in-place update Pixel_9_Pro (emu) 15 (35) 124 SW at https://localhost/sw.js, controlling, cacheledger-v101,index.htmlcached, old UIcaches [], no registration, uncontrolled, P2 UIPixel_10_Pro (emu) 17 (37) 149 identical identical realme RMX3370 (real) 13 (33) 150 a genuine P1-era install, not a staged one — installed 14:25, updated 15:34 that day, with the fixture and real accumulated settings identical 🟢 The realme run is the one that actually settles it. The emulator runs reconstructed the stale state; the realme was in it — a P1 APK that had been installed, used, and already updated once, carrying its own cache and a settings blob written by the old build. After
adb install -rwith no uninstall: registration gone,caches [],cachedIndexHtml false, uncontrolled, and the P2 UI on screen (Settings shows three buttons, no Import CSV). Everything the user owns survived byte-for-byte — 157 tx, 6 accounts, 2 loans, 11 templates, 4 goals, 5 IOUs,cur₱,themesystem,lastBackup 1784713440531,backupCount 176, sync still off — withnagSnoozethe only key added. The nag correctly stayed hidden, sincetotal 176 === backupCount.Also on-device: 157 transactions and 6 accounts survived the update; settings written by the old build (no
nagSnoozekey) load without complaint and gain the field; "Not now" survives a full force-stop + relaunch; back button still unwinds Trends → Overview and exits from Overview; both probes return true so no banner, as expected for these WebViews. Inspection was done over CDP against the debug WebView (adb forwardtowebview_devtools_remote_*), not by reading screenshots. -
🟢 Offline launch survives losing the service worker — the one thing dropping it could plausibly have broken. Verified with airplane mode on,
caches []and zero registrations: the app launches and renders the full ledger, because Capacitor serves every asset from the APK. The SW was never what made the native build offline-capable. -
⚠️ Pre-existing, found while testing P2, not caused by it: localStorage writes are flushed to disk asynchronously, soam force-stopwithin about a second of a write loses it — the first snooze test failed this way before the value was re-read as0. At a 2-second gap it persists reliably. This applies to every setting the app writes (theme, currency, privacy, collapsed cards), not just the nag, and it is a normal Chromium property rather than a Ledger bug. Real users will not hit it. It disappears when storage moves to native storage (P4c — now@capacitor/filesystem) — worth remembering as one more reason to make that move. -
🟢 The probe's failure path is confirmed on a genuinely under-spec WebView. An AOSP Android 10 (API 29) AVD carries
com.android.webview74.0.3729.185 and has no Play Store, so the WebView is frozen at the system-image version and cannot drift above the floor — which is exactly what makes an AOSP image the right choice here; a Google Play image would auto-update itself out of the test. Both probes returnfalseand the banner renders, legible and correctly placed, with the app still usable behind it.- The screenshot also confirms the predicted breakage rather than just asserting it:
flex
gap(Chromium 84) is absent, so the hero rendersINCOME · THIS MONTHEXPENSES · THIS MONTHwith the labels run together, and every shortcut chip loses the space between name and amount (Morning coffee-₱145.00). This is the "49 flex rows losing their spacing" from Locked decisions, observed. - Cosmetic aside, below the floor so not actionable: the
₱glyph renders as tofu on WebView 74. - Keep this AVD. It is the permanent regression target for the reject path — see the Phase 3 note below, which is the real reason it matters.
- The screenshot also confirms the predicted breakage rather than just asserting it:
flex
Why this is split into P3a–P3e. P3 is the riskiest phase because it changes two things at once: what the code is (vanilla → typed modules) and how the screens are built (
innerHTMLstrings → Svelte components). Doing both in one dense pass means reasoning about layout and behaviour simultaneously, and the failure mode is silent drift from the oracle — screens quietly stop matching, or get "improved" by accident, and it is only noticed at the end when it is expensive to unpick. The fix is one hard thing per sub-phase, each with an explicit gate, so a deviation is caught the moment it appears. Do not collapse these back into one step.This works because the reference is a clean pure-core / DOM-shell split: every
document./innerHTMLcall lives in the render/wire code, while money math, currency, crypto, the backup schema and the derivations (netWorth,acctName,relLabel,backupDue) are pure and only close overstate. So the domain extracts cleanly (P3a) and the view is rebuilt fresh in Svelte (P3b–d). The renderers are never re-ported to an interim TS renderer — that would be throwaway work and a second surface to drift on.Parity method throughout — follow the
## Parity methodsection (its own section below, the canonical version). In brief: functional parity is a 100% hard contract; visual parity defaults to the oracle, kept different only as best-of under a fixed whitelist and logged inPARITY_NOTES.md; each screen is checked mobile then desktop before the next, never batched.webDirstayswww— the current build keeps shipping — until the P3e cutover.
P3a — Test harness + domain extraction. No Svelte, no view changes.
- Goal: a typed, Vitest-covered domain core that is behaviour-identical to the reference and consumable by both builds.
- Install Vitest (+ jsdom for the WebCrypto path) and add a
testscript — it is not installed yet. Fold it into the CI gate alongsidecheck. - Extract pure modules to
src/lib/domain/, porting near-verbatim and takingstate(or the specific slices) as parameters instead of closing over a module global:money.ts—fmt/fmtBig/raw, rounding, sign, privacy masking.date.ts—today/nowTime/iso/dim/relLabel/p2.currency.ts— theCURRENCIEStable + symbol handling.crypto.ts—deriveKey/sealBlob/openBlob/b64/unb64/randBytes, params unchanged (AES-GCM + PBKDF2 250k SHA-256 — do not weaken).backup.ts—backupText(schema v9), restore validation /applyBackup,backupDue/totalRecords(softened P2 thresholds 25/30/50/14).state.ts— the typed model + pure reducers for every mutation (add/edit/delete across tx, loans, templates, goals, IOUs; transfer + fee; partial settle; tag-based People derivations and legacy lent/borrowed; Undo as state, not DOM).sync/— a generalisedSyncBackendinterface withGitHubBackendas one impl. Network stays at the edge; the sha-guard/conflict logic is pure and tested. Push is known-broken (pre-existing, dies in P4a) — port the structure, do not chase the bug.
- Verification — golden-master in Vitest. Capture the expected values from the oracle
once: a throwaway copy of
reference/index.htmlwithwindow.__ref = { fmt, netWorth, … }appended inside the IIFE, loaded headless, its outputs across the full fixture + edge cases dumped to a frozen JSON fixture. The TS must then reproduce them exactly. Cover net worth, per-account balances, monthly totals, People balances (incl. partial-settle 1300 − 500 = 800), transfers never.pos/.neg, template due/overdue,backupDuethresholds, the schema-v9 round-trip, and a crypto seal→open round-trip — plus opening a blob sealed by the reference (cross-compat is the real crypto regression guard).- Injecting the compiled domain back into a
www-hybrid was considered and rejected: the byte-diff#appharness is dominated by the unchanged renderer and is blind to exactly the subtle domain bugs golden-master catches, while its only unique by-product (proving the modules bundle and are callable) is already proven by Vitest importing them and by P3b+ importing them into Svelte. Not worth the throwaway lib-build + hybrid glue.
- Injecting the compiled domain back into a
- Gate:
vitestgreen; golden-master matches the oracle-captured values. Deliverable:src/lib/domain/**+ tests. Thewwwbuild still ships unchanged. - ✅ P3a DONE. Modules:
types·money(fmt/fmtBig/raw + round2) ·date·currency·crypto(AES-GCM/PBKDF2-250k, params unchanged) ·backup(schema v9;backupDueon the softened P2 thresholds, not the frozen oracle's — logged inPARITY_NOTES.md) ·derive(acctBal/netWorth, People incl. the partial-settle FIFOpersonOpenItems, entries, flow, goals) ·state(pure reducers for every mutation +buildTag+ Undo assnapshot/restore) ·sync/(SyncBackend+createGitHubBackendwith injectable fetch + the puredecideSync/decidePull/syncOnconflict logic; push left as-is, dies in P4a). Everything takesstate/slices as params — no DOM, and date-dependent functions take an injectabletoday/now. Vitest + jsdom installed;npm run test,npm run test:watch, and the combinednpm run gate(check+test) wired. 79 tests pass;npm run checkclean. The golden master (src/lib/domain/__golden__/oracle.json) was captured once from awindow.__ref- patched copy of the oracle in jsdom (scripts/capture-golden.mjs), frozen totoday = 2026-07-27under UTC for reproducibility;golden.test.tsreproduces it exactly (net worth, per-account balances, monthly + all-time totals, People balances + drill-down + partial-settle 1300→800-style FIFO, transfers never.pos/.neg, template due/overdue, schema-v9backupTextbyte-identical, and opening a reference-sealed blob). The compiled-domain-into-www-hybrid check was not built, as decided above.- Consume, don't re-derive. P3b–P3e import the trusted domain via the barrel
(
src/lib/domain/index.ts); do not re-implement logic in the views. Runnpm run gatebefore each sub-phase lands. Regenerate the golden only if the oracle or the domain's expected outputs change, and only withTZ=UTC node scripts/capture-golden.mjs(the tests run under UTC to match the committed fixture). - P3a is the P3a-scoped core, not every pure function in the oracle. Deliberately
still in the oracle's view layer, to extract in P3d when their screens are wired:
the Trends series builders (day/week/month bucketing, net-worth-over-time,
eventDates), autocomplete ranking (rankByUse/acSource),acctRanked, the display date formatters (fmtDate/fmtTime/dtLabel/relDay/metaLine— locale-dependent, outside the plan'sdate.tslist), and text builders (goalStatus).derive.tsis the tested foundation; P3d adds these adapters as needed. - Branch: all of P3 (P3a–P3e) lives on the local
feat/p3-sveltebranch offdevelop(kept local by choice; not pushed). CI is deliberately not wired yet — thenpm run gatescript is the gate until the P3 →developmerge.
- Consume, don't re-derive. P3b–P3e import the trusted domain via the barrel
(
P3b — Tokens + reusable primitives. Svelte, built and checked in isolation.
- Goal: the reusable component kit, so screens become pure composition and no primitive is hand-rolled twice. This is the sub-phase that buys reusability — spend the effort here.
- Move the
DESIGN.mdtoken system into a global stylesheet; build each repeated primitive as a scoped Svelte component owning its styles (.btnin<Button>;DESIGN.md§6–§7), no utility framework. Inventory by grepping the reference for repeated class patterns:Button(+.sec/.danger/.btn-sm),Card(collapsible, keyed),Field/Input,Select,Segmented(2- & 3-way),Toast,Modal/ConfirmModal, quick-addSheet,Fab,Icon,SnapRow,Hero,Nag. - Gate: each primitive renders in a tiny harness and matches its reference counterpart
(markup + computed CSS; best-of only per the whitelist, logged), mobile then desktop for
that primitive before the next. Deliverable:
src/lib/components/**+ tokens. No screens yet. - ✅ P3b DONE. Tokens moved to one global sheet (
src/lib/styles/tokens.css) —:root+[data-theme="dark"]verbatim from the oracle, plus the minimal base/reset, the.num/.pos/.neg/.neuutilities and the.priv-*money-masking rules; every other style is scoped in its component. 22 primitives undersrc/lib/components/(barrelindex.ts):Icon+IconSprite(inline sprite kept for now; Phosphor swap is P3e),Button·IconButton·RowIconButton(.mini/.del) ·Act(+.primary/.pill),Seg(2-/3-way ·.scroll·.raised, tones,.on),Card(collapsible, grid-rows animation, keyed),Field·Input(+ amount/invalid) ·Select,Dot,Row(.lrow),Badge,SnapRow,Pills+Hero,Nag,Toast,Modal+ConfirmModal,Fab. Each reproduces the oracle's CSS declaration-for-declaration; contextual icon sizing is owned by each parent via.parent :global(.ic).npm run checkclean (0 errors/warnings); 79 domain tests still green. Verified in the isolation harness (src/lib/dev/Harness.svelte, dev-only, not shipped): computed control heights (44/34/30), icon sizes (17/20/14/18/26), radii, money tones, the load-bearingtranslate: -50% 20pxon.toast, seg.raisedvs filled tones, and.priv-partialmasking (both figures →--ink) all match the oracle's token values — ≤480 then >780, light then dark; modal/confirm/toast overlays and the seg/card/theme/privacy interactions exercised. Two best-of (a11y) deviations logged inPARITY_NOTES.md:Segaddsrole="group"/aria-label/aria-pressed;Modalbackdrop addsrole="presentation"(no Escape-to-close — oracle has none). Fonts + tree-shaken Phosphor stay P3e (harness uses fallback fonts). Screens are P3c — not started.
P3c — Static screens. Svelte composition only — NO domain wiring.
Carried from P3b — read before starting. The primitive kit is done and trusted: import from the barrel
src/lib/components, don't hand-roll. Specifically:
- The tab bar is chrome, not
<Seg>.nav.tabsshares the.seg.raisedactive tone but has its own trough + scroll-track, the ≤780 fixed bottom-dock, and safe-area padding — build it in the global shell reusing that tone, not by dropping in a<Seg>.- Specialised rows aren't primitives.
.prow(People),.stmt-row(Statement),.bd-row(Trends breakdown) were deliberately left for their screens (DESIGN.md §2);<Row>covers only the canonical.lrow.- Layout containers aren't built yet —
.cols/.stack/.vstack/.two/.chips/.filters+.filt-*/.list/.day-div/.statcol/.goal/.legendare screen composition; add them scoped, referencing tokens, as the screens need them.- The shortcut chip (
.act.pillwith.nm/.amt) is styled in<Act>, but its data-driven build (template → formatted amount) is P3d.- Fonts + icons stay P3e. Screenshots render fallback fonts — judge layout/structure, not glyph shapes. Icons are the inline
<IconSprite>;<Icon>renders#ph-*.- The harness (
src/lib/dev/Harness.svelte) is dev-only;App.sveltemounts it today and P3c replaces it with the real shell (header → tab bar →<main>→ footer).
- Goal: each of the 8 screens rendered from primitives + fixture data as static props, matched for visual/structural parity — deliberately before behaviour, so any drift shows up as layout drift and nothing else. This is the step the monolithic P3 skipped, and where it drifted.
- Build the global chrome first (header, tab bar incl. the mobile bottom-dock ≤780px, footer, back-to-top, FAB, privacy toggle, nag slot) as the shell screens mount into. Then screens one at a time in order: Overview → Track → Accounts → People → Templates → Trends → Settings → Help. Numbers come from P3a's tested derivations; interactivity does not.
- Gate per screen: structural signature (tag + class) and
#appinnerText diff vs the oracle on the full fixture → matches; screenshot mobile then desktop for that screen before starting the next (desktop>780is not deferred). Any kept difference is logged as a best-of deviation with its whitelist reason. - ✅ P3c DONE. The global shell (
src/App.svelte+src/lib/shell/{Header,Tabs}.svelte: header, tab bar with the ≤780 bottom-dock + scroll-fade, footer, back-to-top, FAB, toast, sprite, modal slot, nag slot) plus all 8 screens rebuilt as static Svelte compositions undersrc/views/(with shared parts insrc/views/parts/:EntryRow,GoalRow,GroupedList,PersonRow,TemplateRow,EntryForm,ShortcutChips,Cols,BigHero). Numbers come from the trusted P3a derivations via a dev session (src/lib/dev/session.svelte.ts: fixture →LedgerStatethroughapplyBackup, plus reactivecur/priv/tab); view-layer display helpers ported tosrc/lib/view/(format.ts,range.ts,trends.ts,help.ts). All 8 screens are an EXACT match to the oracle — structural signature (tag+class) and#appinnerText, zero diffs, on the full 157-tx fixture (Track 631/2150 · Accounts 215/880 · People 149/610 · Templates 252/1117 · Trends 411/1657 · Settings 181/1360 · Help 233/7168; Overview is clock-relative — 303/1349 on Jul-28, 332/1465 on Jul-27 — since its Upcoming/Recent/relative-day sections readtoday()) — verified with a same-origin harness (oracle capture inlocalStorage.__oracle, diffed live), and re-checked across a Jul-27→28 midnight rollover (both builds read the live clock, so they re-derive identically). Mobile+desktop screenshots confirm layout parity.npm run gateclean (0 check errors, 79 tests). Details + the three within-parity primitive notes (Trends charts via{@html};<Seg>optional icon;<Select>unkeyed each) inPARITY_NOTES.md. Fonts + icons are still P3e, so screenshots render fallback fonts — glyph parity is a P3e check.webDirstillwww; the P3b isolation harness (src/lib/dev/Harness.svelte) is superseded by the real shell but left in place.
P3d — Wire the domain in. Screen by screen — make it live.
Carried from P3c — read before starting. The shell + 8 screens are built and at exact visual/structural parity; the only thing to add is behaviour. Concretely:
- The seam to replace is the dev session.
src/lib/dev/session.svelte.tsloads the fixture (applyBackup) into a plainLedgerStateand holds a reactiveui(cur/priv/tab/theme). Swap it forsrc/lib/stores/(real persistence + autosave) feeding the same shapes the screens already read. The P3c view helpers stay —src/lib/view/{format,range,trends,help}.tsare pure and consumed as-is; fold them into the wired adapters rather than re-deriving (they're the "extract in P3d" functions from the P3a note —fmtDate/relDay/metaLine,eventDates/computeRange, the Trends series/chart builders, help copy).- Everything interactive is currently inert, by design. Row actions already carry their hooks (
data-log,data-edit-tx,data-id,data-recdel,data-tmpledit,data-goaledit/data-goaldel,data-acctdel/data-edit,data-psettle/data-psettle1,data-ioudel/data-loandel) — wire them with Svelte handlers (you won't need the attributes). The privacy toggle (privBtn) is a no-op; forms don't submit; the<Modal>/<Toast>primitives are mounted but not driven; the entry-type / view / period segs change their own.onlocally but don't reswap fields or refilter yet.- Undo on every mutation and the privacy blur are the non-negotiables (Do/Don't). The
<Modal>confirm-returns-false-keeps-open contract and the reducers (withsnapshot/restoreUndo) are already there in P3a — wire, don't rebuild.- Nav is chrome and already live: tab bar + header settings/help switch
ui.tab; keep in-memory tab state and re-confirmwww/native.js's back-button DOM-driving still maps to the new markup.- Reuse the P3c parity harness for functional checks: seed the oracle at
/reference/index.htmlfrom the fixture intolocalStorage, stash its per-screen#app{sig, innerText} inlocalStorage.__oracle, and diff the live Svelte app against it after each action (details inPARITY_NOTES.md→ P3c). Overview and parts of Trends are clock-relative — capture the oracle and check the app at the same moment, or freeze the clock, so a date rollover doesn't read as a diff.src/lib/dev/Harness.svelte(the P3b kit harness) is now orphaned — delete it when convenient.EntryForm'spfxprop is unused in the static form but is the seam for the quick-add modal's"q"prefix — keep it.
- Goal: interactivity. With the domain already trusted (P3a) and the markup already matched (P3c), the only thing under test is the wiring.
- Add
src/lib/stores/wrapping the P3a domain + persistence (autosave); wire mutations with Undo on every one, privacy blur, collapsible persistence, filters/search, quick-add and modals. Preserve in-memory tab nav; confirmnative.js's back-button DOM-driving still maps. - Gate per screen: functional parity 100% — every action reproduces the oracle
(add/edit/delete, transfer + fee, partial settle, template log, goal, IOU, backup/restore,
CSV export, sync setup with push left off, theme/currency/privacy, reset view; Undo
everywhere). Deliverable: a fully functional Svelte app at parity, still on
webDir: www. - ✅ P3d DONE. The dev session was replaced by a real store layer:
src/lib/platform/ store.ts(localStorage→IndexedDB→memory, the oracle's chain, sameKkeys sowwwdata loads unchanged) andsrc/lib/stores/—ledger.svelte.ts(the data core + persisted settings + ephemeral view state as$state;commit(label, mutate)= snapshot → domain reducer → autosave → Undo toast, reproducingcommit/toastUndo/undoTo; theme/privacy/ nav/backup helpers), plustoast,modal,actions,syncstores. All 8 screens + shared parts were wired to it (rowdata-*placeholders → Svelteonclick→actions.ts); the 6 form modals (EditEntry/EditAccount/GoalModal/SettlePerson/EditTemplate/QuickAdd) and 4 sync modals live insrc/lib/modals/behind aModalHost. View-layer adapters added:view/{form,entry,template,statement,backup,autocomplete}.ts(the "extract in P3d" functions — entry/template submit, statement rows, backup/restore/CSV, and the autocomplete rankingrankByUse/acSourcewired via ause:autocompleteaction); the P3cview/{format,range,trends,help}.tswere kept andtrends/rangeparameterised by the live period/custom-range/placesAll. Orphans deleted (dev/session.svelte.ts,dev/Harness.svelte,parts/EntryForm.svelte); a DEV-onlydev/seed.ts(dynamically imported, stripped from the prod bundle) seeds the fixture for the dev server + harness.npm run gateclean (0 check errors/warnings, 79 tests).- Verified with the same-origin harness on the full fixture: 7 of 8 screens are an
exact match to the oracle (structural signature and
#appinnerText, zero diffs) in the default state; Settings differs by exactly the intended CSV-import drop (sig 181→179, text 1360→1370) — tracking the shippingwwwbuild, not the frozen oracle, the same call P3a made forbackupDue(logged inPARITY_NOTES.md→ P3d). Functional spot-checks through the real store: shortcut-log + Undo (tx 157→158→157), edit-account modal (seed→save→persist→Undo), People partial-settle FIFO (Jonah 1300 − 500 = ₱800, settle modal pre-fills 800), Track type filter (income → 18 rows), Statement running balance (closing = net worth), add-account (+Undo), currency change (persists + re-tints), quick-add expense↔transfer field-swap, privacy masking, collapsible-card persistence, and autocomplete (where/person → frequency-ranked suggestions, filter, choose) — all correct. - Two additive primitive tweaks (logged):
<Field>gained an optionalstylepassthrough (to display-toggle the person-tag slice field, matching the oracle's inlinestyle) and<Card>now persists its collapsed state through the store (isCollapsed/setCollapsed, keystate.tab+":"+title— matching the oracle, verified restoring across nav); nothing else changed. Sync is wired end-to-end (setup/reset/rename/conflict modals, connected card, disconnect, manual sync, header button) over the domainSyncBackend+ crypto. Only the GitHub backend impl + its setup fields retire in P4a, NOT the machinery: the state machine, the conflict diff/merge (moved into the domainsyncmodule), the crypto envelope and theSyncBackendseam are backend-agnostic — Drive slots in behind the same interface (§P4a). So the durable core is now CI-tested:domain/sync/roundtrip.test.tsdrives the real adapter (sha-guard) + crypto +decideSync- merge against a fake in-memory Contents API (push→pull decrypt, stale→CONFLICT,
both-changed→merge, clash by
prefer, wrong-passphrase→throws). GitHub's push bug is still not chased (auto-push off; the plan's "port the structure"); the one untestable piece is the live round-trip against a real repo.npm run gate: 0 errors/warnings, 84 tests.
- merge against a fake in-memory Contents API (push→pull decrypt, stale→CONFLICT,
both-changed→merge, clash by
- Known minor: in-progress form drafts (Add-entry / Add-IOU field values) are
screen-local and reset on tab switch, where the oracle keeps
formDraft/iouDraftinstate. All persistent view state (filters/search/period/account/view/people-open/ trends-period/entry-type) does persist viaui. Every committed action matches the oracle. SeePARITY_NOTES.md→ P3d. - Still P3e: fonts + icon delivery (glyph parity), the
webDirflip, the three P2 carry-over gates.webDirstillwww.
- Verified with the same-origin harness on the full fixture: 7 of 8 screens are an
exact match to the oracle (structural signature and
P3e — Asset swap + build cutover. Carries the three P2 gates below.
- Swap asset delivery: base64
@font-face→ packagedwoff2(Fontsource / Vite, weights 400/600/700 only); inline SVG sprite → tree-shaken Phosphor (unplugin-icons/phosphor-svelte). - Three gates that must hold through the cutover (all recorded during P2, restated here as P3e's checklist):
- 🛑 BLOCKING, do this before the
webDirflip:sw.jsmust still be served from the app root indist/. It is currentlywww/sw.js;public/holds onlyfavicon.svgandicons.svg, so a naive flip drops it from the build. That is not a cosmetic loss — it is a one-way trap:- A device still on a P1 build loads its cached
index.html, which callsregister("sw.js"). If that request 404s, the update check fails and the existing old worker stays registered and keeps serving the stale cache cache-first, forever. The device never sees the P3 app and cannot be rescued except by clearing app data or reinstalling. - So: copy
sw.jsintopublic/(or otherwise guarantee it lands at the root ofdist/) before flippingwebDir, and re-verify on a device that is genuinely still on P1. - The Huawei SHT-AL09 is deliberately being held on a P1 build for exactly this test — it is the P1 → P3 upgrade path, a bigger jump than P1 → P2. Do not update it casually.
- Only once every install has been through the kill switch can
sw.jsbe deleted. With the Huawei parked on P1, that condition is explicitly not yet met.
- A device still on a P1 build loads its cached
⚠️ Give the web build the capability probe too — it does not have one. The probe ships only inwww/native.js, which only the nativewww/index.htmlloads;reference/index.html(the hosted web build) has zero occurrences ofCSS.supports. So an under-spec browser still gets the silently broken layout. That is not merely a missing nicety: the stated reason a high native floor is acceptable is "anyone the native build excludes still has the web/PWA build" — and the native banner sends those users to a build that is equally broken and says nothing. Fix it when Vite'sindex.htmlbecomes the web build; do not retrofitreference/index.html, which is the frozen oracle.- iOS raises the stakes.
:has()needs Safari 15.4 (March 2022), and on iOS every browser uses Safari's engine — so an older iPad user cannot escape by switching browsers, and iOS is a planned target. Reword the shared banner so it does not say "Android System WebView" on the web.
- iOS raises the stakes.
⚠️ Keep the capability probe outside the bundle, and keep it ES5. Today it survives a broken engine because it is its own<script>inwww/native.js, parsed independently of the app. Once Vite emits the app, its default target is modern syntax that Chromium 74 cannot parse — and aSyntaxErrorin the bundle kills the page before any probe inside it runs, turning a clear "your WebView is too old" banner into a white screen, which is strictly worse than the silent breakage the probe was added to prevent. So the probe must stay a small, separate, ES5-safe script that the bundle cannot take down with it. Verify on the AOSP Android 10 AVD (WebView 74) after the flip: the banner must still render. This is the specific reason that AVD is worth keeping around.- Flip Capacitor
webDirwww→distonce the Svelte app builds and the three gates above hold, and stand up the GitHub Pages deploy (Actions build ofdist/, Vitebase: '/ledger/'). - Gate (cutover): full parity harness on the
dist/build; offline launch (airplane mode, zero caches); the Huawei P1 → P3 in-place upgrade (the sw.js trap — a bigger jump than P1 → P2); and the probe reject path on the AOSP Android 10 AVD (banner renders, not a white screen). Only after this doeswebDiractually point atdist. - ✅ P3e DONE. Asset swap + build unification + the
webDir→distcutover, verified in the browser and on-device (all cutover gates passed — see below). This closes Phase 3.- Fonts: base64
@font-face→@fontsourceper-weight imports inmain.ts(the documented, unicode-range-preserving route), weights within 400/600/700 (Hanken 400/600/700, JetBrains Mono 400/600/700, Fraunces 600). Fontsource's weight files ship all subsets + legacywoff(680 KB / 51 files, ~480 KB never fetched, vs DESIGN.md §6), so a smalltrim-fontsourceVite plugin (enforce: 'pre') drops the non-latin subsets and thewofffallback before Vite emits assets → 14 woff2 / 220 KB, keepinglatin+latin-ext(where ₱ / U+20B1 lives) withunicode-rangeintact.@fontsourcestays a dep. Verified ondist:.numcompute to JetBrains Mono,document.fonts.check(…,'₱')true, only latin/latin-ext woff2 fetched, zero font 404s, pixel-identical to the oracle at mobile. - Icons: inline sprite → tree-shaken
phosphor-svelte(Icon.sveltemaps its 21-name union;IconSprite.sveltedeleted). Glyphs identical (Phosphorregular;sync → ArrowsClockwisepath byte-matches#ph-sync); markup differs (<path>vs<use>) — a best-of #5 delivery change,#appinnerText unchanged. - Parity harness on
dist/: 7/8 screens an exact match to the oracle (sig with icon internals collapsed + innerText), Settings = the intended CSV-import drop (same call as P3d). Mobile/desktop, light/dark all match; no console errors. - Native shell folded into the Vite build:
index.htmlgainsviewport-fit=cover+ classic/capacitor.js+/native.js;public/native.js(successor towww/native.js) carries the reworded, platform-neutral ES5 probe inserted above#root, with the modal selector updated to.modal-bg .modal-x;native.cssimported after tokens;capacitor.jsstaged intopublic/byprepare-public.mjs.www/*left frozen (still the live build). - Three P2 gates: ✅
sw.jsemitted atdist/root; ✅ web build now runs the probe (native.js loads in both builds); ✅ probe stays ES5 & outside the bundle (verified it fires and renders the banner withCSS.supportsforced false). - Build config: Vite
basemode-based —'/'(native/dev) vs'/ledger/'(npm run build:web, Pages).webDiris nowdist;cap:syncbuildsdistthen syncs. - ✅ On-device cutover gates — ALL PASSED (debug APK over adb/CDP on the 3 AVDs + the real
Huawei): AOSP-10/WV74 probe reject-path (banner renders, not white — after fixing the
banner to insert immediately instead of waiting for
DOMContentLoaded); modern boot/render (WV124/WV150, + read a pre-existing P1/P2localStorageunchanged); airplane-mode offline launch; and the decisive Huawei P1→P3 in-place upgrade —adb install -rover the genuine P1 build cleared the stale SW +ledger-v101cache, loaded the P3 UI, and preserved settings byte-for-byte. The Huawei is now on P3. - Fonts — ₱/currency (decided: keep as-is). JetBrains Mono (and the oracle's own inline
copy) has no glyph for
₹ ₱ ₩ ฿ ₺; they fall back to systemmonospace— device-dependent, at oracle parity (renders on the real Huawei; tofu only on the barebones 5556 emulator, as the oracle would). Kept.num { 'JetBrains Mono', monospace }; a bundled subsetted-mono fallback was declined. Verify coverage with fontkit, notdocument.fonts.check/width (unreliable for monospace). Detail inPARITY_NOTES.md→ P3e. - CI/deploy (added at the merge):
ci.yml(npm run gateon develop/production) +deploy.yml(Pages viabuild:web, dormant — triggers only onproduction, and only serves once Pages Source is flipped to GitHub Actions). Deferred to P4: retirewww/+sw.js(Huawei now through the kill switch), flip Pages Source at first ship.
- Fonts: base64
What P4 is, and what "first ship" means. P4 does not add features. It keeps the reference parity P3 achieved and replaces/improves the existing features with the native capabilities Capacitor unlocks — Google Drive sync in place of GitHub sync, real native save/share/pick in place of the dead
<a download>. Anything that adds a genuinely new capability (locks, notifications, haptics, widgets) is P5, after first ship. (A biometric/PIN lock is additive and independent of the privacy blur — the blur is its own genuine, shipped feature, hiding the user's figures from onlookers in public, not a placeholder for a real lock. The blur stays as parity; the lock does not replace it.) The bar for first ship is a seamless successor to the legacy PWA — same data, every feature intact, unmistakably Ledger — now doing on-device what the web build couldn't (real file share/save, Drive sync). Native mechanics that improve on the web ones are the point, not regressions; what must not change is the feature set, the identity, or the privacy/local-first character. (See Locked decisions → P4+ native-first.)Two P1/P2 carve-outs MUST close here (they are the whole reason nothing shipped earlier): GitHub sync's push never lands, and the native build cannot export data at all. P4a and P4b are exactly those closures — they are non-negotiable for first ship; the rest is prioritised below them. Same discipline as P3: one hard thing per sub-phase, each with an on-device gate, tested on the device matrix (native surfaces are barely covered by emulators — go to the realme first).
Install population — assume this in P4, don't re-derive it. The native build has no public users and no testers. The only installs anywhere are ours — the 3 AVDs + 2 real devices (realme, Huawei) — and all are on the P3 build (the Huawei was the last on a P1 build; it was upgraded during the P3e sw.js-trap test). Real users, if any, are on the legacy web PWA (a different origin and scope). Three things follow that P4 can rely on: (1) the storage swap in P4c has nothing to migrate on native — we can wipe/re-seed our own devices freely (the one real migration is carrying legacy web users'
localStorageacross, handled in P4c); (2) thesw.jskill switch has already served its purpose (every native install is through it), so retiringwww/+sw.jsis unconditionally safe; (3) the P1/P2 carve-outs stay survivable right up until P4d flips the switch — first ship is the first time this build reaches anyone but us, which is why P4d's back-to-front parity pass is the real gate.
P4a — Google Drive sync (replaces GitHub sync). Flagship; closes the sync carve-out.
- Native Google Sign-In (via a vetted auth plugin) +
drive.appdatascope; native HTTP viaCapacitorHttp(bypasses CORS;fetchon web), no backend. Slot in behind the existingSyncBackendinterface — the state machine, conflict diff/merge and crypto envelope are already backend-agnostic and CI-tested (P3d), so only the backend impl + its setup fields change. Then retire the GitHub backend (repo/token/host). Updatecapacitor.confignavigation/allow-list for Google, or rely on native HTTP. - Keep the sync secrets (Drive token / E2E passphrase) in Keystore/Keychain instead of plaintext settings — this is doing sync properly on native, not a new feature.
- Gate: on-device sign-in + push/pull round-trip (the one piece untestable in P3d), and conflict/merge against a real Drive folder; the GitHub path removed cleanly (no dead UI).
P4b — Native file operations (fixes export). Critical; closes the export carve-out.
- Backup download →
@capacitor/share+@capacitor/filesystemsave; CSV export → same helper; restore/import<input file>→ native file picker (all with web fallbacks — Web Share /<a download>/<input file>). Restores the JSON backup + CSV export that currently produce no file on native — the P1/P2 regression vs the web build. - Gate: on-device — save a backup, share it, re-pick and restore it; CSV opens in a spreadsheet. Verify on the realme first — the OEM share sheet, file picker and permission dialogs are exactly what the emulators hide.
P4c — Native platform hardening. Lowest-priority first-ship work — the only sub-phase that isn't a carve-out. Improves existing infra; low-risk.
- Storage →
@capacitor/filesystem, ONE implementation behind the existingstore/Kinterface (web fallback = IndexedDB, native = app-sandbox filesystem). Per the P4+ native-first decision this replaces the hand-rolled localStorage→IDB probe with a single plugin-backedKVStore— no two paths to drift — and buys native durability (app-sandbox, not subject to WebView origin-storage eviction), no 5 MB ceiling, and the end of the async-flush loss + "Saved in: this browser" wording. EachKslice is one file; the whole-blob write model is unchanged (O(n)).@capacitor/filesystemis the same plugin P4b already adds. - Migration — embraced, done carefully. On first boot, an idempotent per-key copy from the
old backend into the new one, old data left in place as a fallback. Web carries legacy users'
localStorageacross at the same Pages origin; native is just our own devices (no other installs). Backup/restore is the ultimate net. - Status-bar / safe-area polish across the matrix; retire the frozen
www/+sw.jskill switch (every dev install is now through it — the Huawei was the last, in P3e). - Deferrable on scope, not on risk. It's the only non-carve-out sub-phase, so if scope gets
tight it can move to P5 — but nothing here endangers stability (the swap sits behind the
existing async
store/Kinterface; the migration is a guarded copy). SQLite stays the post-ship option (P5) for row-level writes / queries, if scale ever demands them.
P4d — First-ship gate + cutover. The exhaustive final check, then go live.
- Exhaustive, back-to-front check across every screen and flow, on all devices and the web build. The bar is a seamless successor: no feature lost, no regression, data intact, and unmistakably Ledger — with the native improvements (share/save, Drive, native storage) as the intended differences from the reference, not accidental drift. The reference is the feature/identity yardstick here, not a byte-for-byte target (Locked decisions → P4+ native-first).
- Reconcile against
PARITY_NOTES.md→ "Status going into P4." The standing intentional deviations recorded there (Settings CSV-drop, softenedbackupDue, the a11y additions, the Phosphor icon markup, the ₱/currency fallback) are intended — do not "fix" them back. Reuse its same-origin parity-harness recipe for this pass. - Then cut over, in order: flip Pages Source → GitHub Actions; promote
develop→production(the dormantdeploy.ymlpublishesdist/to Pages); confirm the web build live; bump the app version; publish the APK. The legacy PWA is replaced only here — before this, it stays the hosted build and the one real users are on.
- Only after first ship is real-world used. Everything here adds capability beyond the reference, which is why it waits until first ship is out. Pull from the backlog.
- Real biometric / PIN lock — a new app-access lock, independent of the privacy blur (the blur is a genuine parity feature that stays; the lock does not replace it); local bill notifications; haptics. (Moved here from the old Phase 4 per the first-ship rule: these add capability, they don't restore parity.)
- Home-screen widget / Quick Settings tile (needs a native module).
- Additional sync backends: Dropbox (OAuth PKCE), WebDAV/Nextcloud (behind
SyncBackend). - General "Import from…" feature (would revive a CSV/other importer).
- iOS build via TestFlight; optional desktop (same codebase).
- Native storage swap (if deferred from P4c); a dedicated native date-picker (only if the
HTML
<input type=date/time>native pickers ever prove insufficient — today they suffice). - (Google Drive sync and native file share/save/pick are not here — they are first-ship, P4a / P4b.)
| Bucket | Items |
|---|---|
| A · Ship now (direct port) | all entities; all 8 screens; all cross-cutting UX (quick-add FAB, autocomplete, Undo, privacy blur, collapsible cards, themes, multi-currency, toasts); JSON backup/restore; CSV export; local storage; |
| B · Adapt in place (via Capacitor plugins, one impl + web fallback) | backup download & file import (→ @capacitor/share + @capacitor/filesystem + file picker) — P4b, first ship (regression, must fix); sync fetch (→ CapacitorHttp, native HTTP) — P4a; storage (→ @capacitor/filesystem; web fallback = IndexedDB) — P4c; service worker (web only) |
| C · Native | First ship (P4): Google Drive sync + Keystore for sync secrets (P4a); native share/save + file picker (P4b); storage → @capacitor/filesystem, status-bar polish, retire www/+sw.js (P4c). Post-ship (P5, additive): biometric/PIN lock; bill notifications; haptics; widget/QS tile; SQLite (row-level/queries); native date picker (only if HTML inputs fall short) |
| D · Dropped / changed | done in P2: window.storage shim (dropped); native-build SW (dropped — www/sw.js is now a kill switch; web build keeps reference/sw.js); CSV import (dropped); backup nag (softened + persisted snooze). Still pending: GitHub sync (retire after Drive) |
- Parity oracle:
reference/index.html(the current app; do not delete). - Reuse as TS modules (near-verbatim): crypto (
deriveKey/sealBlob/openBlob); sync adapter (GitHubBackend→ generalize toSyncBackend); currency/format helpers; thestateshape; backup schema v9 (backupText). - New scaffold:
package.json,vite.config.ts,capacitor.config.ts,src/lib/{domain,stores,components,platform},src/views/*,android/(generated),ios/(later),dist/(build output →webDir).
How the Svelte build (and any later screen work) is checked against reference/index.html,
the frozen oracle. This is the canonical version; Phase 3 and Verification point here.
Scope. This method governs P1–P3 — rebuild the same app, with the oracle as a byte-level contract. From P4, features replaced by native capabilities are held to identity + no-regression, not byte-identical behaviour (a native share sheet, Drive, native storage are intended deviations) — see Locked decisions → P4+ native-first. The reference stays the feature/identity baseline throughout; it stops being a byte-for-byte target only for the specific features P4 reworks.
- Functional parity is a 100% hard contract. Every action must reproduce the oracle exactly — no judgement calls, no "better." Best-of (below) never applies to behavior.
- Visual parity defaults to reproducing the oracle. Every visual difference is drift-to-fix until justified. A major discrepancy is fixed toward the oracle immediately. A minor one (spacing, fonts, colours, alignment) is subject to the best-of rule.
- The "best-of" rule — what may be kept as an improvement. Keep the new build's version
of a minor difference only when you can name a concrete reason it is better, from this
fixed whitelist:
- It fixes a real rendering bug in the oracle (truncation, misalignment, sub-pixel jitter, overflow, a wrong wrap).
- It aligns a one-off value to the
DESIGN.mdtoken it should have used — the primitive now owns the style (consistency). - Accessibility: contrast,
:focus-visible, hit-target size, semantic markup / aria. - It removes an inconsistency where the oracle drew the same element two ways.
- Crisper asset rendering (packaged
woff2/ tree-shaken icons) that is not itself a design change. Anything outside this list — taste-based spacing, a "nicer" type scale, a different colour/identity, tighter density, reworded copy — is not best-of: match the oracle, or raise it as a P5 proposal. Best-of stays within Ledger's identity (golden rules): polish and correctness, never redesign or new layout. Unsure whether a diff is an oracle bug or a detail worth keeping? Pause and ask.
- Log every intentional deviation as you make it — screen · element · oracle→new · which
whitelist reason — in
PARITY_NOTES.md(create it in P3). Without the log, the next pass re-flags your improvement as drift and "fixes" it back, and you lose the ability to tell deliberate changes from accidental ones. The log is what keeps best-of from being a loophole. - Order — per screen, mobile THEN desktop, both before the next screen. Do not batch
desktop to the end. Mobile (≤480) is primary; desktop (>780, where
nav.tabsis not bottom-fixed, and which went untested on real hardware in P1) is a first-class per-screen check. The 481–780 middle regime exists too — spot-check it when a screen's layout hinges on it. - Screenshot gate: screenshots are load-bearing for the visual pass. If they stop
working, PAUSE and get the browser pane fixed before continuing visual checks —
DOM/functional checks via
read_pagecan still proceed. - Always load the fixture first (see Verification below) — an empty ledger hides bugs.
Always load the fixture first. reference/ledger-testdata.json (synthetic, schema v9,
₱/PHP, 157 transactions) restores through Settings → Restore from backup. Every check
below assumes it — an empty ledger renders empty states and hides nearly every parity bug.
It now covers every feature, including the two previously-uncovered ones: transfers with
fees and both debt systems (tag-based People and the legacy lent/borrowed loans).
Grow it when a case is missing rather than inventing throwaway data.
Device matrix actually exercised in P1 — keep this current; the interesting axis is
API × WebView × layout regime, not device count.
| Device | API | WebView | CSS width → regime | Result |
|---|---|---|---|---|
| realme RMX3370 | 13 (33) | 150 | 360 → ≤480 phone |
✅ found + fixed the grey status bar |
| Huawei SHT-AL09 (tablet) | 9 (28) | Chrome 138 | 627 → 481–780 middle |
✅ wide hero, 7 tabs unscrolled, no overflow |
| Emulator | 15 (35) | 124 | 411 → ≤480 |
✅ the windowBackground-only case |
| Emulator | 17 (37) | 149 | 411 → ≤480 |
✅ true edge-to-edge, insets 52/24px |
| Emulator (AOSP, P2) | 10 (29) | 74 | 360 → ≤480 |
⛔ below floor on purpose — probe fires, banner renders, flex gap visibly absent |
What each device is actually for. The fleet is not "more devices is better" — each unit covers something the others cannot, and knowing which is what saves re-testing on hardware that has nothing new to say.
| Device | Uniquely covers | Matters most in |
|---|---|---|
| realme RMX3370 (13/33, WV 150) | the only OEM skin (realme UI): background killing, custom share sheet, file picker, permission dialogs; plus API ≤34 bar painting | P4 — notifications, native share/save, biometric prompts |
| Huawei SHT-AL09 (9/28, Chrome 138) | the support floor, old OS + current WebView; the 481–780 middle layout; pre-2019 Huawei GMS |
P1 ✅, P4a Drive sign-in |
| Emulator (15/35, WV 124) | windowBackground-only bar case |
P1 ✅, P2 ✅ |
| Emulator (17/37, WV 149) | true edge-to-edge + injected insets | P1 ✅, P2 ✅ |
| Emulator AOSP (10/29, WV 74) | below the floor on purpose — the probe's reject path; frozen WebView, no Play Store | P2 ✅, P3 (probe must survive bundling) |
Rules of thumb this implies:
- A WebView-only change (all of P2) is fully covered by the emulators; the physical devices add nothing and are not worth plugging in.
- A native-surface change (all of P4) is barely covered by emulators at all — stock images hide exactly the OEM behaviour that breaks things. Go to the realme first.
- The AOSP AVD must never be "fixed" into passing. A failing screenshot there is the expected result.
- Highest-fidelity kill-switch test available: a device still carrying the P1-era APK,
updated in place with
adb install -r. That is the real upgrade path rather than a staged one; the tell is Import CSV being gone from Settings.
>780px wide regime on a real screen — where nav.tabs is not
bottom-fixed. The Huawei is 627 CSS px in portrait and the app is portrait-locked, so no
device on hand reaches it; only the desktop browser harness has. Now a first-class
per-screen gate in P3c/P3d (desktop checked right after mobile, screen by screen, in the
browser pane via resize_window), not an end-of-phase batch — every regime is re-checked as
the screens are rebuilt in Svelte. Still worth closing on a real wide screen once, with a
large tablet or by temporarily lifting the portrait lock.
A cheap and effective web-side parity harness: serve the repo root, open
/reference/index.html and /www/index.html in the same tab (one origin, so both read the
same restored data), and diff each tab's #app innerText plus a structural signature of
tag + class names. Run twice in P1 — once on a thin fixture and again on the full 157-entry
one — and all eight screens were byte-identical both times. It is the fast regression check
for the screen-by-screen P3 rebuild.
Rules the full fixture makes checkable, verified in P1 and worth re-checking in P3:
-
Transfers are never green or red. All render
.neu(grey), paired asFrom → Towith a⇄glyph and the fee inline; none leak into.pos/.neg. -
Both debt systems coexist on People. Tag-derived balances and legacy loans list together, the latter marked
· old loan; partial settlement nets correctly (owed 1,300 − paid 500 = 800). -
Templates split recurring vs shortcuts by the presence of
dueDay, and overdue recurring entries carry a day count. -
P1: debug APK installs; app launches offline (airplane mode); create data → force-close → reopen persists; feature parity vs reference; back button navigates tabs.
GitHub sync push/pull works on-device— struck: sync is broken and stays broken (see Phase 1). Do not treat this criterion as passable until Drive sync lands in 4a. -
P2: ✅ all met. Parity harness — 7 of 8 screens byte-identical to the oracle, Settings differing only by the dropped Import CSV button and its reworded hint. Kill switch clears caches + unregisters when a P2 APK is installed over a pre-P2 one with no uninstall (proved on a genuinely P1-era realme, not just staged emulators), with every record and setting preserved. Offline launch still works in airplane mode with zero caches. Capability probe passes on modern WebViews and renders its banner on WebView 74 (AOSP AVD). Backup restore, CSV export and the 14-day nag snooze all exercised end-to-end.
-
P3: gated per sub-phase (full detail in Phase 3 above). P3a:
vitestgreen + golden-master matches the oracle-captured values — money rounding, crypto seal→open round-trip (incl. opening a reference-sealed blob), sync sha-guard/conflict, schema-v9. P3b: each primitive matches its reference counterpart in isolation. P3c: each static screen matches the oracle's structure +#apptext on the full fixture (mobile then desktop, per screen). P3d ✅: functional parity — the real store drives every action (log/edit/delete/settle/add/currency/privacy/filters/statement/ Undo verified through the store on the full fixture); 7/8 screens still an exact structure+text match, Settings = the intended CSV-import drop. P3e: parity harness ondist/, offline launch, the Huawei P1 → P3 in-place upgrade (sw.js trap), and the probe reject path on the AOSP AVD — plus the two other P2 carry-overs (probe outside the bundle as ES5; web build gets its own probe). -
P4: on-device plugin checks — Drive sign-in + push/pull, biometric prompt, a fired notification, share-sheet backup, Keystore-persisted token.