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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user