From c65913c62af7fa30d136549fb9399e475186a5b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 04:53:23 -0500 Subject: [PATCH] feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifteen themable tokens of GET /public/settings' theme map are parsed into a ShardPalette and applied field by field over the shipped M5 palette, which is the runic-gateway preset value for value — so an instance with no theme_visual row resolves back to a color scheme identical to the one the app shipped, not an approximation of it (THEMING_AND_NAV.md §2, §5.1). Ten tokens have a Material role and go through darkColorScheme; the other five reach screens through LocalShardPalette. ShardOnCta and ShardPillFg are derived rather than themed — they track --bg-deep and --accent-bright, following the server's rule that a value expressed in terms of another token is never frozen as a literal. RunicGatewayTheme(accent) becomes RunicGatewayTheme(appearance). The old signature put --accent on primary, which the contract assigns to --accent-bright; brand.accent now seeds --accent alone, and the server already resolves it as theme['--accent'] || env so the two can never disagree. ThemeComponents.kt was the only file reaching past MaterialTheme.colorScheme for a themable color; its seven now come from the palette and its seven semantic constants stay imported. Co-Authored-By: Claude --- .../java/com/runicgateway/app/MainActivity.kt | 15 +- .../app/ui/components/ThemeComponents.kt | 33 +++-- .../runicgateway/app/ui/theme/BrandColor.kt | 5 + .../runicgateway/app/ui/theme/ShardPalette.kt | 126 ++++++++++++++++ .../com/runicgateway/app/ui/theme/Theme.kt | 101 +++++++------ .../app/ui/theme/ShardColorSchemeTest.kt | 118 +++++++++++++++ .../app/ui/theme/ShardPaletteTest.kt | 135 ++++++++++++++++++ 7 files changed, 470 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/theme/ShardColorSchemeTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/theme/ShardPaletteTest.kt diff --git a/app/src/main/java/com/runicgateway/app/MainActivity.kt b/app/src/main/java/com/runicgateway/app/MainActivity.kt index 069c51c..24ecace 100644 --- a/app/src/main/java/com/runicgateway/app/MainActivity.kt +++ b/app/src/main/java/com/runicgateway/app/MainActivity.kt @@ -31,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver import com.runicgateway.app.ui.RunicApp import com.runicgateway.app.ui.components.LoadingView import com.runicgateway.app.ui.connect.ConnectScreen +import com.runicgateway.app.data.appearance.SiteAppearance import com.runicgateway.app.ui.theme.RunicGatewayTheme -import com.runicgateway.app.ui.theme.parseBrandColor import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import javax.inject.Inject @@ -40,8 +40,8 @@ import javax.inject.Inject /** * Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run * connect screen until a shard site is configured (§3), then the main app. - * The Material theme is seeded from the per-shard brand accent, and asset-path - * resolution is provided to the whole tree. + * The Material theme is resolved from the shard's published appearance (M12), + * and asset-path resolution is provided to the whole tree. */ @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -70,8 +70,11 @@ class MainActivity : ComponentActivity() { val appViewModel: AppViewModel = hiltViewModel() val state by appViewModel.state.collectAsStateWithLifecycle() - val appearance = (state as? AppState.Ready)?.appearance - val accent = appearance?.brand?.let { parseBrandColor(it.accent) } + // The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the + // resolved token map is applied field by field over the shipped palette, + // so NONE — before the site is connected, or when settings can't be + // read — is the app exactly as it shipped. + val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE // The admin's theme and nav can change while the app is backgrounded // (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session @@ -81,7 +84,7 @@ class MainActivity : ComponentActivity() { onPauseOrDispose { } } - RunicGatewayTheme(accent = accent) { + RunicGatewayTheme(appearance = appearance) { CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) { Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt b/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt index 15ccc19..a0ad056 100644 --- a/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt +++ b/app/src/main/java/com/runicgateway/app/ui/components/ThemeComponents.kt @@ -23,15 +23,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.runicgateway.app.ui.theme.ShardCardBottom -import com.runicgateway.app.ui.theme.ShardCardTop +import com.runicgateway.app.ui.theme.LocalShardPalette import com.runicgateway.app.ui.theme.ShardDanger import com.runicgateway.app.ui.theme.ShardDangerBg -import com.runicgateway.app.ui.theme.ShardElevated -import com.runicgateway.app.ui.theme.ShardFaint -import com.runicgateway.app.ui.theme.ShardOutline -import com.runicgateway.app.ui.theme.ShardPillBg -import com.runicgateway.app.ui.theme.ShardPillFg import com.runicgateway.app.ui.theme.ShardSuccess import com.runicgateway.app.ui.theme.ShardSuccessBg import com.runicgateway.app.ui.theme.ShardSuccessDot @@ -43,6 +37,13 @@ import com.runicgateway.app.ui.theme.ShardWarningBg * (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card, * 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 + * stay imported directly — those are semantic and never themed, mirroring the + * server's `FIXED_TOKENS`. */ /** Semantic tone for a [StatusPill] / [OnlineDot]. */ @@ -50,11 +51,13 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info } private data class PillColors(val fg: Color, val bg: Color) +@Composable private fun toneColors(tone: PillTone): PillColors = when (tone) { PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg) PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg) PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg) - PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg) + PillTone.Neutral, PillTone.Info -> + LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) } } /** @@ -81,7 +84,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) { PillTone.Success -> ShardSuccessDot PillTone.Warning -> ShardWarning PillTone.Danger -> ShardDanger - PillTone.Neutral, PillTone.Info -> ShardFaint + PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint } Box(modifier.size(8.dp).clip(CircleShape).background(color)) } @@ -95,7 +98,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) { Text( text = text.uppercase(), style = MaterialTheme.typography.labelSmall, - color = ShardFaint, + color = LocalShardPalette.current.faint, modifier = modifier, ) } @@ -111,12 +114,13 @@ fun FeatureCard( contentPadding: Int = 18, content: @Composable ColumnScope.() -> Unit, ) { + val palette = LocalShardPalette.current Box( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom))) - .border(1.dp, ShardOutline, RoundedCornerShape(12.dp)), + .background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom))) + .border(1.dp, palette.outline, RoundedCornerShape(12.dp)), ) { Column(Modifier.padding(contentPadding.dp), content = content) } @@ -129,13 +133,14 @@ fun FeatureCard( @Composable fun StatBar(fraction: Float, modifier: Modifier = Modifier) { val pct = fraction.coerceIn(0f, 1f) + val palette = LocalShardPalette.current Box( modifier = modifier .fillMaxWidth() .height(6.dp) .clip(RoundedCornerShape(3.dp)) - .background(ShardElevated) - .border(1.dp, ShardOutline, RoundedCornerShape(3.dp)), + .background(palette.elevated) + .border(1.dp, palette.outline, RoundedCornerShape(3.dp)), ) { Box( Modifier diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt b/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt index ab95ce3..efe8b2f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/BrandColor.kt @@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color * without the leading `#`) into a Compose [Color]. Returns null for anything * unparseable so the theme falls back to its default scheme (PLAN.md §3, §5). * Pure logic — covered by JVM unit tests. + * + * Also the parser for every color token in the shard's resolved theme map + * ([ShardPalette.resolve], M12): the server validates those as `#RGB` or + * `#RRGGBB` on write, and a null here is what makes a token that slipped + * through anyway cost only itself. */ fun parseBrandColor(hex: String?): Color? { if (hex.isNullOrBlank()) return null diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt b/app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt new file mode 100644 index 0000000..5b8b947 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt @@ -0,0 +1,126 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * The shard's resolved color palette — the fifteen themable tokens of + * `GET /public/settings`' `theme` map, parsed into Compose colors + * (THEMING_AND_NAV.md §5.1). + * + * **The default value of every field is the shipped constant from + * [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette + * *is* the website's `runic-gateway` preset, value for value, because both were + * drawn from the same `theme.css`. So [Shipped] renders exactly as the app did + * before this milestone, and an instance with no `theme_visual` row resolves + * back to it token by token (§2, AC-1). + * + * The palette has two consumers and one resolution: ten of the fifteen tokens + * have a Material role and are fed into the [androidx.compose.material3.ColorScheme] + * by [shardColorScheme]; the other five have none, and reach the screens that + * need them through [LocalShardPalette]. + */ +@Immutable +data class ShardPalette( + /** `--bg-deep` — the page behind everything. */ + val page: Color = ShardPage, + /** `--bg` — the screen background. */ + val surface: Color = ShardSurface, + /** `--panel-flat` — top bar, inputs, drawer, list tracks. */ + val elevated: Color = ShardElevated, + /** `--panel-a` — feature-card gradient, top. No Material role. */ + val cardTop: Color = ShardCardTop, + /** `--panel-b` — feature-card gradient, bottom. No Material role. */ + val cardBottom: Color = ShardCardBottom, + /** `--line` — borders and input outlines. */ + val outline: Color = ShardOutline, + /** `--line-soft` — hairline row dividers. */ + val divider: Color = ShardDivider, + /** `--ink` — the brightest headings. No Material role. */ + val heading: Color = ShardHeading, + /** `--head` — heading on a surface. No Material role. */ + val headingDim: Color = ShardHeadingDim, + /** `--text` — body copy. */ + val body: Color = ShardBody, + /** `--muted` — secondary text. */ + val muted: Color = ShardMuted, + /** `--dim` — meta and faint labels. No Material role. */ + val faint: Color = ShardFaint, + /** `--accent` — links and secondary highlights. */ + val accent: Color = ShardAccent, + /** `--accent-bright` — the filled CTA surface. */ + val cta: Color = ShardCta, + /** `--blue` — the neutral/info pill background. */ + val pillBg: Color = ShardPillBg, +) { + /** + * Text drawn on the [cta] fill. **Derived, never themed** — it tracks + * `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a + * literal (§5.1). A value expressed in terms of another token must follow + * it, or a future light preset inherits a dark one and looks broken. + */ + val onCta: Color get() = page + + /** + * The neutral/info pill's foreground. Also derived: `ShardPillFg` and + * `ShardCta` are the same `--accent-bright` value, so the pill's text + * follows the CTA fill rather than being a sixteenth token the contract + * does not have. + */ + val pillFg: Color get() = cta + + companion object { + /** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */ + val Shipped = ShardPalette() + + /** + * Resolve a `theme` token map into a palette, **field by field** (§2). + * A token that is missing, blank or unparseable falls back to its + * shipped value on its own; a bad `--accent` must never discard a good + * `--bg` beside it (AC-2). + * + * [brandAccent] is the pre-feature branding path and must keep working: + * an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row + * still tints its links and highlights. It seeds `--accent` only — the + * server resolves `brand.accent` as `theme['--accent'] || env`, so the + * token always wins where both exist. + */ + fun resolve(theme: Map, brandAccent: Color? = null): ShardPalette { + if (theme.isEmpty() && brandAccent == null) return Shipped + fun token(name: String, shipped: Color): Color = + parseBrandColor(theme[name]) ?: shipped + return ShardPalette( + page = token("--bg-deep", ShardPage), + surface = token("--bg", ShardSurface), + elevated = token("--panel-flat", ShardElevated), + cardTop = token("--panel-a", ShardCardTop), + cardBottom = token("--panel-b", ShardCardBottom), + outline = token("--line", ShardOutline), + divider = token("--line-soft", ShardDivider), + heading = token("--ink", ShardHeading), + headingDim = token("--head", ShardHeadingDim), + body = token("--text", ShardBody), + muted = token("--muted", ShardMuted), + faint = token("--dim", ShardFaint), + accent = token("--accent", brandAccent ?: ShardAccent), + cta = token("--accent-bright", ShardCta), + pillBg = token("--blue", ShardPillBg), + ) + } + } +} + +/** + * The live palette, for the five tokens with no Material role and for the + * components that draw the card gradient. Everything that *can* go through + * `MaterialTheme.colorScheme` still should — this is the escape hatch, not the + * front door. + * + * Defaulted to [ShardPalette.Shipped] so previews and any composable outside + * [RunicGatewayTheme] still draw the shipped palette rather than crashing. + */ +val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped } diff --git a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt index 8ce1d0f..5103b55 100644 --- a/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/runicgateway/app/ui/theme/Theme.kt @@ -4,42 +4,49 @@ 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.ui.graphics.Color +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.unit.dp +import com.runicgateway.app.data.appearance.SiteAppearance /** - * The shard-website color scheme (M5 design pass). The app is **dark-only** — the - * design is a single deep blue-black theme, so there is no light variant and the - * system light/dark setting is intentionally ignored. Material roles are mapped - * onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on - * the theme without per-screen color work. + * Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md + * §5.1). The app is **dark-only** — the design is a single deep blue-black + * theme, so there is no light variant and the system light/dark setting is + * intentionally ignored; every v1 preset on the website is dark too. + * + * Ten of the palette's fifteen tokens land here, which is why the ~20 + * token-based screens take on a shard's theme with no per-screen color work. + * Pure, so the no-op proof (AC-1) can assert on it directly. */ -private val ShardColorScheme = darkColorScheme( - primary = ShardCta, // filled CTA buttons - onPrimary = ShardOnCta, - secondary = ShardAccent, // links / secondary highlights - onSecondary = ShardOnCta, - tertiary = ShardAccent, - onTertiary = ShardOnCta, - background = ShardPage, - onBackground = ShardBody, - surface = ShardSurface, - onSurface = ShardBody, - surfaceVariant = ShardElevated, - onSurfaceVariant = ShardMuted, - surfaceContainer = ShardElevated, - surfaceContainerHigh = ShardElevated, - surfaceContainerLow = ShardSurface, - outline = ShardOutline, - outlineVariant = ShardDivider, - secondaryContainer = ShardPillBg, // neutral chips / selected drawer item - onSecondaryContainer = ShardPillFg, +internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme( + primary = palette.cta, // filled CTA buttons + onPrimary = palette.onCta, + secondary = palette.accent, // links / secondary highlights + onSecondary = palette.onCta, + tertiary = palette.accent, + onTertiary = palette.onCta, + background = palette.page, + onBackground = palette.body, + surface = palette.surface, + onSurface = palette.body, + surfaceVariant = palette.elevated, + onSurfaceVariant = palette.muted, + surfaceContainer = palette.elevated, + surfaceContainerHigh = palette.elevated, + surfaceContainerLow = palette.surface, + outline = palette.outline, + outlineVariant = palette.divider, + secondaryContainer = palette.pillBg, // neutral chips / selected drawer item + onSecondaryContainer = palette.pillFg, + // Semantic, never themed — mirrors the server's FIXED_TOKENS (§4). error = ShardDanger, - onError = ShardOnCta, + onError = palette.onCta, errorContainer = ShardDangerBg, onErrorContainer = ShardDanger, ) @@ -54,27 +61,35 @@ private val ShardShapes = Shapes( ) /** - * App theme. The color scheme is the fixed shard-website dark palette; when a shard - * publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and - * secondary roles so buttons and highlights carry that shard's color while the rest - * of the deep blue-black system stays intact. With no accent, the slate default is - * used. + * 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 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. */ @Composable fun RunicGatewayTheme( - accent: Color? = null, + appearance: SiteAppearance = SiteAppearance.NONE, content: @Composable () -> Unit, ) { - val colorScheme = if (accent != null) { - ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent) - } else { - ShardColorScheme + val palette = remember(appearance) { + ShardPalette.resolve( + theme = appearance.theme, + brandAccent = parseBrandColor(appearance.brand?.accent), + ) } + val colorScheme = remember(palette) { shardColorScheme(palette) } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - shapes = ShardShapes, - content = content, - ) + CompositionLocalProvider(LocalShardPalette provides palette) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + shapes = ShardShapes, + content = content, + ) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/ShardColorSchemeTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/ShardColorSchemeTest.kt new file mode 100644 index 0000000..2c6949a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/ShardColorSchemeTest.kt @@ -0,0 +1,118 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * **AC-1, the no-op proof** (THEMING_AND_NAV.md §7). An instance with no + * `theme_visual` row must resolve to a color scheme equal to the one the app + * shipped before M12 — not "close enough", identical, because the app's M5 + * palette *is* the website's `runic-gateway` preset. + * + * Two things this test has to work around: + * + * - **`ColorScheme` does not implement `equals`** (checked against material3 + * 1.3.0), so "the full scheme, not a spot check" is a field-by-field compare. + * It is done by reflection over every color-valued getter rather than by a + * hand-written list, so a role that is added to Material — or one the mapping + * forgets — cannot silently escape the assertion. + * - The expected value is a **verbatim copy of the pre-M12 `ShardColorScheme`**, + * kept here rather than referenced, so the proof is against what the app used + * to do and not against whatever [shardColorScheme] does today. + */ +class ShardColorSchemeTest { + + /** The scheme exactly as `ui/theme/Theme.kt` declared it before M12. */ + private val preM12Scheme = darkColorScheme( + primary = ShardCta, + onPrimary = ShardOnCta, + secondary = ShardAccent, + onSecondary = ShardOnCta, + tertiary = ShardAccent, + onTertiary = ShardOnCta, + background = ShardPage, + onBackground = ShardBody, + surface = ShardSurface, + onSurface = ShardBody, + surfaceVariant = ShardElevated, + onSurfaceVariant = ShardMuted, + surfaceContainer = ShardElevated, + surfaceContainerHigh = ShardElevated, + surfaceContainerLow = ShardSurface, + outline = ShardOutline, + outlineVariant = ShardDivider, + secondaryContainer = ShardPillBg, + onSecondaryContainer = ShardPillFg, + error = ShardDanger, + onError = ShardOnCta, + errorContainer = ShardDangerBg, + onErrorContainer = ShardDanger, + ) + + @Test + fun `the shipped palette reproduces the pre-M12 color scheme exactly`() { + assertEquals(roles(preM12Scheme), roles(shardColorScheme(ShardPalette.Shipped))) + } + + /** The same claim from the other end: an absent theme map is the shipped app. */ + @Test + fun `an absent theme map reproduces the pre-M12 color scheme`() { + val resolved = shardColorScheme(ShardPalette.resolve(emptyMap())) + assertEquals(roles(preM12Scheme), roles(resolved)) + } + + /** Sanity: the comparison is capable of failing, and covers the whole scheme. */ + @Test + fun `the role comparison sees every color role`() { + val roles = roles(preM12Scheme) + // material3 1.3.0 declares 36 color roles; fewer than that means the + // reflection has stopped seeing them and the comparison above went hollow. + assertTrue("expected the full Material role set, got ${roles.keys}", roles.size >= 36) + assertEquals(ShardCta, roles["primary"]) + assertNotEquals( + roles, + roles(shardColorScheme(ShardPalette.Shipped.copy(cta = Color(0xFFC9973F)))), + ) + } + + /** A themed shard moves the roles its tokens own, and only those. */ + @Test + fun `a theme token reaches its Material role`() { + val themed = shardColorScheme( + ShardPalette.resolve(mapOf("--accent" to "#c9973f", "--bg-deep" to "#120c07")), + ) + val roles = roles(themed) + assertEquals(Color(0xFFC9973F), roles["secondary"]) + assertEquals(Color(0xFFC9973F), roles["tertiary"]) + assertEquals(Color(0xFF120C07), roles["background"]) + assertEquals(Color(0xFF120C07), roles["onPrimary"]) // derived: onCta tracks --bg-deep + assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens + } + + /** + * Every color role of a scheme, by name. `Color` is a value class, so the + * roles are the `long`-returning getters and their names carry Kotlin's + * mangling suffix (`getPrimary-0d7_KjU`), which is stripped here. + */ + private fun roles(scheme: ColorScheme): Map = + ColorScheme::class.java.declaredMethods + .filter { + it.name.startsWith("get") && + it.returnType == java.lang.Long.TYPE && + it.parameterCount == 0 + } + .associate { method -> + val name = method.name.removePrefix("get").substringBefore('-') + .replaceFirstChar { it.lowercase() } + // The getter hands back Color's packed ULong bits, not an ARGB int. + name to Color((method.invoke(scheme) as Long).toULong()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/theme/ShardPaletteTest.kt b/app/src/test/java/com/runicgateway/app/ui/theme/ShardPaletteTest.kt new file mode 100644 index 0000000..6138576 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/theme/ShardPaletteTest.kt @@ -0,0 +1,135 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.theme + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * The token → palette resolution (THEMING_AND_NAV.md §5.1) and its + * forgiving-on-read rule (§2, AC-2). + */ +class ShardPaletteTest { + + /** AC-1, the palette half: no theme row resolves to the shipped M5 palette. */ + @Test + fun `no tokens resolve to the shipped palette`() { + assertSame(ShardPalette.Shipped, ShardPalette.resolve(emptyMap())) + } + + /** Every one of the fifteen has a home, and lands in the right one. */ + @Test + fun `all fifteen tokens map to their palette field`() { + val p = ShardPalette.resolve( + mapOf( + "--bg" to "#010101", + "--bg-deep" to "#020202", + "--panel-a" to "#030303", + "--panel-b" to "#040404", + "--panel-flat" to "#050505", + "--line" to "#060606", + "--line-soft" to "#070707", + "--accent" to "#080808", + "--accent-bright" to "#090909", + "--ink" to "#0a0a0a", + "--head" to "#0b0b0b", + "--text" to "#0c0c0c", + "--muted" to "#0d0d0d", + "--dim" to "#0e0e0e", + "--blue" to "#0f0f0f", + ), + ) + assertEquals(Color(0xFF010101), p.surface) + assertEquals(Color(0xFF020202), p.page) + assertEquals(Color(0xFF030303), p.cardTop) + assertEquals(Color(0xFF040404), p.cardBottom) + assertEquals(Color(0xFF050505), p.elevated) + assertEquals(Color(0xFF060606), p.outline) + assertEquals(Color(0xFF070707), p.divider) + assertEquals(Color(0xFF080808), p.accent) + assertEquals(Color(0xFF090909), p.cta) + assertEquals(Color(0xFF0A0A0A), p.heading) + assertEquals(Color(0xFF0B0B0B), p.headingDim) + assertEquals(Color(0xFF0C0C0C), p.body) + assertEquals(Color(0xFF0D0D0D), p.muted) + assertEquals(Color(0xFF0E0E0E), p.faint) + assertEquals(Color(0xFF0F0F0F), p.pillBg) + } + + /** + * AC-2. One valid token applies; four malformed ones each fall back on their + * own, and none of them costs the good one beside it. + */ + @Test + fun `a malformed token costs only itself`() { + val p = ShardPalette.resolve( + mapOf( + "--accent" to "#c9973f", // the one valid token + "--bg" to "not a color", + "--ink" to "", + "--line" to "#12", + "--text" to "rgb(1, 2, 3)", + ), + ) + assertEquals(Color(0xFFC9973F), p.accent) + assertEquals(ShardPalette.Shipped.surface, p.surface) + assertEquals(ShardPalette.Shipped.heading, p.heading) + assertEquals(ShardPalette.Shipped.outline, p.outline) + assertEquals(ShardPalette.Shipped.body, p.body) + } + + /** A token the app does not know is not an error — it is simply not read. */ + @Test + fun `unknown tokens are ignored`() { + val p = ShardPalette.resolve(mapOf("--mode-live" to "#ff0000", "--radius-card" to "2px")) + assertEquals(ShardPalette.Shipped, p) + } + + /** + * The pre-feature branding path: a `BRAND_ACCENT_COLOR` with no theme row + * still tints links and highlights, and nothing else moves. + */ + @Test + fun `brand accent seeds only the accent token`() { + val p = ShardPalette.resolve(emptyMap(), brandAccent = Color(0xFFC9973F)) + assertEquals(Color(0xFFC9973F), p.accent) + assertEquals(ShardPalette.Shipped.copy(accent = Color(0xFFC9973F)), p) + } + + /** + * The server resolves `brand.accent` as `theme['--accent'] || env`, so the two + * can only disagree if a client is holding a stale brand — the token wins. + */ + @Test + fun `the accent token beats the brand accent`() { + val p = ShardPalette.resolve(mapOf("--accent" to "#4f8ef7"), brandAccent = Color(0xFFC9973F)) + assertEquals(Color(0xFF4F8EF7), p.accent) + } + + /** An unparseable brand accent is the same as none. */ + @Test + fun `a malformed brand accent falls back to the shipped accent`() { + assertEquals(ShardPalette.Shipped, ShardPalette.resolve(emptyMap(), parseBrandColor("nope"))) + } + + /** + * The derived pair (§5.1): expressed in terms of another token, so they must + * follow it rather than being frozen at the shipped literal. + */ + @Test + fun `derived colors track the tokens they are expressed in`() { + val p = ShardPalette.resolve(mapOf("--bg-deep" to "#120c07", "--accent-bright" to "#e8c374")) + assertEquals(Color(0xFF120C07), p.onCta) + assertEquals(Color(0xFFE8C374), p.pillFg) + } + + /** And on the shipped palette they are exactly today's two constants. */ + @Test + fun `derived colors are the shipped constants by default`() { + assertEquals(ShardOnCta, ShardPalette.Shipped.onCta) + assertEquals(ShardPillFg, ShardPalette.Shipped.pillFg) + } +}