feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 04:53:23 -05:00
parent 17e9451494
commit c65913c62a
7 changed files with 470 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, Color> =
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())
}
}

View File

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