Ledger is a private, local-first money tracker, migrating from a single-file PWA to a
Svelte + TypeScript app wrapped in Capacitor for native Android (iOS later), while
keeping a web/PWA build. This file orients any new session working in this repo. See
MIGRATION_PLAN.md for the phased roadmap and DESIGN.md for the visual system.
- Private & local-first. All data lives on-device. Nothing leaves it except via opt-in, end-to-end-encrypted sync. Never add analytics, telemetry, accounts, or a server. No new network calls without explicit sign-off.
- No feature regressions. The original app is preserved at
reference/index.html— it is the parity oracle. When migrating a screen, diff behavior against it and do not silently drop features. - Keep the identity. Preserve Ledger's visual design (see
DESIGN.md). "Native" means ergonomics + real system components, not re-skinning to stock Material/Cupertino. - Backendless sync. Sync is a bring-your-own-storage adapter (
SyncBackend); client-side crypto is unchanged. Don't introduce a backend. - Small & auditable. Minimal dependencies — this is a security-sensitive finance app.
Justify every new dependency; prefer platform/standard APIs. From P4, Capacitor plugins
(with their web fallbacks) are the justified path to native capabilities — one implementation,
not two hand-rolled web/native paths that drift; vet community plugins. See
MIGRATION_PLAN.md→ Locked decisions → P4+ native-first. - Don't touch the crypto params. AES-GCM + PBKDF2 (250k iterations, SHA-256). Don't weaken them.
- Svelte + Vite + TypeScript; Vitest for tests. Not SvelteKit (SPA, no server).
- Capacitor for native (Android now, iOS later). Native access via
@capacitor/*plugins behind asrc/lib/platform/layer with web fallbacks (Capacitor.isNativePlatform()/Capacitor.getPlatform()). - Navigation: in-memory tab state, no URL router. Wire the Android hardware back button to tab state.
src/lib/domain/— framework-agnostic, typed, tested: state model, money math, currency/format, crypto,SyncBackendinterface + implementations.src/lib/stores/— Svelte stores wrapping domain state + persistence (autosave).src/lib/platform/— Capacitor bridges (storage, share, filesystem, biometric, notifications, secure storage) with web fallbacks.src/lib/components/— reusable UI.src/views/— the tab screens.reference/index.html— original app (parity oracle; do not delete).reference/ledger-testdata.json— the test fixture. Use it. Synthetic, safe to commit, and it exercises every feature: schema v9, ₱/PHP, 6 accounts (incl. a negative-opening credit card), 157 transactions (116 expense / 18 income / 23 transfer, 6 of them with a fee), 11 templates (5 recurring · 6 shortcuts, 2 variable-amount, all three types), 4 goals (one account-less), 5 IOUs both directions, and both debt systems — the tag-based People flow (13 tagged transactions acrossowes_me,their_money,paying_me,paying_back, plussidsettlement records) and the legacy loan system (one unsettledlent, one settledborrowed), which render together on People as· old loan. Load it through Settings → Restore from backup before checking any screen — an empty ledger renders empty states and hides nearly every parity bug. Extend this file when a case is missing rather than inventing throwaway data. Two properties to keep rather than "tidy up": 9 transactions are dated after today, so they land in this month's totals — whether that was intended when the fixture was generated is unknown, but it is kept on purpose as future-dated-entry coverage. Template-logged rows carrytpl; settlement rows carrysidwith an emptyaccount.www/— what ships today (Phase 1–2): the app plus the native shell.www/index.htmlis editable source and has now diverged fromreference/index.html— the oracle stays frozen, so diff before assuming parity. The differences are the Phase 1 shell hooks (viewport-fit=cover, thenative.csslink, the two shell<script>tags) plus the Phase 2 cleanups: nowindow.storageadapter, no service-worker registration, no CSV import, and a softer backup nag.www/native.css(safe areas) andwww/native.js(WebView capability probe, system bars, back button) are the shell;www/capacitor.jsis staged fromnode_modulesbynpm run prepare:wwwand is gitignored.www/sw.jsis a self-destructing kill switch, not a service worker — it exists only to clear the cache the old worker left on devices that installed an earlier APK, since those still serve a staleindex.htmlcache-first. Keep it until every install has been through it; the real web-build SW lives atreference/sw.js. Phase 3 replaces all of it whenwebDirflips todist/— 🛑 butsw.jsmust keep being served from the app root through that flip.public/does not contain it today, so a naive flip drops it, and a P1-era device then gets a 404 forsw.js, which makes its old worker stay registered and serve the stale cache permanently. The Huawei is being held on a P1 build precisely to test that path.- Token invariant: the status-bar colour has a native half —
android/app/src/main/res/values{,-night}/colors.xmldefinesledgerPaper, which must stay in step with the--paper/ dark--papertokens. Below Android API 35 the system paints the bars from these resources;www/native.jsthen overrides at runtime so the bar follows the app's theme rather than the device's.
The binding constraint is the WebView (Chromium ≥ 105), not the Android version —
WebView updates via the Play Store independently of the OS. :has() and the independent
translate: property set that bar, and translate: is load-bearing (it centres and
animates .toast). Declared/tested floor: Android 9 (API 28), verified on a Huawei
running Chrome 138. minSdkVersion stays 24 — Android 7–8 are "probably fine,
untested", not supported. Phase 2 added the runtime capability probe for exactly these two
properties at the top of www/native.js; under-spec WebViews get a plain banner rather
than a subtly broken layout. See MIGRATION_PLAN.md → Locked decisions for the
rationale.
Testing the reject path: keep an AOSP Android 10 (API 29) AVD around — its
com.android.webview is pinned at 74 and there is no Play Store, so it cannot update
itself above the floor (a Google Play image would). It is the only way to see the failure
branch, and it makes the breakage visible: flex gap is absent, so labels run together.
It is not a supported target — its job is proving under-spec users get the banner. In
P3, keep the probe in its own ES5 <script> outside the Vite bundle, or a SyntaxError
in modern bundle output will white-screen the page before the banner can render.
The web build has no probe — it lives only in www/native.js, so an old browser still
gets the silent breakage, which quietly undercuts the "excluded users still have the web
build" rationale. Add it to Vite's index.html in P3; never retrofit the frozen oracle.
:has() needs Safari 15.4, and on iOS every browser is Safari's engine.
Accounts · Transactions (income / expense / transfer + fee) · Loans (lent/borrowed,
partial settle) · Templates (recurring + shortcuts, fixed/variable amount) · Goals ·
IOUs · Settings. Backup schema is version 9 (see the original backupText). Keep
restore backward-compatible.
Install once before scaffolding (Phase 0). Node covers the web/Svelte/Capacitor side; Android Studio covers the native Android build.
- Node.js LTS (20+) + npm — Vite, Svelte, Capacitor CLI, web build (a version manager
like
nvm/fnmis convenient). - Git.
- Android Studio — bundles the Android SDK, build-tools, platform-tools (
adb), a compatible JDK 21 (its JetBrains Runtime), and the emulator; it also works with Capacitor's Gradle wrapper (./gradlewfetches Gradle itself — no separate Gradle or JDK install needed). - After installing Studio:
- SDK Manager → an SDK Platform (API 34/35), Build-Tools, Platform-Tools, Command-line Tools.
- Accept licenses:
sdkmanager --licenses. - Env vars (for CLI builds /
adb):ANDROID_HOME→ SDK path; addplatform-toolstoPATH;JAVA_HOME→ a JDK 21 (can point at Studio's bundledjbr). Building only via Studio's UI needs noJAVA_HOME. - A physical device (USB debugging on) or an emulator AVD to run on.
- Verify:
npx cap doctor, thennpx cap open androidshould build a debug APK. - iOS (later, not now): macOS + Xcode + CocoaPods — Mac-only (or a cloud-Mac CI).
- Physical device: install the Google USB Driver (SDK Manager → SDK Tools) and enable USB debugging; some OEMs need their own driver too.
- Long paths: deep
node_modules/Gradle paths can exceed the legacyMAX_PATH— enable Win32 long paths (registryLongPathsEnabled=1or Group Policy) andgit config --global core.longpaths true. - Env vars: set
ANDROID_HOME/JAVA_HOMEvia System Properties → Environment Variables (orsetx); restart the terminal afterward. - Emulator speed: enable hardware acceleration (WHPX/Hyper-V or the Android Emulator hypervisor driver). Excluding the project + SDK folders from antivirus noticeably speeds up Gradle.
- Xcode Command Line Tools (
xcode-select --install) for the git/build toolchain; Homebrew for Node. Apple Silicon is fine. (Full Xcode + CocoaPods only when adding iOS.)
- Emulator: enable KVM and add your user to the
kvmgroup for usable speed. - Physical device: add udev rules (
51-android.rules) soadbdetects it.
- Dev server:
npm run dev - Tests:
npm run test(Vitest, run mode) /npm run test:watch. Wired in P3a with the domain modules. The static + test gate isnpm run gate(checkthentest).npm run check(svelte-check + tsc) remains the static-only gate. CI wired in P3e:.github/workflows/ci.ymlrunsnpm run gateon push/PR todevelop(+production). The golden-master fixture (src/lib/domain/__golden__/oracle.json) is captured from the oracle byTZ=UTC node scripts/capture-golden.mjs(jsdom; regenerate only if the oracle or the domain's expected outputs change — the tests run under UTC to match it). - Build:
npm run build→dist/(base/, for native/dev);npm run build:web→dist/(base/ledger/, for the Pages deploy). Both stagepublic/capacitor.jsviaprepare-public. - Sync native:
npm run cap:sync— buildsdist/thencap sync(webDir is nowdist). - Android:
npm run android(sync + open Studio), or./gradlew assembleDebuginsideandroid/→android/app/build/outputs/apk/debug/app-debug.apk - iOS (later, needs macOS/Xcode):
npx cap open ios - App ID:
io.friendsnone.ledger(permanent; no domain purchase needed). - Capacitor
webDir:dist(flipped in P3e; waswwwin Phase 1–2).www/is frozen, pending P4 retirement. - Web deploy: GitHub Pages via Actions (builds
dist/); Vitebase: '/ledger/'(served atfriendsnone.github.io/ledger/). No SPA 404 fallback — nav is tab state.
- Branches:
legacy— archived pre-migration history (the old single-file PWA). Reference only; never build on it. Unrelated history todevelopby design.develop— default / integration branch; day-to-day work lands here.production— stable release snapshots; GitHub Pages deploys from pushes here (an Action buildsdist/). Promote a build by mergingdevelop→production.- Feature work → short-lived branches off
develop, merged back via PR.
- Commits: Conventional Commits (
feat:,fix:,docs:,refactor:,test:,chore:) — matches the original app'sfeat/fixhistory. Keep messages descriptive. - History: don't rewrite published history on
develop/production; rebase only local feature branches before merging.
Nothing has shipped yet, and that is deliberate. First ship is after P4. P1 and P2 are technically shippable; we are choosing not to. Until then the legacy single-file PWA stays hosted and is what real users are on, and the native build's whole audience is the dev devices. This is what makes the accepted carve-outs tolerable — GitHub sync's push does not land, and the native build cannot export data at all (backup download and CSV export both produce no file). Both must be closed by P4, because first ship is when they stop being tolerable. Until then, do not keep a real ledger in the native build. See
MIGRATION_PLAN.md→ Locked decisions → When we first ship.
- P0 Alignment & docs
- P1 Ship literal copy in Capacitor —
www/copy + native shell + debug APK. Verified on device: screen-by-screen parity vs the oracle, offline launch (airplane mode), data surviving recents-swipe / force-stop / reboot, the whole back-button chain, restore-from-backup, autocomplete with the soft keyboard up, native date/time pickers, privacy blur, theme switching, and system bars + safe areas across four devices spanning API 28–37 (matrix inMIGRATION_PLAN.md→ Verification). Two accepted carve-outs, both deferred on purpose: 🔴 GitHub sync broken — pre-existing, push does not land; leave it off, dies in P4a. 🔴 Backup download & CSV export produce no file on native — a real regression vs the web build; needs P4's native share/save. Until then the native build has no way to export data, so don't keep a real ledger in it. - P2 Cleanup — dropped the
window.storageshim, the native-build service worker (www/sw.jsis now a kill switch for stale caches on older installs; the web build keepsreference/sw.js), and CSV import (CSV export stays). Softened the backup nag: first at 25 records, repeat after 30 days or 50 changes, and "Not now" is a real persisted 14-day snooze instead of a flag reset on every launch. Added the WebView capability probe innative.js—translate:+:has(), with a plain banner instead of a silently broken layout. Verified in the web parity harness (7 of 8 screens byte-identical to the oracle, Settings differing only by the removed Import CSV button and its reworded hint), on both emulators, and — decisively — on the realme, which was carrying a genuine P1-era install rather than a staged one.adb install -rwithout uninstalling leftcaches [], no registration, an uncontrolled page and the P2 UI, with every record and setting preserved andnagSnoozethe only key added. Offline launch was re-verified in airplane mode with zero caches — the APK, not the SW, is what makes native offline. - P3 Adapt to TS + Svelte, in five gated sub-phases (split to stop the
logic-extraction and view-rebuild from drifting the oracle when done at once — see
MIGRATION_PLAN.md→ Phase 3): P3a extract the pure domain to typed Vitest-covered modules (golden-master vs the oracle); P3b tokens + reusable primitives, checked in isolation; P3c static screens, visual/structural parity before any behaviour; P3d wire the trusted domain in, functional parity 100%; P3e asset swap +webDirflip, carrying the three P2 gates (sw.js intodist/root, probe outside the bundle as ES5, probe on the web build). Renderers go straight to Svelte — never an interim TS renderer.- ✅ P3a done. Pure domain extracted to
src/lib/domain/(types,money,date,currency,crypto,backup,derive,statereducers + Undo,sync) — ported near-verbatim, takingstate/slices as params, no DOM. Vitest + jsdom installed;npm run test/npm run gatewired. 79 tests green, incl. a golden-master (golden.test.ts) that reproduces the oracle over the full fixture exactly: net worth, per-account balances, monthly totals, People balances + the partial-settle FIFO, transfers staying.neu, template due/overdue, schema-v9backupTextbyte-identical, and opening a reference-sealed crypto blob (AES-GCM/PBKDF2 250k unchanged). One intentional divergence logged inPARITY_NOTES.md:backupDueuses the softened P2 thresholds (matcheswww, not the frozen oracle). Thewwwbuild still ships unchanged. Golden regenerated byTZ=UTC node scripts/capture-golden.mjs. - ✅ P3b done. Design tokens live once in
src/lib/styles/tokens.css(:root+[data-theme="dark"]verbatim from the oracle + minimal base/reset +.num/.pos/.neg/.neu+.priv-*masking); every other style is component-scoped. 22 reusable primitives insrc/lib/components/(barrelindex.ts):Icon/IconSprite,Button,IconButton,RowIconButton,Act,Seg,Card,Field,Input,Select,Dot,Row,Badge,SnapRow,Pills,Hero,Nag,Toast,Modal,ConfirmModal,Fab— each reproduces the oracle's CSS declaration-for-declaration and owns its scoped styles. Verified in the dev-only isolation harness (src/lib/dev/Harness.svelte): computed heights/sizes/tones, the load-bearing.toasttranslate:, and privacy masking match the oracle ≤480 then >780, light then dark;npm run checkclean, 79 domain tests still green. Two best-of (a11y) deviations logged inPARITY_NOTES.md. Icons stay the inline sprite and fonts are unpackaged — both swap in P3e.webDirstillwww. - ✅ P3c done. The global shell (
src/App.svelte+src/lib/shell/: header, tab bar with the ≤780 bottom-dock, footer, back-to-top, FAB, toast, sprite, modal/nag slots) and all 8 static screens undersrc/views/(shared parts insrc/views/parts/), composed from the P3b kit + the trusted P3a derivations via a dev session (src/lib/dev/session.svelte.ts, fixture → state); view helpers insrc/lib/view/(format/range/trends/help). Every screen is an EXACT match to the oracle — tag+class signature and#appinnerText, zero diffs on the full fixture — plus mobile/desktop screenshot parity.npm run gateclean (79 tests). Three within-parity primitive notes (Trends SVG charts via{@html};<Seg>optional icon;<Select>unkeyed) inPARITY_NOTES.md. Fonts + icons still P3e. Behaviour/wiring is P3d; the P3bHarness.svelteis superseded but kept.webDirstillwww. - ✅ P3d done. The dev session was replaced by a real store layer:
src/lib/platform/ store.ts(localStorage→IndexedDB→memory, sameKkeys aswww) +src/lib/stores/(ledger.svelte.tsdata core/settings/ephemeral UI as$state;commit()= snapshot → reducer → autosave → Undo toast; theme/privacy/nav/backup helpers; plustoast,modal,actions,syncstores). All 8 screens + parts wired (data-*→onclick→actions.ts); 6 form modals + 4 sync modals undersrc/lib/modals/behind aModalHost; view adaptersview/{form,entry,template,statement,backup,autocomplete}.tsadded (the last incl. the P3a-deferredrankByUse/acSource, wired via ause:autocompleteaction), P3cview/{format,range,trends,help}kept (trends/range parameterised by live period). Orphans deleted (dev/session.svelte.ts,dev/Harness.svelte,parts/EntryForm.svelte); DEV-onlydev/seed.ts(dynamically imported, out of the prod bundle) seeds the fixture.npm run gateclean (0 errors/warnings, 84 tests). Same-origin harness: 7/8 screens exact match (structure +#apptext) to the oracle; Settings differs by exactly the intended CSV-import drop (trackingwww, not the frozen oracle — likebackupDue). Functional checks through the store: log+Undo, edit-account modal, People partial-settle (Jonah 1300−500=₱800), Track filter/statement, add-account, currency, quick-add field-swap, privacy, collapsible-card persistence, autocomplete (where/person ranked suggestions) — all correct. Sync is wired end-to-end (setup/reset/rename/conflict modals, connected card, disconnect, manual sync, header button). Only the GitHub backend + 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, so Drive slots in behind the same interface. The durable core is CI-tested end-to-end against a fake in-memory backend (domain/sync/roundtrip.test.ts: push→pull decrypt, sha-guard CONFLICT, both-changed→merge, clash byprefer, wrong-passphrase). GitHub's push bug is still not chased (auto-push off); only the live round-trip against a real repo is untestable here. Known minor: in-progress form drafts are screen-local (persistent view state persists viaui). Details inPARITY_NOTES.md→ P3d. Fonts + icons +webDirflip still P3e. - ✅ P3e done — Phase 3 complete. Asset swap + build unification + the
webDir→distcutover, verified in the browser and on-device (all gates passed). Fonts: base64 →@fontsourceper-weight imports (main.ts; weights within 400/600/700, Fraunces 600 only) + atrim-fontsourceVite plugin that drops the non-latin subsets and legacywofffrom Fontsource's weight CSS → 14 woff2 / 220 KB, keepinglatin+latin-ext(where ₱ / U+20B1 lives) withunicode-rangeintact (Fontsource's recommended model;woffdrop matches upstream #1068). Icons: inline sprite → tree-shakenphosphor-svelte(Icon.sveltemaps 21 names;IconSpritedeleted) — glyphs identical (sync → ArrowsClockwise), markup differs (<path>vs<use>), a best-of #5 delivery change. Native shell into the Vite build:index.htmlgainsviewport-fit=cover+ classic/capacitor.js+/native.js;public/native.jsis the successor towww/native.js(reworded platform-neutral ES5 probe above#root; modal selector.modal-bg .modal-x);native.cssimported after tokens;capacitor.jsstaged byprepare-public.mjs.www/*left frozen (P4 retirement). Three P2 gates:sw.jsatdist/root (public/sw.js); web build now runs the probe; probe stays ES5 & outside the bundle. Build: Vitebasemode-based ('/'native/dev vs'/ledger/'fornpm run build:web). Harness ondist/: 7/8 exact, Settings = the CSV-import drop; light/dark match; gate clean (84 tests). ✅ On-device: all cutover gates PASSED (3 AVDs + real Huawei via adb/CDP) — AOSP-10/WV74 probe reject-path (banner renders; fixed it to insert immediately, not atDOMContentLoaded, else a doomed-bundle cold start flashed white); modern boot/render (WV124/150, read old P1/P2localStorageunchanged); airplane-mode offline; and the decisive Huawei P1→P3 in-place upgrade (install -r: stale SW +ledger-v101cache cleared, P3 UI, settings preserved — Huawei now on P3). Fonts ₱/currency (kept as-is): JetBrains Mono (and the oracle's inline copy) lack₹ ₱ ₩ ฿ ₺→ system-monospace fallback, device-dependent at oracle parity (renders on the real Huawei); check coverage with fontkit, notdocument.fonts.check(unreliable for monospace). CI/deploy added:.github/workflows/ci.yml(gate) +deploy.yml(Pages, dormant — production-only + Pages Source still on the legacy branch). P4 cleanup: retirewww/+sw.js, flip Pages Source at ship. Details inPARITY_NOTES.md→ P3e.
- ✅ P3a done. Pure domain extracted to
- P4 Native → FIRST SHIP (gated sub-phases; native-first via Capacitor plugins;
replace/improve existing features, keep the feature set + identity — native mechanisms may
differ — no new features): P4a Google Drive sync (replaces GitHub sync;
CapacitorHttp) + Keystore for sync secrets; P4b native file ops —@capacitor/share+filesystem+ picker (fixes the dead export); P4c hardening (storage→@capacitor/filesystem, web fallback = IndexedDB, + migration; retirewww/+sw.js; status-bar polish); P4d exhaustive back-to-front check vs the reference across all devices + web, then cut over (Pages Source→Actions, develop→production, version bump). P4a/P4b close the P1/P2 carve-outs (broken sync, no export); first ship is P4d — a seamless successor to the legacy PWA (feature-complete + identity-preserving; native improvements welcome). - P5 New features (after first ship — additive): biometric/PIN lock, bill
notifications, haptics, widget/QS tile, extra sync backends (Dropbox/WebDAV), "Import
from…", iOS build. See
MIGRATION_PLAN.md→ backlog.
- Dropped:
window.storageClaude-artifact adapter; service worker in the native build (kept for the web build); CSV import. - Kept: CSV export.
- GitHub sync ships in v1 → replaced by Google Drive sync (native Google Sign-In,
drive.appdata, no backend), then retired. - Asset delivery: base64-inline fonts + inline SVG icon sprite → Vite-packaged local
fonts (Hanken Grotesk / Fraunces / JetBrains Mono, weights 400/600/700 only) +
tree-shaken Phosphor icons. Styling: scoped CSS + tokens, no utility framework
(Tailwind/UnoCSS evaluated and set aside); each primitive owns its scoped styles
(
.btnin<Button>). SeeDESIGN.md.
- DO port screen-by-screen with parity checks against
reference/index.html, followingMIGRATION_PLAN.md→## Parity method(functional = 100% exact; visual defaults to the oracle, deviate only as whitelisted "best-of" and log it inPARITY_NOTES.md; check each screen mobile then desktop before the next). - DO keep Undo on every mutation and the privacy-blur behavior.
- DON'T add a backend, accounts, analytics, or external CDN/fonts (stay CSP-safe, offline).
- DON'T break the Android back button, safe areas, or offline launch.
MIGRATION_PLAN.md— phased roadmap, decisions, feature triage, verificationDESIGN.md— visual system & platform-adaptation rulesreference/index.html— original app (parity oracle)