Add a Compose HTML (DOM) web app and split Compose UI out of :shared

webHtmlApp is a new web build using Compose HTML instead of Compose
Multiplatform UI's Skia/canvas renderer - real <select>/<input>/<button>
elements, an anchored popup menu for reordering/removing currencies
(Compose HTML has no built-in DropdownMenu), and locale-independent
English strings (moko-resources' Compose integration pulls in
compose.foundation/ui, which this module exists to avoid).

Realizing the bundle-size win needed splitting sharedUi out of shared:
the Compose Multiplatform Gradle plugin bundles Skiko's several-MB web
runtime into any js/wasmJs target whose resolved dependencies contain
org.jetbrains.compose.ui:ui anywhere, and shared previously declared it
directly for the other (Skia-based) web app's UI. shared is now pure
business/data logic with no Compose dependency; sharedUi holds App()/
PriceScreen() for androidApp, desktopApp, and webApp to render.
PriceViewModel's ViewModel supertype and CurrencyConverter's BigDecimal
params moved from implementation() to api() in shared, since they're
part of its public surface that webHtmlApp now touches directly.

Also drops the @js-joda/timezone dependency from both web builds: the
app only ever calls TimeZone.currentSystemDefault(), which resolves to
a synthetic zone backed by the native Date offset on Kotlin/JS and
never touches the tz database - confirmed empirically on both js and
wasmJs targets. Cuts webApp's js bundle by ~950 KB and webHtmlApp's by
~800 KB.

currencyFlagEmoji() gained an explicit supportsFlagEmoji parameter
(defaulting to the existing expect/actual, false on web) so webHtmlApp
can opt in - Compose HTML renders real DOM text, so flag emoji work
fine there even though they don't on Compose Multiplatform's canvas.

website/serve-local.sh now builds and serves webHtmlApp instead of
webApp's wasmJs build, so it no longer mirrors what's actually deployed
to production; docs updated to reflect the new module layout and that
divergence, and the Pages workflow's path trigger now includes
sharedUi/** so UI changes there still redeploy the site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 18:35:23 +03:00
co-authored by Claude Sonnet 5
parent 97e3a34476
commit 188536867d
31 changed files with 970 additions and 72 deletions
+46 -12
View File
@@ -24,10 +24,28 @@ Kotlin) implementation — `main` is now this Kotlin Multiplatform project.
## Module layout
- `shared/` the actual app: UI, view models, data sources, domain logic.
Almost everything happens here.
- `shared/` — view models, data sources, domain logic. No Compose UI
toolkit dependency (deliberately - see `sharedUi/` below). Almost all
business logic happens here.
- `sharedUi/` — the Skia-based Compose Multiplatform UI (`App()`,
`ui/PriceScreen.kt`) that `androidApp`, `desktopApp`, and `webApp`
render. Split out from `shared` so a js/wasmJs consumer that doesn't
want Compose UI's several-MB Skia web runtime (see `webHtmlApp/`) can
depend on `shared` alone without pulling it in transitively - the
Compose Multiplatform Gradle plugin bundles that runtime into *any*
js/wasmJs target whose resolved dependencies contain
`org.jetbrains.compose.ui:ui` anywhere.
- `androidApp/`, `desktopApp/`, `webApp/` — thin launcher shells around
`shared`'s Compose UI. Rarely need changes.
`sharedUi`'s Compose UI. Rarely need changes.
- `webHtmlApp/` — an alternative web build rendering to real DOM via
Compose HTML instead of Compose Multiplatform UI's canvas/Skia. Depends
on `shared` directly, not `sharedUi`: Compose HTML and Compose
Multiplatform UI are different, incompatible composable sets, so its
screens (`HtmlApp.kt`, `HtmlCurrencyPicker.kt`) are a separate
hand-written port of `sharedUi/ui/PriceScreen.kt` rather than a shared
one. Not what's deployed to production (`webApp`'s wasmJs build is);
exists for comparing the two approaches and via
`website/serve-local.sh`.
- `iosApp/` — a native SwiftUI app (shared across iOS and macOS via
`#if os(macOS)`), **not** Compose. It talks to `shared` through a
hand-written bridge (see "Core architecture" below), not by rendering
@@ -53,8 +71,10 @@ unused KMP-wizard scaffolding, not load-bearing.
(`ConverterUiState.xyz()` extension functions) shared between Compose and
the iOS bridge. **Read the localization note below before adding
anything here that produces user-facing text.**
- `ui/PriceScreen.kt` — the actual Compose UI. Used as-is by Android,
Desktop, and Web (same composable, three renderers).
- `sharedUi/src/commonMain/.../ui/PriceScreen.kt` — the actual Compose UI.
Used as-is by Android, Desktop, and `webApp` (same composable, three
renderers). `webHtmlApp` has its own separate Compose HTML port of this
screen instead (see "Module layout" above).
- iOS/macOS **doesn't** use Compose. The chain is:
`PriceViewModel` (Kotlin) → `IosPriceViewModel`/`IosConverterState`
(`shared/src/appleMain/.../IosPriceViewModel.kt`, a flattened Map-free
@@ -92,6 +112,13 @@ the pattern to copy: it returns a locale-formatted `String?` (using
locale-aware formatting via platform APIs, not translated text), and each
UI layer wraps it with its own localized "Updated %1$s" string.
`webHtmlApp` is the one UI layer that deliberately breaks this pattern: its
`Strings.kt` hardcodes English rather than calling `stringResource()`,
because moko-resources' Compose integration (`moko-resourcesCompose`) pulls
in `compose.foundation`/`compose.ui` transitively - exactly the dependency
`webHtmlApp` exists to avoid (see "Module layout" above). Not a template to
follow elsewhere; a one-off tradeoff specific to that module.
## Locale-aware formatting
`domain/NumberFormat.kt` (digit grouping, decimal separator) and
@@ -164,14 +191,17 @@ site (nav, hero, download section) via `/app/`.
- `website/app/` is git-ignored — it's build output, generated fresh by CI
(and locally by the script below), never committed.
- To reproduce the production layout locally (site + web app together,
`/app/` links working): `./website/serve-local.sh [port]`. It builds the
wasmJs distribution, copies it into `website/app/`, and serves
`website/` with `python3 -m http.server`.
- To try the site + web app together locally (`/app/` links working):
`./website/serve-local.sh [port]`. It builds `webHtmlApp`'s Compose
HTML (DOM) distribution, copies it into `website/app/`, and serves
`website/` with `python3 -m http.server` — note this no longer matches
what the Pages workflow actually deploys (still the Skia `webApp`
wasmJs build), so it's for trying the DOM build, not reproducing
production.
- If you change the Pages workflow, remember `paths:` in the trigger
includes `webApp/**`/`shared/**`/Gradle files, not just `website/**`
a shared-code change that affects the web app should also redeploy the
site.
includes `webApp/**`/`shared/**`/`sharedUi/**`/Gradle files, not just
`website/**` a shared-code change that affects the web app should
also redeploy the site.
## Testing and verification
@@ -195,6 +225,10 @@ site (nav, hero, download section) via `/app/`.
`--headless=new --disable-gpu-sandbox --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader --ignore-gpu-blocklist`.
This is also the most practical way to verify a Compose UI change at
all, since there's no headless Android/Desktop runner set up here.
- `webHtmlApp` is the exception to the above: it renders real DOM, so
plain `--headless=new --dump-dom` (no WebGL/GPU flags needed) shows
actual inspectable HTML. Build/serve it with
`./gradlew :webHtmlApp:jsBrowserDistribution` and a static file server.
- Interactive verification of the native macOS build via AppleScript/System
Events GUI scripting works but is genuinely flaky — stale processes can
linger across launches, the accessibility tree doesn't always reflect