feat(theme): old-vs-new styleguide @Preview for the color normalization

Replace the single-theme pair preview with a side-by-side comparison: each app
surface (Save button, checked/unchecked switch, settings tile, card, links,
error) rendered Old·Dark | New·Dark | Old·Light | New·Light with live contrast
and hexes. "Old" reconstructs the pre-normalization scheme (purple primary +
teal secondary, Material's violet-tinted surfaces/containers, deep default
onPrimary); "new" uses the real production schemes, so editing Theme.kt
re-renders both sides. Makes darkColors/lightColors internal so the preview
builds the actual current scheme rather than a copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EVwY2Qzth2EREWg8qLhyzF
This commit is contained in:
Claude
2026-07-17 04:03:03 +00:00
parent f937925fbf
commit d76c803e1d
2 changed files with 186 additions and 142 deletions
@@ -28,13 +28,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -48,205 +51,246 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.model.AccentColorType
import com.vitorpamplona.amethyst.model.ThemeType
import kotlin.math.max
import kotlin.math.min
// A living reference of every foreground/background pair the mobile theme uses, rendered with the
// real AmethystTheme colors (no approximation). Edit Theme.kt and the two columns below re-render.
// Left column is the dark theme, right column is the light theme, each with the current accent.
//
// Each pair references the actual ColorScheme roles the app consumes for that surface, so tweaking a
// role here previews everywhere it lands. `usage` names the concrete in-app surfaces.
private class Pair(
// Living reference + side-by-side "before / after" of the mobile theme, rendered with the real
// color schemes so editing Theme.kt re-renders both. Each row is one app surface (Save button,
// checked switch, settings tile, card, unchecked switch, …) shown four ways:
// Old·Dark | New·Dark | Old·Light | New·Light
// so the effect of the accent-normalization (+ the new filledAccent role) is directly comparable
// within each theme. "Old" reconstructs the pre-normalization scheme: purple primary + teal
// secondary with Material's default violet-tinted surfaces/containers and the deep default
// onPrimary — i.e. the theme before container-derivation, surface-neutralization and onPrimary=White.
// ----- reconstructed pre-normalization scheme (default purple accent) -----
private fun oldDarkColors(): ColorScheme =
darkColorScheme(
primary = Purple200,
secondary = Teal200,
tertiary = Teal200,
background = Color.Black,
surface = Color.Black,
surfaceDim = Color.Black,
surfaceVariant = Color(0xFF1D1A22),
)
private fun oldLightColors(): ColorScheme =
lightColorScheme(
primary = Purple500,
secondary = Teal200,
tertiary = Teal200,
surfaceContainerHighest = Color(0xFFECE6F0),
surfaceVariant = Color(0xFFFAF5FC),
)
// One app surface and how to resolve its fill/content from a scheme. `isOld` lets a surface pick a
// different role for the two eras — the Save button + switch used primary/onPrimary before and
// filledAccent/onFilledAccent now; everything else keeps its role and just shows the value drift.
private class StyleEntry(
val name: String,
val bg: (ColorScheme) -> Color,
val fg: (ColorScheme) -> Color,
val usage: String,
val bg: (ColorScheme, Boolean) -> Color,
val fg: (ColorScheme, Boolean) -> Color,
val highlight: Boolean = false,
)
private val COLOR_PAIRS =
private val STYLE_ENTRIES =
listOf(
Pair(
"filledAccent / onFilledAccent",
{ it.filledAccent },
{ it.onFilledAccent },
"Top-bar Save·Post·Send·Create button, standalone Save button, checked switch (track + thumb).",
StyleEntry(
"Save button · checked switch",
"Old: primary/onPrimary (washed out). New: the dedicated filledAccent fill.",
bg = { s, old -> if (old) s.primary else s.filledAccent },
fg = { s, old -> if (old) s.onPrimary else s.onFilledAccent },
highlight = true,
),
Pair(
"primary / onPrimary",
{ it.primary },
{ it.onPrimary },
"Floating action buttons (New Note, New Image…), generic filled buttons, sliders, progress.",
StyleEntry(
"FAB · filled button",
"primary / onPrimary — unchanged; still the pastel pairing on dark.",
bg = { s, _ -> s.primary },
fg = { s, _ -> s.onPrimary },
),
Pair(
"primaryContainer / onPrimaryContainer",
{ it.primaryContainer },
{ it.onPrimaryContainer },
"Settings icon tiles, tonal cards (DVM, wallet, relay info), tonal chips.",
StyleEntry(
"Settings tile · tonal card",
"primaryContainer / onPrimaryContainer — old = Material violet, new = accent-derived.",
bg = { s, _ -> s.primaryContainer },
fg = { s, _ -> s.onPrimaryContainer },
),
Pair(
"secondaryContainer / onSecondaryContainer",
{ it.secondaryContainer },
{ it.onSecondaryContainer },
"Selected-reaction highlight, relay-group chips, chatroom header, reply-mode toggle.",
StyleEntry(
"Selected reaction · chip",
"secondaryContainer / onSecondaryContainer.",
bg = { s, _ -> s.secondaryContainer },
fg = { s, _ -> s.onSecondaryContainer },
),
Pair(
"secondary / onSecondary",
{ it.secondary },
{ it.onSecondary },
"Teal accent (purple theme only); tertiary mirrors it.",
StyleEntry(
"Teal accent",
"secondary / onSecondary (purple theme).",
bg = { s, _ -> s.secondary },
fg = { s, _ -> s.onSecondary },
),
Pair(
"tertiaryContainer / onTertiaryContainer",
{ it.tertiaryContainer },
{ it.onTertiaryContainer },
"Tertiary tonal surfaces; derived from the accent alongside the other containers.",
StyleEntry(
"App background · body text",
"surface / onSurface — old carried Material's violet tint, new is neutral grey.",
bg = { s, _ -> s.surface },
fg = { s, _ -> s.onSurface },
),
Pair(
"error / onError",
{ it.error },
{ it.onError },
"Destructive dialogs and error states. Material baseline — not themed to the accent.",
StyleEntry(
"Secondary text",
"surface / onSurfaceVariant.",
bg = { s, _ -> s.surface },
fg = { s, _ -> s.onSurfaceVariant },
),
Pair(
"surface / onSurface",
{ it.surface },
{ it.onSurface },
"App background and primary body text everywhere.",
StyleEntry(
"Card · sheet",
"surfaceContainer / onSurface — old = Material default, new = neutral ramp.",
bg = { s, _ -> s.surfaceContainer },
fg = { s, _ -> s.onSurface },
),
Pair(
"surface / onSurfaceVariant",
{ it.surface },
{ it.onSurfaceVariant },
"Secondary text, timestamps, subtitles and icons on the background.",
StyleEntry(
"Unchecked switch",
"surfaceContainerHighest / outline (off-track + thumb).",
bg = { s, _ -> s.surfaceContainerHighest },
fg = { s, _ -> s.outline },
),
Pair(
"surfaceContainer / onSurface",
{ it.surfaceContainer },
{ it.onSurface },
"Cards, bottom sheets and elevated rows, with body text on top.",
),
Pair(
"surfaceVariant / onSurfaceVariant",
{ it.surfaceVariant },
{ it.onSurfaceVariant },
"Subtle filled areas — code blocks, inset panels, muted backgrounds.",
),
Pair(
"surfaceContainerHighest / outline",
{ it.surfaceContainerHighest },
{ it.outline },
"The UNCHECKED switch (off-track fill + thumb) and other inactive controls.",
StyleEntry(
"Error",
"error / onError — Material baseline in both.",
bg = { s, _ -> s.error },
fg = { s, _ -> s.onError },
),
)
private fun Color.hex(): String = "#%06X".format(0xFFFFFF and toArgb())
// WCAG contrast ratio between two opaque colors.
private fun contrastRatio(
a: Color,
b: Color,
): Float {
val la = a.luminance()
val lb = b.luminance()
val hi = max(la, lb)
val lo = min(la, lb)
return (hi + 0.05f) / (lo + 0.05f)
return (max(la, lb) + 0.05f) / (min(la, lb) + 0.05f)
}
private fun contrastLabel(ratio: Float): String =
when {
ratio >= 4.5f -> "AA"
ratio >= 3f -> "AA-lg"
else -> "low"
}
private const val CELL_WIDTH = 116
private const val LABEL_WIDTH = 196
@Composable
private fun ColorPairRow(pair: Pair) {
val scheme = MaterialTheme.colorScheme
val bg = pair.bg(scheme)
val fg = pair.fg(scheme)
private fun StyleCell(
entry: StyleEntry,
scheme: ColorScheme,
isOld: Boolean,
) {
val bg = entry.bg(scheme, isOld)
val fg = entry.fg(scheme, isOld)
val ratio = contrastRatio(bg, fg)
val label = MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.width(CELL_WIDTH.dp).padding(horizontal = 4.dp)) {
Box(
modifier =
Modifier
.size(width = 92.dp, height = 58.dp)
.clip(RoundedCornerShape(10.dp))
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(bg)
.then(
if (pair.highlight) {
Modifier.border(2.dp, scheme.onBackground, RoundedCornerShape(10.dp))
} else {
Modifier.border(1.dp, scheme.outlineVariant, RoundedCornerShape(10.dp))
},
).padding(8.dp),
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp))
.padding(6.dp),
) {
Text("Aa", color = fg, fontSize = 19.sp, fontWeight = FontWeight.Bold)
Text("Aa", color = fg, fontSize = 15.sp, fontWeight = FontWeight.Bold)
Text(
"${"%.1f".format(ratio)}:1 ${contrastLabel(ratio)}",
"%.1f".format(ratio),
color = fg,
fontSize = 8.5.sp,
fontSize = 8.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
Text(bg.hex(), color = label, fontSize = 8.5.sp, fontFamily = FontFamily.Monospace)
Text(fg.hex(), color = label, fontSize = 8.5.sp, fontFamily = FontFamily.Monospace)
}
}
Spacer(Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
@Composable
private fun StyleRow(
entry: StyleEntry,
oldDark: ColorScheme,
newDark: ColorScheme,
oldLight: ColorScheme,
newLight: ColorScheme,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.width(LABEL_WIDTH.dp).padding(end = 8.dp)) {
Text(
pair.name,
color = scheme.onBackground,
fontSize = 12.5.sp,
fontWeight = if (pair.highlight) FontWeight.Bold else FontWeight.Medium,
entry.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 11.5.sp,
fontWeight = if (entry.highlight) FontWeight.Bold else FontWeight.Medium,
)
Text(
"${bg.hex()} / ${fg.hex()}",
color = scheme.onSurfaceVariant,
fontSize = 10.5.sp,
fontFamily = FontFamily.Monospace,
)
Text(
pair.usage,
color = scheme.onSurfaceVariant,
fontSize = 10.sp,
maxLines = 2,
entry.usage,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 9.5.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
StyleCell(entry, oldDark, isOld = true)
StyleCell(entry, newDark, isOld = false)
StyleCell(entry, oldLight, isOld = true)
StyleCell(entry, newLight, isOld = false)
}
}
@Composable
private fun ColorPairsList() {
Column(
modifier = Modifier.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
"fg / bg pairs · $ACCENT_HINT",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
COLOR_PAIRS.forEach { ColorPairRow(it) }
}
private fun ColumnHeader(text: String) {
Text(
text,
modifier = Modifier.width(CELL_WIDTH.dp).padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.onSurface,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
)
}
private const val ACCENT_HINT = "accent = Settings → Accent Color (default Purple)"
@Preview(name = "Theme color pairs · dark | light", widthDp = 820, showBackground = true)
@Preview(name = "Theme styleguide — old vs new", widthDp = 720, showBackground = true)
@Composable
fun ThemeColorPairsPreview() {
ThemeComparisonRow {
ColorPairsList()
fun ThemeStyleguideOldVsNewPreview() {
val oldDark = oldDarkColors()
val newDark = darkColors(AccentColorType.PURPLE)
val oldLight = oldLightColors()
val newLight = lightColors(AccentColorType.PURPLE)
// Neutral dark chrome for the table itself; each cell paints its own scheme color.
AmethystTheme(ThemeType.DARK) {
Surface(color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
"Theme styleguide · before normalization → current",
color = MaterialTheme.colorScheme.onSurface,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 6.dp),
)
Row {
Spacer(Modifier.width(LABEL_WIDTH.dp))
ColumnHeader("Old·Dark")
ColumnHeader("New·Dark")
ColumnHeader("Old·Light")
ColumnHeader("New·Light")
}
STYLE_ENTRIES.forEach {
StyleRow(it, oldDark, newDark, oldLight, newLight)
}
}
}
}
}
@@ -131,7 +131,7 @@ fun AccentColorType.previewColor(dark: Boolean): Color = accentPrimary(this, dar
// Contrast colour (black or white) for content drawn on top of an accent swatch.
fun contentColorOnAccent(color: Color): Color = onAccent(color)
private fun darkColors(accent: AccentColorType): ColorScheme {
internal fun darkColors(accent: AccentColorType): ColorScheme {
val primary = accentPrimary(accent, dark = true)
val secondary = accentSecondary(accent, dark = true)
return darkColorScheme(
@@ -173,7 +173,7 @@ private fun darkColors(accent: AccentColorType): ColorScheme {
)
}
private fun lightColors(accent: AccentColorType): ColorScheme {
internal fun lightColors(accent: AccentColorType): ColorScheme {
val primary = accentPrimary(accent, dark = false)
val secondary = accentSecondary(accent, dark = false)
return lightColorScheme(