Neither page had og:image/twitter:image tags, so link previews (iMessage,
Slack, Discord, etc.) fell back to scraping a screenshot off the page.
Point both at the app icon instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Skiko's Skia-via-WebAssembly runtime is loaded through a dynamic
import() inside webApp.js, so the browser previously only discovered
those multi-MB .wasm files after fully downloading and executing the
JS bundle. Inject <link rel="preload"> hints for the content-hashed
.wasm output into index.html after each browser distribution build so
the WASM fetch starts in parallel with the JS instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The GitHub Pages workflow now builds the Compose wasmJs web app and
publishes it alongside the marketing site, so it's reachable at
/app/. Add nav/hero/download links to it and list Web among the
supported platforms. Add website/serve-local.sh to reproduce the
production layout locally, and document the setup in README/AGENTS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Static, dark-themed landing page showcasing the app's capabilities with
real screenshots for Android, iOS, iPad, and macOS, plus download links
for all supported distribution channels.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AejmWBrdE3DbJWbXoaE3aX
F-Droid's RFP scanner flagged the repo for missing Fastlane metadata
(https://gitlab.com/fdroid/rfp/-/work_items/4395#note_3827250669):
without it, F-Droid can't show app screenshots and description edits
require going through the F-Droid team directly.
Screenshots are symlinked from docs/assets/screenshots/android/ rather
than duplicated, keeping one source of truth. Also drops the stock
fastlane .gitignore rule that was silently excluding these PNGs --
unlike Play Store screenshots, F-Droid reads them straight from the
repo with no separate regeneration step, so they're meant to be
committed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Auto-downloads a JDK toolchain over the network when the required
version isn't installed locally, which conflicts with F-Droid's
offline/reproducible build requirements and was flagged by their RFP
scanner (https://gitlab.com/fdroid/rfp/-/work_items/4395#note_3827250669)
as a network-access risk. Confirmed unused: no module in this project
declares an explicit jvmToolchain, so it was inert KMP-wizard
scaffolding to begin with.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
The module-layout bullets were still generic KMP-wizard boilerplate
("your project", a hypothetical CoreCrypto example never used here)
and omitted macOS, androidApp, desktopApp, and webApp entirely.
Replaces them with an accurate description of the actual layout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
NumericField's .decimalPad/.numberPad keyboards have no Return/Done
key of their own, and there was previously no way to dismiss them
once shown. Tried .scrollDismissesKeyboard(.interactively) alone
first, but it's unreliable in a Form: sections without .onMove/
.onDelete (Price Source, Bitcoin) don't consistently wire up the
drag-to-dismiss gesture, so it only worked when dragging within the
Currencies section. Adds a "Done" button to the keyboard toolbar as a
deterministic fallback that works from any field, keeping the scroll
dismissal too since it's a free bonus where it does work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
localizedGroupedInteger() routed the digit string through
NSDecimalNumber before formatting, which has a ~38-significant-digit
precision ceiling — past that, it silently replaced the excess
trailing digits with zeros. Since every field re-groups on each edit,
a Sats amount typed past ~38 digits kept re-corrupting through this on
every keystroke, visibly changing without the user typing anything new.
Groups the digit string directly instead, using only the locale's
grouping metadata (separator + primary/secondary group sizes) — pure
string manipulation with no numeric precision limit. Verified against
NSNumberFormatter's own output across multiple locales (including
hi-IN's irregular "12,34,567" grouping) and added a regression test
for a 60-digit input.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
NumericField's macOS-only frame was hard-capped at 140pt, but the
app's own max BTC supply (21,000,000) means Sats amounts can reach a
~21-character grouped string, which didn't fit — the field visibly
clipped once typed input exceeded the cap. Lets it grow up to 220pt
instead of a fixed 140pt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
AppIcon.appiconset only defined an iOS "universal" 1024x1024 entry, so
the macOS build had no mac-idiom icon at all — App Store Connect
rejected uploads for missing the required 512pt@2x (1024x1024) image.
Generates the full mac icon set (16/32/128/256/512pt at 1x/2x) from
the existing 1024px source and wires it into Contents.json.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Web actually persists via localStorage (LocalStorageStores.kt), which
survives page reloads — only SQLDelight itself isn't used there, since
its web driver needs a worker plus a wasm sqlite binary. The doc
comments and AGENTS.md still described the in-memory state this
replaced.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Desktop and Web are real future release targets, not permanent
dev-only/preview builds — they just lack a release/CI pipeline yet.
desktopApp already has full DMG/MSI/DEB packaging configured, and
webApp has production-build groundwork in place.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Drop the "developed on kmp, not main" framing now that kmp is merging
into main, which becomes the sole Kotlin Multiplatform implementation.
Keeps the warning about stale pre-rewrite branches (e.g. update-readme)
still describing the old Skip/SPM workflow.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
README: drop the stale Skip attribution line (the project moved to
Kotlin Multiplatform entirely; the "Kotlin Multiplatform" line below
it already covers the current tooling) and fix the stated minimum
Android version — androidApp's actual minSdk is 24 (Android 7.0), not
10.0.
AGENTS.md: a navigation/orientation doc for AI agents, focused on the
things that aren't obvious from reading the code or that took real
trial-and-error to establish this session — the localization
architecture constraint (no synchronous, cross-platform way to
resolve a moko-resources string outside Compose/Apple, so shared code
must return data, not sentences), the Swift-interop plain-function
convention, per-platform currency-data quirks (web's ICU-dependent
currency list, missing color-emoji font), and the verification
workflow for each platform (especially that Compose for Web renders
to a <canvas>, so headless Chrome + specific GL flags is the way to
actually see a change, not DOM inspection).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Was above the card, right under the "SatsPrice" title; now it's the
last item inside the card's content, below "USD to Sats" — matching
where iOS's Section footer renders it. Muted with onSurfaceVariant to
read as a footer-style caption rather than primary content.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
statusLine() used to build the whole "via Coinbase, updated ..."
string in shared code with hardcoded English literals ("updated",
"loading rates…", "via ") — not localizable, since this shared code
has no Composable/moko-resources context to resolve a string from,
and said more than needed (the source is already shown in the Price
Source picker right above it).
Replaced with lastUpdatedDateTime(): String?, which returns only the
locale-formatted date/time (still shared, since that part is genuine
platform-native locale formatting, not translated text) — null for
Manual, or while the first fetch hasn't completed. Each UI layer now
builds the localized "Updated %1$s" / "Loading rates…" text itself
via stringResource()/IosLocalizationKt around that value, using two
new string resources.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
New "Reset" button in the currency picker's top bar (next to Done),
shown only when more than the pinned default currency is selected.
Tapping it asks for confirmation before removing every other
selected currency, via PriceViewModel.onSelectedCurrenciesReset() —
same recompute/persist pattern as onFiatCurrencyToggled.
Compose uses an AlertDialog; iOS/macOS uses .alert with a destructive
Reset action, matching the destructive-role toolbar button that
triggers it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
The "Currencies" section (not-yet-added currencies) is now two:
"Priced Currencies" and "Unpriced Currencies", partitioned by
whether the active source quotes a rate for each one. "Current
Currency" and "Selected Currencies" are unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Pulled the complete "Historical currency codes" table from
https://en.wikipedia.org/wiki/ISO_4217#Historical_codes and added
every code not already covered — ~85 more entries (Argentine austral,
Brazilian cruzeiro/cruzado through its several redenominations,
Yugoslav dinar variants, Bulgarian lev's earlier revisions, settlement/
funds-only codes like XEU/XFO/XFU/XRE, and more).
Cross-checked the full 127-entry list against the JVM's live-derived
"currently active" set to make sure nothing genuinely active got
added by mistake — clean, except for one deliberate exception:
Wikipedia lists "BGN" (a fourth, euro-replaced Bulgarian lev) as
historical, but the JVM still resolves BGN as Bulgaria's current
currency, so it's left out to stay consistent with what this app's
own JVM/Android path actually does — documented inline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
CLDR appends a "(1927–2002)" or "(2009–2024)" style validity-period
suffix to a historical/superseded currency's display name — a
broader, data-driven signal than the hand-maintained
WITHDRAWN_CURRENCY_CODES list, catching ones not added there yet
(confirmed AFA, ALK, AOK, AON, AOR, ROL, TRL, RUR, and others all get
this treatment). Not a full replacement for the list though: some
withdrawn currencies (pre-euro ones especially, e.g. "German Mark"
for DEM) carry no such hint in every ICU dataset.
Verified with Node against the real Intl.DisplayNames output: matches
every year-annotated name actually observed, and produces zero false
positives across all 162 currently-formattable codes (Node's
Intl.supportedValuesOf('currency') set) — the only two matches were
SLL and ZWL, which are already known-withdrawn.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Reverts the allowlist approach back to WITHDRAWN_CURRENCY_CODES per
request, and adds the codes actually reported: AFA, ALK, AOK, AON,
AOR, plus the full set of pre-euro legacy currencies (ATS, BEF, DEM,
FRF, ITL, ESP, GRD, PTE, NLG, IEP, and the rest), which are exactly
the kind of long-withdrawn code an ICU currency dataset would still
carry.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
The previous WITHDRAWN_CURRENCY_CODES exclusion list only covered the
5 retired codes I'd found by diffing against the JVM — but which
codes Intl.supportedValuesOf('currency') returns is ICU-version-
dependent, and apparently some browsers' ICU data includes currencies
retired decades ago (AFA, ALK, AOK, AON, AOR, and evidently more)
alongside current ones. An exclusion list can't keep up with an
unbounded, browser-dependent set of historical codes.
Replaced with ACTIVE_CURRENCY_CODES, an allowlist taken directly from
the JVM's own live-derived "currently used" set (verified against a
real JVM run, 155 codes) plus PRECIOUS_METAL_CURRENCY_CODES. This
naturally excludes every retired code regardless of what a given
browser's Intl data happens to expose, rather than needing to know
about each one in advance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Intl.supportedValuesOf('currency') includes several codes for
currencies that have actually been withdrawn/superseded — unlike
JVM/Android/Apple, which derive "currently used" from each ISO
country's live-assigned currency (java.util.Currency/NSLocale), the
web platform has no equivalent API, so this app was showing them
unfiltered.
Confirmed exactly which codes by diffing the web list against the
JVM's own live-derived set: ANG, CUC, HRK, SLL, and ZWL are genuinely
withdrawn/superseded national currencies, now excluded via a manually
maintained list (the web platform's only option here). XDR and XSU
were also missing from the JVM's set, but only because they aren't
tied to any ISO country — not because they're withdrawn — so those
stay, same reasoning this codebase already applies to precious
metals.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
jsCurrencyDisplayName() was hardcoding 'en' for Intl.DisplayNames,
so every currency name shown on web (USD, JPY, ...) stayed English
regardless of the user's locale — same class of bug as the "EU"
string, just for every currency rather than one region. Switched to
undefined, matching regionDisplayName()'s fix; verified with Node
that this actually produces locale-appropriate names (e.g. "US
Dollar" / "dollar des États-Unis" / "米ドル").
Left jsCurrencyFractionDigits()'s 'en' alone and documented why:
verified with Node that a currency's decimal-digit count doesn't
vary by locale, so locale there is just a required constructor
argument, not something the result depends on.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Verified directly (java.util.Locale, NSLocale, and Intl.DisplayNames
all tested outside the app) that every platform's locale data resolves
"EU" correctly on its own — "European Union" in en, "Union européenne"
in fr, "Europäische Union" in de, etc. — via CLDR, which defines "EU"
as a grouping in its own right despite it not being a real ISO 3166-1
country code. The hardcoded English fallback was based on an
assumption that was never actually verified.
Removes the special case from the shared code entirely. That required
two platform-side fixes to stop blocking "EU" from ever reaching the
platform lookup:
- JVM/Android: dropped the Locale.getISOCountries() membership guard
(which excluded "EU") in favor of detecting an unresolvable code by
getDisplayCountry() echoing it back unchanged.
- Web: jsRegionDisplayName() was hardcoding locale 'en' for
Intl.DisplayNames — changed to undefined (the browser's own locale),
matching what the other platforms already did. Without this fix,
"EU" would resolve, but only ever in English on web.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Previously the cache only filled lazily on first search use, so the
very first character typed into currency search still paid for every
region code at once. Kicking off warmRegionDisplayNameCache() in its
own coroutine from PriceViewModel's init moves that cost to app
startup, well before the user can reach the search field, without
delaying the ViewModel's own startup sequence (separate launch{}, not
folded into it).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
matchesCurrencySearch() re-derives a country name for every currency
on every keystroke; region codes don't change during a session, so
this was redoing the same locale lookups repeatedly — worst on web,
where each lookup constructs a fresh Intl.DisplayNames instance.
Caches by region code instead, including null results (regions the
platform doesn't recognize), so each code's lookup happens once ever.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
It was using the decimal sanitizer/keyboard, same as the rate field
next to it — but Sats are indivisible, so this one should behave like
the existing Sats amount field: integers only, numeric keypad.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Exceptions from the HTTP client or JSON parsing can carry verbose,
technical text (full response bodies, stack traces) — not meaningful
to a user seeing it as a red error banner.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Replaces the raw "2026-09-10 10:03:54" with each platform's own
locale-formatted, no-seconds rendering (e.g. "Sep 10, 2026, 10:03 AM"
in en-US) — mirroring how amounts are already locale-formatted
elsewhere (NumberFormat.kt). Android uses the pre-java.time
Calendar/DateFormat APIs since minSdk 24 predates java.time (API 26)
and the project has no core library desugaring configured.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
Always render the field now (label included) with just its value
empty when unknown, rather than hiding the whole field.
In Manual source mode it's now a genuine input: typing into it
recomputes the "BTC to currency" rate the same way typing into that
field recomputes this one. Both edit the same underlying
ManualExchangeRateSource.rate via the same self-inverse division
(CurrencyConverter.satsPerCurrencyUnit of a rate gives Sats, and of a
Sats amount gives a rate back), tracked as a new manualSatsPerCurrencyInput
field alongside the existing manualRateInput.
Wired through PriceViewModel, the iOS bridge/ConverterViewModel, and
both UIs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy