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 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 05:15:08 -05:00
parent 1530c83fbc
commit c7c49a9d6b
24 changed files with 466 additions and 70 deletions

View File

@@ -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(

View File

@@ -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(

View File

@@ -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()

View File

@@ -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<String>, 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(

View File

@@ -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,

View File

@@ -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.

View File

@@ -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)

View File

@@ -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<EquipmentDto>) {
@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()

View File

@@ -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)

View File

@@ -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(

View File

@@ -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<List<VendorSaleDto>>) {
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),

View File

@@ -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),

View File

@@ -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(

View File

@@ -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

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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),

View File

@@ -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()

View File

@@ -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(

View File

@@ -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<String, String>): 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
}

View File

@@ -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,
)
}

View File

@@ -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<WikiSummaryDto>, 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)

View File

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