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:
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal 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 }
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user