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
+25
View File
@@ -0,0 +1,25 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
// 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.dependencies {
implementation(project(":shared"))
implementation(libs.compose.runtime)
implementation(libs.compose.html.core)
}
}
}
@@ -0,0 +1,285 @@
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.refresh)
attr("title", Strings.refresh)
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.currencyOptions(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.moveToTop, enabled = canMoveUp) { expanded = false; onMoveToTop() }
MenuItem(Strings.moveUp, enabled = canMoveUp) { expanded = false; onMoveUp() }
MenuItem(Strings.moveDown, enabled = canMoveDown) { expanded = false; onMoveDown() }
MenuItem(Strings.moveToBottom, enabled = canMoveDown) { expanded = false; onMoveToBottom() }
if (canRemove) {
MenuItem(Strings.remove, 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,137 @@
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.resetConfirmationTitle) }
Div { Text(Strings.resetConfirmationMessage(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.resetButton) }
}
}
}
}
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.resetButton) }
}
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,45 @@
package xyz.tyiu.satsprice
/**
* English-only string constants mirroring shared/src/commonMain/moko-resources/base/strings.xml.
* moko-resources-compose's `stringResource()` pulls in Compose Foundation/UI (for its
* `LocalComposition`/`Modifier` plumbing) transitively, which would drag Skia-oriented
* dependencies into a module whose whole point is avoiding them, so this DOM build skips
* moko-resources rather than fight that dependency and stays English-only as a result.
*/
object Strings {
const val addCurrency = "Add currency"
const val bitcoinSectionTitle = "Bitcoin"
const val btcLabel = "BTC"
fun btcToCurrency(code: String) = "BTC to $code"
const val cancel = "Cancel"
const val clearSearch = "Clear search"
const val currenciesSectionTitle = "Currencies"
fun currenciesSelectedCount(count: Int) = "$count selected"
const val currentCurrencySectionTitle = "Current Currency"
fun currencyNotPriced(sourceName: String) = "Not priced by $sourceName"
fun currencyOptions(code: String) = "Options for $code"
fun currencyOptionLabel(code: String, displayName: String) = "$code - $displayName"
fun currencyToSats(code: String) = "$code to Sats"
const val done = "Done"
fun exceedsMaxSupply(maxSupplyText: String) = "Exceeds the $maxSupplyText maximum supply"
const val loadingRatesStatus = "Loading rates…"
const val moveDown = "Move down"
const val moveToBottom = "Move to bottom"
const val moveToTop = "Move to top"
const val moveUp = "Move up"
const val priceSource = "Price Source"
const val pricedCurrenciesSectionTitle = "Priced Currencies"
const val refresh = "Refresh"
const val remove = "Remove"
const val resetButton = "Reset"
fun resetConfirmationMessage(code: String) =
"This keeps $code, but removes every other currency you've added."
const val resetConfirmationTitle = "Remove all selected currencies?"
const val retry = "Retry"
const val satsLabel = "Sats"
const val searchCurrenciesPlaceholder = "Search currencies"
const val selectedCurrenciesSectionTitle = "Selected Currencies"
const val unpricedCurrenciesSectionTitle = "Unpriced Currencies"
fun updatedStatus(time: String) = "Updated $time"
}
@@ -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 (DOM)</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;
}