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) + } +}