From c7c49a9d6b22e97f42a4ae97f834dc56fd1bbb80 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 8 Aug 2026 05:15:08 -0500 Subject: [PATCH] feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2) The structure half of the admin's Appearance page. ShardStructure.resolve() turns the four --radius-* tokens and --shadow-card into a Material shape scale, a pill shape and a card elevation; RunicGatewayTheme feeds the scale to MaterialTheme and the other two to a LocalShardStructure, mirroring phase 1's palette split. Radii are applied as a ratio, never as a literal. The app's Shapes came from the M5 mockup and the website's from theme.css, and the two scales differ - copying the web value in would have restyled an untouched app on day one. Each field is scaled by resolved / runic-gateway baseline instead, so the shipped theme and an explicit runic-gateway both give ratio 1.0 and are provable no-ops. Three things the spec did not survive contact with: Card depth is not a no-op, and that is the org lead's decision. Material3's filled Card is Level0 and FeatureCard drew none of the shadow its own docs claimed, so the app has been flat since M5 - while the preset it was drawn from selects the "Default" shadow. Section 5.4 is applied as written rather than rebased on the flat baseline, which would have collapsed three of the admin's four choices onto 0dp. Every card gains 4dp; sections 2, 5.4 and AC-1 record it. The shadow is matched by nearest blur, not by exact string. The fantasy preset publishes a --shadow-card that SHADOW_OPTIONS does not contain, because a preset's tokens are copied verbatim and never pass through the admin dropdown - an exact match would have missed the one preset whose point is a heavier shadow. --radius-pill is resolved as a literal px, because CircleShape is a percentage and has no shipped dp for a ratio to scale. It reaches exactly one composable: the app's other two CircleShape uses are 8dp status dots, and a dot stays a dot. ShardCard exists because Material's theme cannot carry elevation - Card takes it as a default argument. All 24 Card( call sites across 20 files moved to the wrapper, which is mechanical because every one of them passed only a modifier. A Card( outside ThemeComponents.kt is now, by construction, an unthemable card. Shapes does implement equals (unlike ColorScheme), so the structural no-op proof is one assertion against a verbatim copy of the pre-M12 scale. 13 new tests, 386 green, lintDebug and assembleDebug clean. Co-Authored-By: Claude --- .../app/ui/admin/AdminContentScreen.kt | 6 +- .../app/ui/admin/AdminSupportScreen.kt | 4 +- .../runicgateway/app/ui/auth/AccountScreen.kt | 6 +- .../app/ui/auth/RecoveryCodesScreen.kt | 4 +- .../app/ui/auth/TrustedDevicesScreen.kt | 4 +- .../app/ui/components/ThemeComponents.kt | 49 ++++- .../runicgateway/app/ui/news/NewsScreen.kt | 4 +- .../app/ui/player/CharacterSheetScreen.kt | 4 +- .../app/ui/player/CharactersScreen.kt | 8 +- .../app/ui/player/MyHousesScreen.kt | 4 +- .../app/ui/player/VendorsScreen.kt | 6 +- .../runicgateway/app/ui/shard/AtlasScreen.kt | 4 +- .../runicgateway/app/ui/shard/ChampsScreen.kt | 4 +- .../app/ui/shard/GovernorsScreen.kt | 4 +- .../runicgateway/app/ui/shard/GuildsScreen.kt | 4 +- .../runicgateway/app/ui/shard/HousesScreen.kt | 4 +- .../app/ui/shard/LeaderboardsScreen.kt | 4 +- .../runicgateway/app/ui/shard/MarketScreen.kt | 4 +- .../runicgateway/app/ui/shard/RulesScreen.kt | 4 +- .../runicgateway/app/ui/shard/ShardScreen.kt | 4 +- .../app/ui/theme/ShardStructure.kt | 179 +++++++++++++++++ .../com/runicgateway/app/ui/theme/Theme.kt | 31 ++- .../runicgateway/app/ui/wiki/WikiScreen.kt | 4 +- .../app/ui/theme/ShardStructureTest.kt | 187 ++++++++++++++++++ 24 files changed, 466 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/ui/theme/ShardStructure.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/theme/ShardStructureTest.kt diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt index 01aab33..35740f5 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -46,6 +45,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -139,7 +139,7 @@ private fun PostsTab( } } items(state.data, key = { it.id }) { post -> - Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Column(Modifier.padding(12.dp)) { Text(post.title, style = MaterialTheme.typography.bodyLarge) Spacer(Modifier.height(4.dp)) @@ -190,7 +190,7 @@ private fun WikiTab( } } items(state.data, key = { it.id }) { cat -> - Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Column(Modifier.padding(12.dp)) { Text(cat.title, style = MaterialTheme.typography.bodyLarge) Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt index 5a7eb91..9bafad0 100644 --- a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -38,6 +37,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** * The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close, @@ -100,7 +100,7 @@ private fun SupportPageCard( onReply: () -> Unit, onClose: () -> Unit, ) { - Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { Column(Modifier.padding(12.dp)) { val who = page.sender?.name ?: page.sender?.account ?: page.pageId Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index f771c93..d3daebb 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -107,7 +107,7 @@ fun AccountScreen( @Composable private fun IdentityCard(username: String, roleLabel: String) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(20.dp)) { Text(text = username, style = MaterialTheme.typography.titleLarge) StatusPill( @@ -154,7 +154,7 @@ private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCode @Composable private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) { - Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) { Column(Modifier.padding(16.dp)) { Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium) content() diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt index 4c518f6..21f3e98 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/RecoveryCodesScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -38,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.ShardCard /** * Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a @@ -117,7 +117,7 @@ fun RecoveryCodesShowOnceCard(codes: List, onDismiss: () -> Unit) { val clipboard = LocalClipboardManager.current val joined = remember(codes) { codes.joinToString("\n") } - Card(Modifier.fillMaxWidth().padding(top = 16.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium) Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt index c8e45fb..907a61d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/TrustedDevicesScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -30,6 +29,7 @@ import com.runicgateway.app.data.api.dto.TrustedDeviceDto import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** * Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip @@ -108,7 +108,7 @@ fun TrustedDevicesScreen( @Composable private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) { - Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) { Row( Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt b/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt index a0ad056..e463376 100644 --- a/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt +++ b/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt @@ -14,16 +14,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.runicgateway.app.ui.theme.LocalShardPalette +import com.runicgateway.app.ui.theme.LocalShardStructure import com.runicgateway.app.ui.theme.ShardDanger import com.runicgateway.app.ui.theme.ShardDangerBg import com.runicgateway.app.ui.theme.ShardSuccess @@ -38,10 +42,11 @@ import com.runicgateway.app.ui.theme.ShardWarningBg * and stat-bar motifs the mockup repeats across screens. Pure presentation — * no state, no data dependencies — so any screen can adopt them. * - * This is the app's **only** file that reaches past `MaterialTheme.colorScheme` - * for a themable color, so it is the one place M12 had to migrate: the surface, - * line and accent tokens now come from [LocalShardPalette] and follow the - * shard's theme (THEMING_AND_NAV.md §5.1). The success/warning/danger constants + * This is the app's **only** file that reaches past `MaterialTheme` for a + * themable value, so it is the one place M12 had to migrate: the surface, line + * and accent tokens come from [LocalShardPalette] and the pill shape and card + * depth from [LocalShardStructure], both following the shard's theme + * (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants * stay imported directly — those are semantic and never themed, mirroring the * server's `FIXED_TOKENS`. */ @@ -63,11 +68,14 @@ private fun toneColors(tone: PillTone): PillColors = when (tone) { /** * A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a * rounded filled background tinted by [tone]. Mirrors the mockup's pill badges. + * + * The one place `--radius-pill` lands: the app's other two [CircleShape] uses are + * 8dp status dots, and a dot stays a dot however square the shard makes its site. */ @Composable fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) { val c = toneColors(tone) - Surface(color = c.bg, shape = CircleShape, modifier = modifier) { + Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) { Text( text = text.uppercase(), style = MaterialTheme.typography.labelSmall, @@ -107,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) { * The elevated "feature" card: a vertical blue gradient with a hairline outline and * soft shadow, used for the home status card, the shard-online banner, and the * vendor card. [content] is laid out in a padded [Column]. + * + * The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was + * built with — the same value, now following `--radius-card`'s ratio (§5.2). The + * shadow this doc always claimed is finally drawn, at the depth `--shadow-card` + * resolves to (§5.4). */ @Composable fun FeatureCard( @@ -115,17 +128,39 @@ fun FeatureCard( content: @Composable ColumnScope.() -> Unit, ) { val palette = LocalShardPalette.current + val shape = MaterialTheme.shapes.medium Box( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .shadow(LocalShardStructure.current.cardElevation, shape) + .clip(shape) .background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom))) - .border(1.dp, palette.outline, RoundedCornerShape(12.dp)), + .border(1.dp, palette.outline, shape), ) { Column(Modifier.padding(contentPadding.dp), content = content) } } +/** + * A Material [Card] at the shard's resolved depth — the app's standard card, and + * the reason every screen's `Card(` became a `ShardCard(`. + * + * `Card` takes its elevation as a **default argument**, not from the theme, so + * unlike the color scheme and the shape scale there is no way to make + * `--shadow-card` reach ~24 call sites without a wrapper. Passing + * [CardDefaults.cardElevation] at each site instead would have put the same line + * in eighteen files and let one drift. A `Card(` outside this file is therefore a + * card the shard cannot theme, which makes the invariant greppable. + */ +@Composable +fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Card( + modifier = modifier, + elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation), + content = content, + ) +} + /** * A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is * the slate accent over a bordered dark track. diff --git a/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt index e3fdd2c..f2bc95c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/news/NewsScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab @@ -29,6 +28,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** News hub with category tabs and a post list (PLAN.md §6.1). */ @Composable @@ -82,7 +82,7 @@ private fun PostList( @Composable private fun PostRow(post: PostDto, onClick: () -> Unit) { - Card( + ShardCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt index 790835e..8948c20 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharacterSheetScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatBar import kotlinx.serialization.json.jsonPrimitive @@ -289,7 +289,7 @@ private fun EquipmentBlock(equipment: List) { @Composable private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium) content() diff --git a/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt index 41b85a8..9f7e01c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/CharactersScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -39,6 +38,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -90,7 +90,7 @@ fun CharactersScreen( @Composable private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) { var code by rememberSaveable { mutableStateOf("") } - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium) Text( @@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) { var account by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium) OutlinedTextField( @@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) { @Composable private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) { - Card( + ShardCard( Modifier .fillMaxWidth() .padding(vertical = 4.dp) diff --git a/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt index 89d0dc1..e60bc11 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/MyHousesScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +27,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** * The player's own houses with home/decay status (PLAN.md §6.3), text-only. An @@ -60,7 +60,7 @@ fun MyHousesScreen( @Composable private fun HouseCard(house: PlayerHouseDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt index c06c2a9..e9c4a89 100644 --- a/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/player/VendorsScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard import java.text.DateFormat import java.util.Date @@ -79,7 +79,7 @@ fun VendorsScreen( @Composable private fun SalesCard(sales: UiState>) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium) when (sales) { @@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) { @Composable private fun VendorCard(vendor: VendorDto) { - Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Column(Modifier.padding(16.dp)) { Text( vendor.shopName ?: stringResource(R.string.player_vendor_fallback), diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt index 0619dd2..ea24312 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/AtlasScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -41,6 +40,7 @@ import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -95,7 +95,7 @@ fun AtlasScreen( @Composable private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) { val slug = creature.slug - Card( + ShardCard( Modifier .fillMaxWidth() .then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier), diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt index 7560337..5ee98e1 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.ChampDto import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */ @@ -44,7 +44,7 @@ fun ChampsScreen( @Composable private fun ChampCard(champ: ChampDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt index 2d33b91..d627336 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GovernorsScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -33,6 +32,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */ @Composable @@ -84,7 +84,7 @@ private fun CityCard( onExpand: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column { Column( Modifier diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt index 9df373c..262c2d4 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/GuildsScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.GuildDto +import com.runicgateway.app.ui.components.ShardCard /** The guild board (PLAN.md §6.2), live via SSE deltas. */ @Composable @@ -42,7 +42,7 @@ fun GuildsScreen( @Composable private fun GuildCard(guild: GuildDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt index 0d6fe96..b0fec7d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/HousesScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.runicgateway.app.R import com.runicgateway.app.data.api.dto.HouseDto import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */ @@ -44,7 +44,7 @@ fun HousesScreen( @Composable private fun HouseCard(house: HouseDto) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Row(Modifier.fillMaxWidth()) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt index dc6cb14..abe8c49 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/LeaderboardsScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -26,6 +25,7 @@ import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.PointsBoardDto import com.runicgateway.app.data.api.dto.PointsEntryDto import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard /** * The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via @@ -52,7 +52,7 @@ fun LeaderboardsScreen( @Composable private fun BoardCard(board: PointsBoardDto, placeholderName: String) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt index ab2ddd6..05e9f72 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/MarketScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard /** * The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock. @@ -99,7 +99,7 @@ fun MarketScreen( @Composable private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) { val vendorSerial = listing.vendor?.serial - Card( + ShardCard( Modifier .fillMaxWidth() .then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier), diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt index 53a7225..7187ab2 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/RulesScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -30,6 +29,7 @@ import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.components.PillTone +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -167,7 +167,7 @@ private fun CapsCard(caps: RulesetCapsDto) { @Composable private fun RuleCard(title: String, content: @Composable () -> Unit) { - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { Text(title, style = MaterialTheme.typography.titleMedium) content() diff --git a/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt b/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt index bef6d86..e653692 100644 --- a/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/shard/ShardScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,6 +36,7 @@ import com.runicgateway.app.data.repository.ShardFeatures import com.runicgateway.app.data.repository.canSee import com.runicgateway.app.ui.components.PillTone import com.runicgateway.app.ui.components.SectionLabel +import com.runicgateway.app.ui.components.ShardCard import com.runicgateway.app.ui.components.StatusPill /** @@ -193,7 +193,7 @@ private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Un } } if (boards.isEmpty()) return - Card(Modifier.fillMaxWidth()) { + ShardCard(Modifier.fillMaxWidth()) { Column { boards.forEachIndexed { index, (board, labelRes) -> Text( diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/ShardStructure.kt b/app/src/main/java/com/runicgateway/app/ui/theme/ShardStructure.kt new file mode 100644 index 0000000..5e64655 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/theme/ShardStructure.kt @@ -0,0 +1,179 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * The shard's resolved corner radii and card depth — the `structure` half of the + * admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to + * [ShardPalette]. + * + * **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came + * from the M5 mockup and the website's from `theme.css`; the two scales genuinely + * differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in + * would restyle an untouched app the day this milestone shipped, so each field is + * scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped + * theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every + * field and is a provable no-op (§2, AC-1). + * + * Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation]. + */ +@Immutable +data class ShardStructure( + /** The Material shape scale, ratio-scaled off the app's own shipped dp values. */ + val shapes: Shapes = ShippedShapes, + /** + * `--radius-pill`. Not part of [shapes]: the app draws its chips with + * [CircleShape], which is a percentage and so has no dp for a ratio to scale. + * Resolved as a literal instead — the only rule available — see [pillShape]. + */ + val pill: Shape = CircleShape, + /** `--shadow-card`, mapped onto Material elevation (§5.4). */ + val cardElevation: Dp = ShippedCardElevation, +) { + companion object { + /** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */ + val Shipped = ShardStructure() + + /** + * Resolve a `theme` token map into a structure, **field by field** (§2): + * a `--radius-panel` the server never validated must not cost the + * `--radius-card` beside it, exactly as in [ShardPalette.resolve]. + */ + fun resolve(theme: Map): ShardStructure { + if (theme.isEmpty()) return Shipped + val input = ratio(theme["--radius-input"], BaseInputPx) + val card = ratio(theme["--radius-card"], BaseCardPx) + val panel = ratio(theme["--radius-panel"], BasePanelPx) + return ShardStructure( + shapes = Shapes( + extraSmall = corner(ShippedExtraSmallDp, input), + small = corner(ShippedSmallDp, input), + medium = corner(ShippedMediumDp, card), + // extraLarge has no web counterpart and follows the panel + // ratio, since it is the panel family. + large = corner(ShippedLargeDp, panel), + extraLarge = corner(ShippedExtraLargeDp, panel), + ), + pill = pillShape(theme["--radius-pill"]), + cardElevation = elevation(theme["--shadow-card"]), + ) + } + } +} + +/** + * The live structure, for the two things Material's theme cannot carry: the pill + * shape, and a card elevation ([androidx.compose.material3.Card] takes its + * elevation as a default argument, not from a composition local). The shape + * scale itself reaches screens through `MaterialTheme.shapes` and needs nothing + * here. + */ +val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped } + +// ── the shipped scale ────────────────────────────────────────────────────── +// +// The app's own dp values, which the ratios scale. Kept here rather than in +// Theme.kt so the resolution and the thing it resolves back to sit together. + +private const val ShippedExtraSmallDp = 8 +private const val ShippedSmallDp = 8 +private const val ShippedMediumDp = 12 +private const val ShippedLargeDp = 16 +private const val ShippedExtraLargeDp = 24 + +/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */ +internal val ShippedShapes = Shapes( + extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp), + small = RoundedCornerShape(ShippedSmallDp.dp), + medium = RoundedCornerShape(ShippedMediumDp.dp), + large = RoundedCornerShape(ShippedLargeDp.dp), + extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp), +) + +/** + * The depth an unthemed instance draws its cards at. + * + * **This is the one field of this milestone that is deliberately not a no-op.** + * The app has been flat since M5 — Material's filled `Card` is `Level0` and + * `FeatureCard` never had the shadow its own docs claimed — while the + * `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is + * applied as written rather than rebased on the app's flat baseline, so every + * card gains this depth and the admin's four-step control reads the same on the + * phone as on the web. Approved by the org lead as an amendment to §2. + */ +private val ShippedCardElevation = 4.dp + +// ── the runic-gateway baselines ─────────────────────────────────────────── +// +// The preset the app's own scale corresponds to (server/src/config/themePresets.js). +// A resolved value is meaningful only against these: the ratio, not the number, +// is what crosses from the web scale to the app's. + +private const val BaseInputPx = 8f +private const val BaseCardPx = 10f +private const val BasePanelPx = 12f +private const val BasePillPx = 999f + +/** + * Below half the pill baseline the chip stops reading as a pill and becomes a + * rounded rectangle, so an admin who squares the site off squares off the app's + * chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s + * 999px does not. + */ +private const val PillCircleFloorPx = BasePillPx / 2f + +// A radius as the server writes it: an integer count of px, 0..999, always with +// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value +// this app can scale, and falls back to the shipped dp on its own. +private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""") + +// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no +// unit, so the blur is the second px length. +private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""") + +/** + * `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact + * string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`, + * but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value + * `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass + * through that dropdown. An exact match would have missed the one preset whose + * point is a heavier shadow. Matching the blur puts any future preset on the + * nearest step instead of silently on the default. + */ +private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp) + +private fun parseRadiusPx(raw: String?): Float? = + raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull() + +private fun ratio(raw: String?, baselinePx: Float): Float = + parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f + +/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */ +private fun corner(shippedDp: Int, ratio: Float) = + RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp) + +private fun pillShape(raw: String?): Shape { + val px = parseRadiusPx(raw) ?: return CircleShape + return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp) +} + +private fun elevation(raw: String?): Dp { + val value = raw?.trim() ?: return ShippedCardElevation + if (value.equals("none", ignoreCase = true)) return 0.dp + val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull() + ?.groupValues?.get(1)?.toFloatOrNull() + ?: return ShippedCardElevation + return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation +} diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt index 5103b55..8a9d726 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt @@ -3,15 +3,12 @@ */ package com.runicgateway.app.ui.theme -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import androidx.compose.ui.unit.dp import com.runicgateway.app.data.appearance.SiteAppearance /** @@ -51,25 +48,19 @@ internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorSch onErrorContainer = ShardDanger, ) -/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */ -private val ShardShapes = Shapes( - extraSmall = RoundedCornerShape(8.dp), - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(12.dp), - large = RoundedCornerShape(16.dp), - extraLarge = RoundedCornerShape(24.dp), -) - /** * App theme, themed by the shard (M12). [appearance] carries the resolved token * map the admin's Appearance page publishes; it is applied field by field over - * the shipped palette, so [SiteAppearance.NONE] — no settings rows, a backend - * that predates the feature, or a settings call that failed — renders exactly - * as the app did before this milestone (§2). + * the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings + * rows, a backend that predates the feature, or a settings call that failed — + * renders as the app did before this milestone (§2), the one exception being the + * card depth [ShardStructure] documents. * * The palette reaches screens two ways: through [MaterialTheme]'s color scheme * for the ten tokens with a Material role, and through [LocalShardPalette] for - * the five without one. + * the five without one. The radii split the same way — [MaterialTheme]'s shape + * scale for everything Material draws, [LocalShardStructure] for the pill and + * the card depth, which it cannot carry. */ @Composable fun RunicGatewayTheme( @@ -83,12 +74,16 @@ fun RunicGatewayTheme( ) } val colorScheme = remember(palette) { shardColorScheme(palette) } + val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) } - CompositionLocalProvider(LocalShardPalette provides palette) { + CompositionLocalProvider( + LocalShardPalette provides palette, + LocalShardStructure provides structure, + ) { MaterialTheme( colorScheme = colorScheme, typography = Typography, - shapes = ShardShapes, + shapes = structure.shapes, content = content, ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt index 77810c9..ee265d7 100644 --- a/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/wiki/WikiScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -30,6 +29,7 @@ import com.runicgateway.app.ui.UiState import com.runicgateway.app.ui.components.EmptyView import com.runicgateway.app.ui.components.ErrorView import com.runicgateway.app.ui.components.LoadingView +import com.runicgateway.app.ui.components.ShardCard /** Wiki index: search field + page list (PLAN.md §6.1). */ @Composable @@ -72,7 +72,7 @@ fun WikiScreen( private fun WikiList(pages: List, onOpenPage: (String) -> Unit) { LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { items(pages, key = { it.id }) { page -> - Card( + ShardCard( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/ShardStructureTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/ShardStructureTest.kt new file mode 100644 index 0000000..b5e17d1 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/ShardStructureTest.kt @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * [ShardStructure.resolve] — the radius ratio (§5.2) and the shadow map (§5.4). + * + * The no-op proof is easier here than it was for the palette: material3's + * [Shapes] *does* implement `equals` (unlike `ColorScheme`), so a whole shape + * scale can be compared in one assertion. As in [ShardColorSchemeTest] the + * expected value is a **verbatim copy of the pre-M12 scale**, kept here rather + * than referenced, so the proof is against what the app used to draw and not + * against whatever [ShippedShapes] says today. + */ +class ShardStructureTest { + + /** The scale exactly as `ui/theme/Theme.kt` declared it before M12. */ + private val preM12Shapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), + ) + + /** The `runic-gateway` preset's structure tokens, as the server publishes them. */ + private val runicGateway = mapOf( + "--radius-pill" to "999px", + "--radius-panel" to "12px", + "--radius-card" to "10px", + "--radius-input" to "8px", + "--shadow-card" to "0 14px 34px rgba(0, 0, 0, 0.3)", + ) + + private val fantasy = mapOf( + "--radius-pill" to "4px", + "--radius-panel" to "3px", + "--radius-card" to "2px", + "--radius-input" to "2px", + "--shadow-card" to "0 16px 38px rgba(0, 0, 0, 0.45)", + ) + + private val modern = mapOf( + "--radius-pill" to "8px", + "--radius-panel" to "8px", + "--radius-card" to "6px", + "--radius-input" to "6px", + "--shadow-card" to "0 8px 20px rgba(0, 0, 0, 0.25)", + ) + + @Test + fun `the shipped scale is the pre-M12 scale`() { + assertEquals(preM12Shapes, ShardStructure.Shipped.shapes) + assertSame(CircleShape, ShardStructure.Shipped.pill) + } + + @Test + fun `no theme resolves to the shipped structure`() { + assertEquals(ShardStructure.Shipped, ShardStructure.resolve(emptyMap())) + } + + @Test + fun `a theme with no structure tokens resolves to the shipped structure`() { + // A shard that themed its colors only still draws the app's own radii: + // every ratio is 1.0 because every token is absent. + val colorsOnly = mapOf("--accent" to "#7f99bd", "--bg" to "#0b1220") + assertEquals(ShardStructure.Shipped, ShardStructure.resolve(colorsOnly)) + } + + @Test + fun `the runic-gateway preset is a no-op`() { + // AC-1 for the structure half: an admin who explicitly picks the preset + // the app was drawn from gets ratio 1.0 on all four fields. + assertEquals(ShardStructure.Shipped, ShardStructure.resolve(runicGateway)) + } + + @Test + fun `fantasy scales the app's own dp, it does not adopt the web's`() { + val s = ShardStructure.resolve(fantasy) + // 2/8 -> 8dp becomes 2dp; the web's own value is also 2px, coincidentally. + assertEquals(RoundedCornerShape(2.dp), s.shapes.extraSmall) + assertEquals(RoundedCornerShape(2.dp), s.shapes.small) + // 2/10 -> 12dp * 0.2 = 2.4, rounded. + assertEquals(RoundedCornerShape(2.dp), s.shapes.medium) + // 3/12 -> 16dp * 0.25. The web value is 3px; the app's is 4dp, which is + // the whole point of the ratio. + assertEquals(RoundedCornerShape(4.dp), s.shapes.large) + // extraLarge has no web counterpart and follows the panel ratio. + assertEquals(RoundedCornerShape(6.dp), s.shapes.extraLarge) + assertEquals(RoundedCornerShape(4.dp), s.pill) + // 38px blur is nearer Default's 34 than Deep's 44. + assertEquals(4.dp, s.cardElevation) + } + + @Test + fun `modern scales the app's own dp`() { + val s = ShardStructure.resolve(modern) + assertEquals(RoundedCornerShape(6.dp), s.shapes.extraSmall) + assertEquals(RoundedCornerShape(7.dp), s.shapes.medium) + assertEquals(RoundedCornerShape(11.dp), s.shapes.large) + assertEquals(RoundedCornerShape(16.dp), s.shapes.extraLarge) + assertEquals(RoundedCornerShape(8.dp), s.pill) + assertEquals(2.dp, s.cardElevation) + } + + @Test + fun `a bad radius costs only its own field`() { + val s = ShardStructure.resolve( + mapOf( + "--radius-input" to "8", // no unit; the server never writes this + "--radius-card" to "huge", + "--radius-panel" to "3px", // good, and must still apply + "--radius-pill" to "", + ), + ) + assertEquals(preM12Shapes.extraSmall, s.shapes.extraSmall) + assertEquals(preM12Shapes.medium, s.shapes.medium) + assertEquals(RoundedCornerShape(4.dp), s.shapes.large) + assertSame(CircleShape, s.pill) + } + + @Test + fun `a zero radius squares the corner off rather than clamping to the shipped value`() { + val s = ShardStructure.resolve(mapOf("--radius-card" to "0px", "--radius-input" to "0px")) + assertEquals(RoundedCornerShape(0.dp), s.shapes.medium) + assertEquals(RoundedCornerShape(0.dp), s.shapes.extraSmall) + assertNotEquals(preM12Shapes, s.shapes) + } + + @Test + fun `the pill keeps its circle until the site is squared off`() { + assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "999px")).pill) + assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "500px")).pill) + assertEquals(RoundedCornerShape(499.dp), ShardStructure.resolve(mapOf("--radius-pill" to "499px")).pill) + assertEquals(RoundedCornerShape(0.dp), ShardStructure.resolve(mapOf("--radius-pill" to "0px")).pill) + } + + @Test + fun `every shadow option lands on its step`() { + fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation + assertEquals(0.dp, elevation("none")) + assertEquals(2.dp, elevation("0 8px 20px rgba(0, 0, 0, 0.25)")) + assertEquals(4.dp, elevation("0 14px 34px rgba(0, 0, 0, 0.3)")) + assertEquals(8.dp, elevation("0 18px 44px rgba(0, 0, 0, 0.45)")) + } + + @Test + fun `a shadow the options do not contain lands on the nearest step`() { + // The reason the match is on blur and not on the exact string: the + // Fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS' + // four values, because a preset's tokens never pass through that + // dropdown. An exact match would have dropped it on the floor. + fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation + assertEquals(4.dp, elevation("0 16px 38px rgba(0, 0, 0, 0.45)")) + assertEquals(8.dp, elevation("0 20px 60px rgba(0, 0, 0, 0.5)")) + assertEquals(2.dp, elevation("0 2px 4px rgba(0, 0, 0, 0.2)")) + } + + @Test + fun `an unreadable shadow keeps the shipped depth`() { + fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation + assertEquals(ShardStructure.Shipped.cardElevation, elevation("inset 0 0 nonsense")) + assertEquals(ShardStructure.Shipped.cardElevation, elevation("")) + // One length is an offset, not a blur — an incomplete value is not a + // reason to flatten every card on the shard. + assertEquals(ShardStructure.Shipped.cardElevation, elevation("0 14px")) + } + + @Test + fun `the shipped depth is the runic-gateway default, not flat`() { + // The one deliberate departure from §2: the app has been flat since M5 + // (material3's filled Card is Level0 and FeatureCard drew no shadow), + // while the preset the app was drawn from selects the "Default" shadow. + // §5.4 is applied as written, so an untouched instance gains this depth. + assertEquals(4.dp, ShardStructure.Shipped.cardElevation) + } +} -- 2.49.1