Merge pull request #9 from tyiu/compose-html
Deploy website to GitHub Pages / deploy (push) Canceled after 0s

Add Compose HTML UI for web app
This commit is contained in:
2026-09-17 14:10:24 +03:00
committed by GitHub
34 changed files with 1029 additions and 101 deletions
+1
View File
@@ -6,6 +6,7 @@ on:
paths:
- "website/**"
- "shared/**"
- "sharedUi/**"
- "webApp/**"
- "gradle/**"
- "gradlew"
+52 -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,19 @@ 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: it
can't call `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). Its `Strings.kt` is instead **generated** at build
time (`:webHtmlApp:generateStrings`, in `webHtmlApp/build.gradle.kts`) by
parsing `shared/.../moko-resources/base/strings.xml` directly, so that file
stays the single source of truth instead of a manually maintained,
silently driftable duplicate - edit the XML, not the generated file. Only
covers the base (English) locale, since it skips moko's per-locale
resolution entirely. 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 +197,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 +231,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
+6 -4
View File
@@ -71,13 +71,15 @@ options:
- Desktop app:
- Hot reload: `./gradlew :desktopApp:hotRun --auto`
- Standard run: `./gradlew :desktopApp:run`
- Web app:
- Web app (Compose Multiplatform UI, Skia-rendered - what's deployed to production):
- Wasm target (faster, modern browsers): `./gradlew :webApp:wasmJsBrowserDevelopmentRun`
- JS target (slower, supports older browsers): `./gradlew :webApp:jsBrowserDevelopmentRun`
- Web app, DOM alternative (Compose HTML UI, renders real DOM instead of Skia/canvas - not what's
deployed to production): `./gradlew :webHtmlApp:jsBrowserDevelopmentRun`
- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there.
- [Website](./website) (with the web app built and served at `/app/`, matching production):
`./website/serve-local.sh` (serves at http://localhost:8000 by default; pass a port number to
override)
- [Website](./website) with the web app built and served at `/app/` (the `webHtmlApp` DOM build,
not the one production actually deploys): `./website/serve-local.sh` (serves at
http://localhost:8000 by default; pass a port number to override)
### Running tests
+1 -1
View File
@@ -11,7 +11,7 @@ kotlin {
}
}
dependencies {
implementation(project(":shared"))
implementation(project(":sharedUi"))
implementation(libs.androidx.activity.compose)
+1 -1
View File
@@ -7,7 +7,7 @@ plugins {
}
dependencies {
implementation(project(":shared"))
implementation(project(":sharedUi"))
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
+2
View File
@@ -34,6 +34,7 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
@@ -41,6 +42,7 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" }
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-html-core = { module = "org.jetbrains.compose.html:html-core", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
moko-resources = { module = "dev.icerock.moko:resources", version.ref = "mokoResources" }
moko-resourcesCompose = { module = "dev.icerock.moko:resources-compose", version.ref = "mokoResources" }
+1 -2
View File
@@ -274,7 +274,6 @@ struct ContentView: View {
unselectedCurrencies: state.unselectedCurrencies,
pricedCurrencyCodes: state.pricedCurrencyCodes,
sourceName: state.sourceName,
localeCurrencyCode: state.localeCurrencyCode,
selectedCount: state.selectedCurrencyCodes.count,
onToggle: { viewModel.onFiatCurrencyToggled($0) },
onReset: { viewModel.onSelectedCurrenciesReset() }
@@ -404,7 +403,7 @@ private struct NumericField: View {
}
private func currencyFieldLabel(for code: String) -> String {
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code) {
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) {
return "\(flag) \(code)"
}
return code
+5 -14
View File
@@ -7,7 +7,6 @@ struct CurrencyPickerSheet: View {
let unselectedCurrencies: [CurrencyInfo]
let pricedCurrencyCodes: [String]
let sourceName: String
let localeCurrencyCode: String?
let selectedCount: Int
let onToggle: (String) -> Void
let onReset: () -> Void
@@ -142,19 +141,11 @@ struct CurrencyPickerSheet: View {
}
private func currencyLabel(for info: CurrencyInfo) -> String {
let text: String
if info.code == localeCurrencyCode {
text = IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label_local,
args: [info.code, info.displayName]
)
} else {
text = IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label,
args: [info.code, info.displayName]
)
}
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code) {
let text = IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label,
args: [info.code, info.displayName]
)
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) {
return "\(flag) \(text)"
}
return text
-5
View File
@@ -7,11 +7,6 @@
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
"@js-joda/timezone@2.25.1":
version "2.25.1"
resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.25.1.tgz#4628f18d51af1b197a410834d344418f48a8a264"
integrity sha512-s79ts8bXrWqM9dIBKc0AdgGuAUFpu9gmzYhOCPHJlks/Sf7FSbJHRauWlFYUwjSTZevimqthEvJycrwrVz5m4g==
"@messageformat/core@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.1.0.tgz#d4d2f5c3555228a6b5980b122a02b53dfc6458bd"
-5
View File
@@ -63,11 +63,6 @@
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
"@js-joda/timezone@2.25.1":
version "2.25.1"
resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.25.1.tgz#4628f18d51af1b197a410834d344418f48a8a264"
integrity sha512-s79ts8bXrWqM9dIBKc0AdgGuAUFpu9gmzYhOCPHJlks/Sf7FSbJHRauWlFYUwjSTZevimqthEvJycrwrVz5m4g==
"@jsonjoy.com/base64@17.67.0":
version "17.67.0"
resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-17.67.0.tgz#7eeda3cb41138d77a90408fd2e42b2aba10576d7"
+3 -1
View File
@@ -30,4 +30,6 @@ dependencyResolutionManagement {
include(":androidApp")
include(":desktopApp")
include(":shared")
include(":webApp")
include(":sharedUi")
include(":webApp")
include(":webHtmlApp")
+6 -18
View File
@@ -4,8 +4,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.mokoResources)
alias(libs.plugins.sqldelight)
@@ -62,28 +60,22 @@ kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.uiTooling)
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.androidDriver)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.materialIconsExtended)
implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview)
api(libs.moko.resources)
api(libs.moko.resourcesCompose)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
// PriceViewModel's own supertype and CurrencyConverter's BigDecimal parameters are
// part of :shared's public API, so consumers that touch those types directly (e.g.
// webHtmlApp's Compose HTML screens) need them on their own compile classpath too -
// api() (not implementation()) is what makes Gradle expose them transitively.
api(libs.androidx.lifecycle.viewmodel)
api(libs.bignum)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.serialization.kotlinxJson)
implementation(libs.bignum)
implementation(libs.sqldelight.runtime)
}
commonTest.dependencies {
@@ -112,10 +104,6 @@ kotlin {
}
}
dependencies {
androidRuntimeClasspath(libs.compose.uiTooling)
}
multiplatformResources {
resourcesPackage.set("xyz.tyiu.satsprice.shared")
}
@@ -26,7 +26,6 @@ data class IosConverterState(
val unselectedCurrencies: List<CurrencyInfo>,
val selectedCurrencyCodes: List<String>,
val pricedCurrencyCodes: List<String>,
val localeCurrencyCode: String?,
val defaultCurrencyCode: String,
val sourceName: String,
val isManualSource: Boolean,
@@ -81,7 +80,6 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState
unselectedCurrencies = unselectedCurrencies(),
selectedCurrencyCodes = selectedFiatCurrencies,
pricedCurrencyCodes = pricedCurrencyCodes.toList(),
localeCurrencyCode = localeCurrencyCode,
defaultCurrencyCode = defaultCurrencyCode,
sourceName = sourceName,
isManualSource = isManualSource,
@@ -1,8 +0,0 @@
package xyz.tyiu.satsprice.ui
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
// PriceScreen.kt is unused on iOS (native SwiftUI is used instead), but this module still
// needs an actual to satisfy the expect declaration for this target.
actual val screenHorizontalPadding: Dp = 16.dp
@@ -36,11 +36,14 @@ internal fun issuingCountryCodes(code: String): List<String> {
* A country flag emoji for [code], or null when there's no flag to show either because [code]
* isn't tied to any country, because it's shared by more than [MAX_FLAGS_PER_CURRENCY] countries
* (too visually noisy to show side by side), or because [supportsFlagEmoji] says the platform
* can't render one anyway (Compose for Web's canvas-based text renderer has no color-emoji font
* to fall back to, so a flag's regional-indicator codepoints draw as empty boxes there).
* can't render one anyway. [supportsFlagEmoji] defaults to the expect/actual of the same name,
* which is false for Compose Multiplatform's canvas-based Skia renderer on web (no bundled or
* system color-emoji font to fall back to, so a flag's regional-indicator codepoints draw as
* empty boxes there) - callers that render through a real DOM text node instead (e.g. Compose
* HTML) can pass `true` explicitly, since the browser's own font stack handles it fine.
*/
fun currencyFlagEmoji(code: String): String? {
if (!supportsFlagEmoji()) return null
fun currencyFlagEmoji(code: String, supportsFlagEmoji: Boolean = supportsFlagEmoji()): String? {
if (!supportsFlagEmoji) return null
val regionCodes = issuingCountryCodes(code)
if (regionCodes.isEmpty() || regionCodes.size > MAX_FLAGS_PER_CURRENCY) return null
return regionCodes.joinToString(" ") { regionFlagEmoji(it) }
@@ -10,7 +10,6 @@
<string name="current_currency_section_title">Current Currency</string>
<string name="currency_not_priced">Not priced by %1$s</string>
<string name="currency_option_label">%1$s - %2$s</string>
<string name="currency_option_label_local">%1$s - %2$s</string>
<string name="currency_options_content_description">Options for %1$s</string>
<string name="currency_to_sats">%1$s to Sats</string>
<string name="done">Done</string>
+61
View File
@@ -0,0 +1,61 @@
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
// The Skia-based Compose Multiplatform UI (App()/PriceScreen()) that androidApp, desktopApp,
// and webApp render. Split out of :shared so :shared itself stays free of compose.ui - the
// Compose Multiplatform Gradle plugin bundles the several-MB Skiko web runtime into *any*
// js/wasmJs target whose resolved dependency graph contains org.jetbrains.compose.ui:ui
// anywhere, and webHtmlApp's DOM-only build needs to avoid that.
kotlin {
jvm()
js {
browser()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
}
android {
namespace = "xyz.tyiu.satsprice.sharedui"
compileSdk = libs.versions.android.compileSdk.get().toInt()
minSdk = libs.versions.android.minSdk.get().toInt()
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.compose.uiTooling)
}
commonMain.dependencies {
api(project(":shared"))
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.materialIconsExtended)
implementation(libs.compose.ui)
implementation(libs.compose.uiToolingPreview)
implementation(libs.moko.resourcesCompose)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
}
}
}
dependencies {
androidRuntimeClasspath(libs.compose.uiTooling)
}
@@ -605,7 +605,6 @@ private fun CurrencyPickerScreen(
isSelected = true,
isPriced = state.isPriced(currentCurrency.code),
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = null,
)
}
@@ -625,7 +624,6 @@ private fun CurrencyPickerScreen(
isSelected = true,
isPriced = state.isPriced(info.code),
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
@@ -649,7 +647,6 @@ private fun CurrencyPickerScreen(
isSelected = false,
isPriced = true,
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
@@ -671,7 +668,6 @@ private fun CurrencyPickerScreen(
isSelected = false,
isPriced = false,
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
@@ -689,14 +685,9 @@ private fun CurrencyRow(
isSelected: Boolean,
isPriced: Boolean,
sourceName: String,
localeCurrencyCode: String?,
onClick: (() -> Unit)?,
) {
val text = if (info.code == localeCurrencyCode) {
stringResource(MR.strings.currency_option_label_local, info.code, info.displayName)
} else {
stringResource(MR.strings.currency_option_label, info.code, info.displayName)
}
val text = stringResource(MR.strings.currency_option_label, info.code, info.displayName)
val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text
Row(
modifier = Modifier
+1 -3
View File
@@ -21,11 +21,9 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":shared"))
implementation(project(":sharedUi"))
implementation(libs.compose.ui)
implementation(npm("@js-joda/timezone", "2.25.1"))
}
}
}
@@ -3,15 +3,9 @@ package xyz.tyiu.satsprice
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
@OptIn(ExperimentalWasmJsInterop::class)
@JsModule("@js-joda/timezone")
external object JsJodaTimeZoneModule
private val jsJodaTz = JsJodaTimeZoneModule
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport {
App()
}
}
}
+111
View File
@@ -0,0 +1,111 @@
import org.w3c.dom.Element
import javax.xml.parsers.DocumentBuilderFactory
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
// webHtmlApp can't use moko-resources' Compose integration (stringResource()) without pulling
// in compose.foundation/ui transitively - exactly what this module exists to avoid (see
// AGENTS.md's "Module layout"). Generating a plain Strings.kt from the same strings.xml moko
// reads keeps that file as the single source of truth without a manually maintained, silently
// driftable duplicate. Only covers the base (English) locale - see AGENTS.md.
val stringsXmlFile = rootProject.file("shared/src/commonMain/moko-resources/base/strings.xml")
val generateStrings by tasks.registering {
val outputDir = layout.buildDirectory.dir("generated/strings")
// Captured as a plain local (rather than read from the doLast closure directly) so the task
// action doesn't hold a reference to the build script object itself, which the configuration
// cache refuses to serialize.
val xmlFile = stringsXmlFile
inputs.file(xmlFile)
outputs.dir(outputDir)
doLast {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlFile)
val stringNodes = document.getElementsByTagName("string")
val placeholderPattern = Regex("""%(\d+)\$([sd])""")
fun String.toCamelCase(): String = split("_").mapIndexed { index, word ->
if (index == 0) word else word.replaceFirstChar { it.uppercase() }
}.joinToString("")
val source = buildString {
appendLine("// GENERATED FILE - do not edit directly.")
appendLine("// Regenerated by :webHtmlApp:generateStrings from")
appendLine("// shared/src/commonMain/moko-resources/base/strings.xml - edit that file instead.")
appendLine("package xyz.tyiu.satsprice")
appendLine()
appendLine("object Strings {")
for (i in 0 until stringNodes.length) {
val element = stringNodes.item(i) as Element
val propertyName = element.getAttribute("name").toCamelCase()
// moko/Android string-resource XML escapes a literal apostrophe as \' inside the
// (unquoted) text content - unescape that before anything else so it doesn't get
// mistaken for a Kotlin escape sequence below.
val rawValue = element.textContent.replace("\\'", "'")
val placeholders = placeholderPattern.findAll(rawValue)
.map { it.groupValues[1].toInt() to it.groupValues[2] }
.distinct()
.sortedBy { it.first }
.toList()
// Swap each %N$s/%N$d placeholder for a control-character sentinel first, so the
// literal '$' in "%1$s" itself doesn't get caught by the blanket '$' -> "\$"
// escaping below - then escape, then turn the sentinels into "${argN}".
var withSentinels = rawValue
for ((index, _) in placeholders) {
withSentinels = withSentinels.replace(Regex("""%$index\$[sd]"""), "$index")
}
val escaped = withSentinels
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("$", "\\$")
var templateBody = escaped
for ((index, _) in placeholders) {
templateBody = templateBody.replace("$index", "\${arg$index}")
}
if (placeholders.isEmpty()) {
appendLine(" const val $propertyName: String = \"$templateBody\"")
} else {
val params = placeholders.joinToString(", ") { (index, type) ->
"arg$index: ${if (type == "d") "Int" else "String"}"
}
appendLine(" fun $propertyName($params): String = \"$templateBody\"")
}
}
appendLine("}")
}
val outputFile = outputDir.get().file("xyz/tyiu/satsprice/Strings.kt").asFile
outputFile.parentFile.mkdirs()
outputFile.writeText(source)
}
}
// Compose HTML (org.jetbrains.compose.html:html-core) only publishes js and jvm target
// variants as of Compose Multiplatform 1.11.1 - no wasmJs artifact exists yet, unlike the
// Skia-based Compose Multiplatform UI toolkit webApp uses (which targets both). So this
// comparison module is js-only for now.
kotlin {
js {
browser()
binaries.executable()
}
sourceSets {
commonMain {
kotlin.srcDir(generateStrings)
dependencies {
implementation(project(":shared"))
implementation(libs.compose.runtime)
implementation(libs.compose.html.core)
}
}
}
}
@@ -0,0 +1,288 @@
package xyz.tyiu.satsprice
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import org.jetbrains.compose.web.attributes.InputType
import org.jetbrains.compose.web.dom.Button
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.H1
import org.jetbrains.compose.web.dom.Input
import org.jetbrains.compose.web.dom.Option
import org.jetbrains.compose.web.dom.Select
import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text
import xyz.tyiu.satsprice.domain.CurrencyConverter
import xyz.tyiu.satsprice.domain.formatAmount
import xyz.tyiu.satsprice.domain.groupDigits
import xyz.tyiu.satsprice.ui.PriceViewModel
import xyz.tyiu.satsprice.ui.defaultCurrencyRate
import xyz.tyiu.satsprice.ui.exceedsMaxSupply
import xyz.tyiu.satsprice.ui.isPriced
import xyz.tyiu.satsprice.ui.lastUpdatedDateTime
import xyz.tyiu.satsprice.ui.oneCurrencyToSats
@Composable
fun HtmlApp() {
val viewModel = remember { PriceViewModel() }
val state by viewModel.uiState.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
if (showCurrencyPicker) {
HtmlCurrencyPicker(
state = state,
onToggle = viewModel::onFiatCurrencyToggled,
onReset = viewModel::onSelectedCurrenciesReset,
onDone = { showCurrencyPicker = false },
)
return
}
Div({ classes("screen") }) {
H1({ classes("app-title") }) { Text("SatsPrice") }
// Price source
Div({ classes("card") }) {
Div({ classes("row") }) {
Span({ classes("field-inline-label") }) { Text(Strings.priceSource) }
Select(attrs = {
onChange { event -> viewModel.onSourceSelected(event.target.value) }
}) {
viewModel.availableSources.forEach { name ->
Option(value = name, attrs = { if (name == state.sourceName) attr("selected", "") }) { Text(name) }
}
}
}
Div({ classes("row") }) {
LabeledInput(
label = Strings.btcToCurrency(state.defaultCurrencyCode),
value = if (state.isManualSource) state.manualRateInput else state.defaultCurrencyRate(),
readOnly = !state.isManualSource,
onValueChange = viewModel::onManualRateChanged,
modifierClass = "grow",
)
if (!state.isManualSource) {
if (state.isLoading) {
Span({ classes("spinner") })
} else {
Button(attrs = {
classes("icon-button")
attr("aria-label", Strings.refreshContentDescription)
attr("title", Strings.refreshContentDescription)
onClick { viewModel.refresh() }
}) { Text("") }
}
}
}
if (state.isManualSource) {
LabeledInput(
label = Strings.currencyToSats(state.defaultCurrencyCode),
value = state.manualSatsPerCurrencyInput,
onValueChange = viewModel::onManualSatsPerCurrencyChanged,
)
} else {
LabeledInput(
label = Strings.currencyToSats(state.defaultCurrencyCode),
value = state.oneCurrencyToSats(),
readOnly = true,
onValueChange = {},
)
}
val statusLine = if (state.isManualSource) {
null
} else {
state.lastUpdatedDateTime()?.let { Strings.updatedStatus(it) } ?: Strings.loadingRatesStatus
}
statusLine?.let { Div({ classes("status") }) { Text(it) } }
}
state.errorMessage?.let { message ->
Div({ classes("card", "error-banner") }) {
Span { Text(message) }
Button(attrs = { onClick { viewModel.refresh() } }) { Text(Strings.retry) }
}
}
val exceedsMaxSupply = state.exceedsMaxSupply()
// Bitcoin
Div({ classes("card") }) {
Div({ classes("section-title") }) { Text(Strings.bitcoinSectionTitle) }
LabeledInput(
label = Strings.satsLabel,
value = state.satsAmount,
isError = exceedsMaxSupply,
onValueChange = viewModel::onSatsAmountChanged,
)
LabeledInput(
label = Strings.btcLabel,
value = state.btcAmount,
isError = exceedsMaxSupply,
onValueChange = viewModel::onBtcAmountChanged,
)
if (exceedsMaxSupply) {
val maxSupplyText = groupDigits(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) + " BTC"
Div({ classes("warning") }) { Text(Strings.exceedsMaxSupply(maxSupplyText)) }
Button(attrs = {
classes("link-button")
onClick { viewModel.onBtcAmountChanged(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) }
}) { Text("Set BTC to $maxSupplyText") }
}
}
// Currencies
Div({ classes("card") }) {
Div({ classes("row", "section-title-row") }) {
Span({ classes("section-title") }) { Text(Strings.currenciesSectionTitle) }
if (!state.isManualSource) {
Button(attrs = { classes("outlined-button"); onClick { showCurrencyPicker = true } }) {
Text(
if (state.selectedFiatCurrencies.size <= 1) {
Strings.addCurrency
} else {
Strings.currenciesSelectedCount(state.selectedFiatCurrencies.size)
},
)
}
}
}
val displayedCurrencies = if (state.isManualSource) {
listOf(state.defaultCurrencyCode)
} else {
state.selectedFiatCurrencies
}
displayedCurrencies.forEachIndexed { index, code ->
val isPriced = state.isPriced(code)
val fieldLabel = currencyFlagEmoji(code, supportsFlagEmoji = true)?.let { flag -> "$flag $code" } ?: code
Div({ classes("row", "currency-row") }) {
LabeledInput(
label = fieldLabel,
value = if (isPriced) state.fiatAmounts[code].orEmpty() else "",
enabled = isPriced,
isError = !isPriced && !state.isManualSource,
supportingText = if (!isPriced && !state.isManualSource) {
Strings.currencyNotPriced(state.sourceName)
} else {
null
},
onValueChange = { viewModel.onFiatAmountChanged(code, it) },
modifierClass = "grow",
)
if (displayedCurrencies.size > 1) {
val selection = state.selectedFiatCurrencies
CurrencyRowMenu(
code = code,
canMoveUp = index > 0,
canMoveDown = index < selection.lastIndex,
canRemove = code != state.defaultCurrencyCode,
onMoveUp = { viewModel.onFiatCurrenciesReordered(selection.moved(index, index - 1)) },
onMoveDown = { viewModel.onFiatCurrenciesReordered(selection.moved(index, index + 1)) },
onMoveToTop = { viewModel.onFiatCurrenciesReordered(selection.moved(index, 0)) },
onMoveToBottom = {
viewModel.onFiatCurrenciesReordered(selection.moved(index, selection.lastIndex))
},
onRemove = { viewModel.onFiatCurrencyToggled(code) },
)
}
}
}
}
}
}
internal fun List<String>.moved(fromIndex: Int, toIndex: Int): List<String> =
toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
/**
* An anchored popup menu for reordering/removing a currency row, matching Material3's
* DropdownMenu behavior. Compose HTML has no built-in menu primitive, so this is plain DOM: a
* relatively-positioned trigger, an absolutely-positioned popup anchored under it (CSS handles
* the anchoring - no JS position math needed), and a full-viewport backdrop that closes the menu
* on any outside click, matching how the Material3 version dismisses.
*/
@Composable
private fun CurrencyRowMenu(
code: String,
canMoveUp: Boolean,
canMoveDown: Boolean,
canRemove: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onMoveToTop: () -> Unit,
onMoveToBottom: () -> Unit,
onRemove: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Div({ classes("menu-anchor") }) {
Button(attrs = {
classes("icon-button")
attr("aria-label", Strings.currencyOptionsContentDescription(code))
attr("aria-haspopup", "true")
attr("aria-expanded", if (expanded) "true" else "false")
onClick { expanded = true }
}) { Text("") }
if (expanded) {
Div({
classes("menu-backdrop")
onClick { expanded = false }
})
Div({ classes("menu-popup") }) {
MenuItem(Strings.moveCurrencyToTopContentDescription, enabled = canMoveUp) { expanded = false; onMoveToTop() }
MenuItem(Strings.moveCurrencyUpContentDescription, enabled = canMoveUp) { expanded = false; onMoveUp() }
MenuItem(Strings.moveCurrencyDownContentDescription, enabled = canMoveDown) { expanded = false; onMoveDown() }
MenuItem(Strings.moveCurrencyToBottomContentDescription, enabled = canMoveDown) {
expanded = false
onMoveToBottom()
}
if (canRemove) {
MenuItem(Strings.removeCurrencyContentDescription, destructive = true) { expanded = false; onRemove() }
}
}
}
}
}
@Composable
private fun MenuItem(label: String, enabled: Boolean = true, destructive: Boolean = false, onClick: () -> Unit) {
Button(attrs = {
classes("menu-item")
if (destructive) classes("menu-item-destructive")
if (!enabled) attr("disabled", "")
onClick { onClick() }
}) { Text(label) }
}
@Composable
internal fun LabeledInput(
label: String,
value: String,
onValueChange: (String) -> Unit,
readOnly: Boolean = false,
enabled: Boolean = true,
isError: Boolean = false,
supportingText: String? = null,
modifierClass: String? = null,
) {
Div({
classes("field")
modifierClass?.let { classes(it) }
if (isError) classes("field-error")
}) {
Span({ classes("field-label") }) { Text(label) }
Input(type = InputType.Text, attrs = {
value(value)
if (readOnly) attr("readonly", "")
if (!enabled) attr("disabled", "")
onInput { event -> onValueChange(event.value) }
})
supportingText?.let { Div({ classes("supporting-text") }) { Text(it) } }
}
}
@@ -0,0 +1,139 @@
package xyz.tyiu.satsprice
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import org.jetbrains.compose.web.attributes.InputType
import org.jetbrains.compose.web.dom.Button
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.dom.H2
import org.jetbrains.compose.web.dom.Input
import org.jetbrains.compose.web.dom.Span
import org.jetbrains.compose.web.dom.Text
import xyz.tyiu.satsprice.ui.ConverterUiState
import xyz.tyiu.satsprice.ui.currentCurrency
import xyz.tyiu.satsprice.ui.isPriced
import xyz.tyiu.satsprice.ui.selectedOtherCurrencies
import xyz.tyiu.satsprice.ui.unselectedCurrencies
@Composable
internal fun HtmlCurrencyPicker(
state: ConverterUiState,
onToggle: (String) -> Unit,
onReset: () -> Unit,
onDone: () -> Unit,
) {
var showResetConfirmation by remember { mutableStateOf(false) }
if (showResetConfirmation) {
Div({ classes("modal-overlay") }) {
Div({ classes("modal") }) {
H2 { Text(Strings.resetSelectedCurrenciesConfirmationTitle) }
Div { Text(Strings.resetSelectedCurrenciesConfirmationMessage(state.defaultCurrencyCode)) }
Div({ classes("row", "modal-actions") }) {
Button(attrs = { onClick { showResetConfirmation = false } }) { Text(Strings.cancel) }
Button(attrs = {
classes("destructive-button")
onClick {
showResetConfirmation = false
onReset()
}
}) { Text(Strings.resetSelectedCurrenciesButton) }
}
}
}
}
Div({ classes("screen") }) {
Div({ classes("row", "section-title-row") }) {
H2 { Text(Strings.currenciesSectionTitle) }
Div({ classes("row") }) {
if (state.selectedFiatCurrencies.size > 1) {
Button(attrs = { onClick { showResetConfirmation = true } }) {
Text(Strings.resetSelectedCurrenciesButton)
}
}
Button(attrs = { onClick { onDone() } }) { Text(Strings.done) }
}
}
var searchQuery by remember { mutableStateOf("") }
Input(type = InputType.Text, attrs = {
classes("search-input")
attr("placeholder", Strings.searchCurrenciesPlaceholder)
value(searchQuery)
onInput { event -> searchQuery = event.value }
})
val currentCurrency = state.currentCurrency()
val selectedOthers = state.selectedOtherCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
val unselected = state.unselectedCurrencies().filter { matchesCurrencySearch(it, searchQuery) }
val (unselectedPriced, unselectedUnpriced) = unselected.partition { state.isPriced(it.code) }
if (matchesCurrencySearch(currentCurrency, searchQuery)) {
CurrencySection(Strings.currentCurrencySectionTitle) {
CurrencyRow(currentCurrency, isSelected = true, isPriced = state.isPriced(currentCurrency.code), state, onClick = null)
}
}
if (selectedOthers.isNotEmpty()) {
CurrencySection(Strings.selectedCurrenciesSectionTitle) {
selectedOthers.forEach { info ->
CurrencyRow(info, isSelected = true, isPriced = state.isPriced(info.code), state) { onToggle(info.code) }
}
}
}
if (unselectedPriced.isNotEmpty()) {
CurrencySection(Strings.pricedCurrenciesSectionTitle) {
unselectedPriced.forEach { info ->
CurrencyRow(info, isSelected = false, isPriced = true, state) { onToggle(info.code) }
}
}
}
if (unselectedUnpriced.isNotEmpty()) {
CurrencySection(Strings.unpricedCurrenciesSectionTitle) {
unselectedUnpriced.forEach { info ->
CurrencyRow(info, isSelected = false, isPriced = false, state) { onToggle(info.code) }
}
}
}
}
}
@Composable
private fun CurrencySection(title: String, content: @Composable () -> Unit) {
Div({ classes("currency-section") }) {
Div({ classes("section-header") }) { Text(title.uppercase()) }
content()
}
}
@Composable
private fun CurrencyRow(
info: CurrencyInfo,
isSelected: Boolean,
isPriced: Boolean,
state: ConverterUiState,
onClick: (() -> Unit)?,
) {
val text = Strings.currencyOptionLabel(info.code, info.displayName)
val label = currencyFlagEmoji(info.code, supportsFlagEmoji = true)?.let { flag -> "$flag $text" } ?: text
Div({
classes("row", "picker-row")
if (onClick != null) {
classes("clickable")
onClick { onClick() }
}
}) {
Div {
Div { Text(label) }
if (!isPriced) {
Div({ classes("supporting-text", "error-text") }) {
Text(Strings.currencyNotPriced(state.sourceName))
}
}
}
if (isSelected) Span({ classes("check-mark") }) { Text("") }
}
}
@@ -0,0 +1,9 @@
package xyz.tyiu.satsprice
import org.jetbrains.compose.web.renderComposable
fun main() {
renderComposable(rootElementId = "root") {
HtmlApp()
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SatsPrice</title>
<meta name="description" content="SatsPrice converts Bitcoin, satoshis, and fiat currency in real time — Compose HTML build.">
<link type="text/css" rel="stylesheet" href="styles.css">
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="icon" type="image/png" href="favicon-192.png" sizes="192x192">
</head>
<body>
<div id="root"></div>
<script type="application/javascript" src="webHtmlApp.js"></script>
</body>
</html>
+314
View File
@@ -0,0 +1,314 @@
:root {
--bg: #fef7ff;
--surface: #ffffff;
--surface-container: #f3edf7;
--on-surface: #1d1b20;
--on-surface-variant: #49454f;
--primary: #6750a4;
--outline: #79747e;
--error: #b3261e;
--error-container: #f9dedc;
--on-error-container: #410e0b;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #141218;
--surface: #211f26;
--surface-container: #2b2930;
--on-surface: #e6e0e9;
--on-surface-variant: #cac4d0;
--primary: #d0bcff;
--outline: #948f99;
--error: #f2b8b5;
--error-container: #8c1d18;
--on-error-container: #f9dedc;
}
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--on-surface);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.screen {
max-width: 480px;
margin: 0 auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
}
.app-title {
margin: 8px;
font-size: 1.5rem;
}
.card {
background: var(--surface-container);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.section-title {
font-weight: 600;
font-size: 1.05rem;
}
.section-title-row {
justify-content: space-between;
}
.field {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.field.grow {
flex: 1;
}
.field-label {
font-size: 0.75rem;
color: var(--on-surface-variant);
}
.field-inline-label {
font-size: 0.9rem;
color: var(--on-surface-variant);
}
.field input {
font-size: 1rem;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--outline);
background: var(--surface);
color: var(--on-surface);
box-sizing: border-box;
}
.field input:disabled {
opacity: 0.6;
}
.field-error input {
border-color: var(--error);
}
.supporting-text {
font-size: 0.75rem;
color: var(--on-surface-variant);
}
.error-text {
color: var(--error);
}
.status {
font-size: 0.8rem;
color: var(--on-surface-variant);
}
.error-banner {
flex-direction: row;
justify-content: space-between;
align-items: center;
background: var(--error-container);
color: var(--on-error-container);
}
.warning {
color: var(--error);
font-size: 0.85rem;
}
.currency-row {
align-items: flex-start;
}
.menu-anchor {
position: relative;
margin-top: 20px;
}
.menu-backdrop {
position: fixed;
inset: 0;
z-index: 15;
}
.menu-popup {
position: absolute;
top: 100%;
right: 0;
z-index: 16;
margin-top: 4px;
min-width: 200px;
background: var(--surface);
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
padding: 4px;
display: flex;
flex-direction: column;
}
.menu-item {
border: none;
background: transparent;
color: var(--on-surface);
text-align: left;
padding: 10px 12px;
border-radius: 6px;
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
}
.menu-item:not(:disabled):hover {
background: rgba(0, 0, 0, 0.08);
}
.menu-item-destructive {
color: var(--error);
}
button {
font-family: inherit;
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.35;
}
.icon-button {
border: none;
background: transparent;
color: var(--on-surface);
font-size: 1.1rem;
width: 32px;
height: 32px;
border-radius: 50%;
}
.icon-button:not(:disabled):hover {
background: rgba(0, 0, 0, 0.08);
}
.outlined-button, .link-button, .destructive-button, .modal-actions button, .section-title-row button {
border: 1px solid var(--outline);
background: transparent;
color: var(--primary);
border-radius: 20px;
padding: 8px 16px;
font-size: 0.9rem;
}
.link-button {
border: none;
padding: 0;
text-decoration: underline;
align-self: flex-start;
}
.destructive-button {
color: var(--error);
border-color: var(--error);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--outline);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.search-input {
font-size: 1rem;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--outline);
background: var(--surface);
color: var(--on-surface);
box-sizing: border-box;
}
.currency-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.section-header {
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--primary);
font-weight: 600;
margin-top: 8px;
}
.picker-row {
justify-content: space-between;
padding: 10px 4px;
border-bottom: 1px solid var(--surface-container);
}
.picker-row.clickable {
cursor: pointer;
}
.picker-row.clickable:hover {
background: rgba(0, 0, 0, 0.04);
}
.check-mark {
color: var(--primary);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.modal {
background: var(--surface);
border-radius: 12px;
padding: 20px;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-actions {
justify-content: flex-end;
}
+3 -3
View File
@@ -7,12 +7,12 @@ PORT="${1:-8000}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
echo "Building web app (wasmJs)..."
"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webApp:wasmJsBrowserDistribution
echo "Building web app (webHtmlApp)..."
"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webHtmlApp:jsBrowserDistribution
echo "Copying web app into website/app/..."
mkdir -p "$SCRIPT_DIR/app"
cp -r "$REPO_ROOT/webApp/build/dist/wasmJs/productionExecutable/." "$SCRIPT_DIR/app/"
cp -r "$REPO_ROOT/webHtmlApp/build/dist/js/productionExecutable/." "$SCRIPT_DIR/app/"
echo "Serving website at http://localhost:$PORT/ (web app at http://localhost:$PORT/app/)"
cd "$SCRIPT_DIR" && python3 -m http.server "$PORT"