From 2ab20e39588d7f31ea31aa11308dcbed496ea90d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 12:40:17 -0400 Subject: [PATCH] refactor(android): split tokens into Dark/Light/Flat + LocalFabledSwordTheme + light mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-task 1 of the design-drift correction. Adds light theme support and adopts the Flutter pattern of consuming tokens directly via a theme-extension equivalent (CompositionLocal data class) rather than fitting everything into Material's ColorScheme roles. Files: - theme/FabledSwordTokens.kt — split the monolith into three objects: FabledSwordDarkTokens (#14171A obsidian, etc.), FabledSwordLightTokens (#F8F5EE obsidian, #14171A parchment — note the semantic inversion, names are roles not literal colors), FabledSwordFlatTokens (moss/bronze/oxblood/warning/error/info/ accent/onAction + radii). The old FabledSwordTokens object is kept as a @Deprecated alias re-exporting dark+flat — existing call sites compile with a warning until migrated. - theme/FabledSwordTheme.kt — NEW. Data class holding the full 14-color set + radii, with Dark/Light companion factories. LocalFabledSwordTheme CompositionLocal exposes the active variant. - theme/MinstrelTheme.kt — both darkColorScheme and lightColorScheme defined; MinstrelTheme composable accepts darkOverride and falls back to isSystemInDarkTheme(). Provides LocalFabledSwordTheme + keeps LocalActionColors for back-compat. - widget files (AlbumCard, MiniPlayer, NowPlayingScreen, ActionColors) migrated to import FabledSwordFlatTokens directly for radii / action colors (mode-independent). Sub-tasks 2-4 (shell, Home, Library tabs) will follow, each its own commit. Detail screens stay as stubs until their feature phases. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/library/widgets/AlbumCard.kt | 4 +- .../minstrel/player/ui/MiniPlayer.kt | 4 +- .../minstrel/player/ui/NowPlayingScreen.kt | 4 +- .../minstrel/theme/ActionColors.kt | 10 +- .../minstrel/theme/FabledSwordTheme.kt | 97 ++++++++++++++++ .../minstrel/theme/FabledSwordTokens.kt | 107 ++++++++++++++---- .../minstrel/theme/MinstrelTheme.kt | 107 +++++++++++++----- 7 files changed, 267 insertions(+), 66 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTheme.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt index ad48454e..65deddcc 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/widgets/AlbumCard.kt @@ -26,7 +26,7 @@ import com.composables.icons.lucide.Disc3 import com.composables.icons.lucide.Lucide import androidx.compose.material3.Icon import com.fabledsword.minstrel.models.AlbumRef -import com.fabledsword.minstrel.theme.FabledSwordTokens +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens /** * One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp @@ -49,7 +49,7 @@ fun AlbumCard( Box( modifier = Modifier .size(144.dp) - .clip(RoundedCornerShape(FabledSwordTokens.radiusSm)), + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)), contentAlignment = Alignment.Center, ) { if (album.coverUrl.isEmpty()) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt index 26ba2741..de8b1bf4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/MiniPlayer.kt @@ -29,7 +29,7 @@ import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Music import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Play -import com.fabledsword.minstrel.theme.FabledSwordTokens +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens private const val MINI_BAR_HEIGHT_DP = 64 private const val MINI_BAR_TONAL_ELEVATION_DP = 4 @@ -69,7 +69,7 @@ fun MiniPlayer( Box( modifier = Modifier .size(COVER_SIZE_DP.dp) - .clip(RoundedCornerShape(FabledSwordTokens.radiusSm)), + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusSm)), contentAlignment = Alignment.Center, ) { Icon( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 98d6e0a5..250ebed6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -36,7 +36,7 @@ import com.composables.icons.lucide.Play import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipForward import com.fabledsword.minstrel.shared.widgets.EmptyState -import com.fabledsword.minstrel.theme.FabledSwordTokens +import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.LocalActionColors private const val COVER_MAX_WIDTH_DP = 320 @@ -101,7 +101,7 @@ private fun CoverPlaceholder() { .widthIn(max = COVER_MAX_WIDTH_DP.dp) .fillMaxWidth() .aspectRatio(1f) - .clip(RoundedCornerShape(FabledSwordTokens.radiusLg)), + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusLg)), contentAlignment = Alignment.Center, ) { Icon( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/ActionColors.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/ActionColors.kt index 34d8109d..ab0adf0c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/theme/ActionColors.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/ActionColors.kt @@ -5,7 +5,7 @@ import androidx.compose.ui.graphics.Color /** * Semantic action colors. The FabledSword design system reserves Moss/Bronze/ - * Oxblood for action buttons; the forest-teal accent (`FabledSwordTokens.accent`) + * Oxblood for action buttons; the forest-teal accent (`FabledSwordFlatTokens.accent`) * is for surfaces / highlights / non-action emphasis only and must NEVER be * used as a button color. * @@ -21,9 +21,9 @@ data class ActionColors( val LocalActionColors = staticCompositionLocalOf { ActionColors( - primary = FabledSwordTokens.moss, - secondary = FabledSwordTokens.bronze, - destructive = FabledSwordTokens.oxblood, - onAction = FabledSwordTokens.onAction, + primary = FabledSwordFlatTokens.moss, + secondary = FabledSwordFlatTokens.bronze, + destructive = FabledSwordFlatTokens.oxblood, + onAction = FabledSwordFlatTokens.onAction, ) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTheme.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTheme.kt new file mode 100644 index 00000000..288c19bc --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTheme.kt @@ -0,0 +1,97 @@ +package com.fabledsword.minstrel.theme + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp + +/** + * Mode-resolved snapshot of the FabledSword design system, exposed via + * [LocalFabledSwordTheme]. Mirrors the Flutter `FabledSwordTheme` + * ThemeExtension — most Flutter widgets read `Theme.of(context).extension< + * FabledSwordTheme>()!.parchment` directly rather than via `colorScheme`, + * and the Android port follows the same convention to keep token + * semantics centralized rather than scattered across Material role mappings. + * + * UI code reads tokens via `LocalFabledSwordTheme.current.parchment`, + * `.iron`, `.accent`, etc. The MinstrelTheme composable swaps between + * the dark and light instances based on system mode. + */ +data class FabledSwordTheme( + // Surface ladder (lightest text → darkest bg). + val parchment: Color, + val vellum: Color, + val ash: Color, + val pewter: Color, + val slate: Color, + val iron: Color, + val obsidian: Color, + + // Mode-independent action + status colors. + val moss: Color, + val bronze: Color, + val oxblood: Color, + val warning: Color, + val error: Color, + val info: Color, + val accent: Color, + val onAction: Color, + + // Radii. + val radiusSm: Dp, + val radiusMd: Dp, + val radiusLg: Dp, + val radiusXl: Dp, +) { + companion object { + val Dark: FabledSwordTheme = FabledSwordTheme( + parchment = FabledSwordDarkTokens.parchment, + vellum = FabledSwordDarkTokens.vellum, + ash = FabledSwordDarkTokens.ash, + pewter = FabledSwordDarkTokens.pewter, + slate = FabledSwordDarkTokens.slate, + iron = FabledSwordDarkTokens.iron, + obsidian = FabledSwordDarkTokens.obsidian, + moss = FabledSwordFlatTokens.moss, + bronze = FabledSwordFlatTokens.bronze, + oxblood = FabledSwordFlatTokens.oxblood, + warning = FabledSwordFlatTokens.warning, + error = FabledSwordFlatTokens.error, + info = FabledSwordFlatTokens.info, + accent = FabledSwordFlatTokens.accent, + onAction = FabledSwordFlatTokens.onAction, + radiusSm = FabledSwordFlatTokens.radiusSm, + radiusMd = FabledSwordFlatTokens.radiusMd, + radiusLg = FabledSwordFlatTokens.radiusLg, + radiusXl = FabledSwordFlatTokens.radiusXl, + ) + + val Light: FabledSwordTheme = FabledSwordTheme( + parchment = FabledSwordLightTokens.parchment, + vellum = FabledSwordLightTokens.vellum, + ash = FabledSwordLightTokens.ash, + pewter = FabledSwordLightTokens.pewter, + slate = FabledSwordLightTokens.slate, + iron = FabledSwordLightTokens.iron, + obsidian = FabledSwordLightTokens.obsidian, + moss = FabledSwordFlatTokens.moss, + bronze = FabledSwordFlatTokens.bronze, + oxblood = FabledSwordFlatTokens.oxblood, + warning = FabledSwordFlatTokens.warning, + error = FabledSwordFlatTokens.error, + info = FabledSwordFlatTokens.info, + accent = FabledSwordFlatTokens.accent, + onAction = FabledSwordFlatTokens.onAction, + radiusSm = FabledSwordFlatTokens.radiusSm, + radiusMd = FabledSwordFlatTokens.radiusMd, + radiusLg = FabledSwordFlatTokens.radiusLg, + radiusXl = FabledSwordFlatTokens.radiusXl, + ) + } +} + +/** + * Reads as `LocalFabledSwordTheme.current.X` from anywhere inside a + * [MinstrelTheme] composable scope. Default = dark; the MinstrelTheme + * composable overrides this based on system mode. + */ +val LocalFabledSwordTheme = staticCompositionLocalOf { FabledSwordTheme.Dark } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt index a30bc57f..7309a465 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/FabledSwordTokens.kt @@ -4,39 +4,96 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp /** - * FabledSword design-system tokens. Pure data — no theme wiring. + * Raw color values for the dark surface cohort. Source of truth: + * `flutter_client/shared/fabledsword.tokens.json` (dark block). * - * Source of truth: `flutter_client/shared/fabledsword.tokens.json`. When that - * file changes, mirror the new hex values here. (Native v1 doesn't run the - * Flutter `tool/gen_tokens.dart` generator — manual sync until a cross-language - * codegen pipeline lands.) - * - * Mirrors flutter_client/lib/theme/tokens.dart, dark surfaces + flat. Light - * theme support deferred to post-v1; the user's primary mode is dark. + * Token names are **semantic roles**, not literal colors. In dark + * mode, `parchment` is light text on dark surfaces (#E8E4D8); in + * [FabledSwordLightTokens], `parchment` is dark text on light + * surfaces (#14171A). Consume tokens through [LocalFabledSwordTheme], + * never reference these raw objects from UI code — the mode-switch + * stops working otherwise. */ -object FabledSwordTokens { - // Dark surface tokens. - val obsidian = Color(0xFF14171A) - val iron = Color(0xFF1E2228) - val slate = Color(0xFF2C313A) - val pewter = Color(0xFF3F4651) +object FabledSwordDarkTokens { + val obsidian = Color(0xFF14171A) + val iron = Color(0xFF1E2228) + val slate = Color(0xFF2C313A) + val pewter = Color(0xFF3F4651) val parchment = Color(0xFFE8E4D8) - val vellum = Color(0xFFC2BFB4) - val ash = Color(0xFF9C9A92) + val vellum = Color(0xFFC2BFB4) + val ash = Color(0xFF9C9A92) +} - // Flat (theme-mode-independent) tokens. - val moss = Color(0xFF4A5D3F) // primary action - val bronze = Color(0xFF8B7355) // secondary action - val oxblood = Color(0xFF6B2118) // destructive action - val warning = Color(0xFF8B6F1E) - val error = Color(0xFFC04A1F) - val info = Color(0xFF3D5A6E) - val accent = Color(0xFF4A6B5C) // forest-teal — NEVER for actions +/** + * Raw color values for the light surface cohort. Mirrors + * `fabledsword.tokens.json`'s light block. + * + * Same names as [FabledSwordDarkTokens] but inverted semantics — see + * that doc-comment. + */ +object FabledSwordLightTokens { + val obsidian = Color(0xFFF8F5EE) + val iron = Color(0xFFECE6D5) + val slate = Color(0xFFDCD3BD) + val pewter = Color(0xFFB8AE94) + val parchment = Color(0xFF14171A) + val vellum = Color(0xFF2C313A) + val ash = Color(0xFF5C6068) +} + +/** + * Mode-independent (flat) tokens. Action colors live here; they're + * the same in light + dark. Per the design system: Moss / Bronze / + * Oxblood are action colors; the forest-teal accent is for + * highlights / non-action emphasis and must NEVER be used as a + * button color. + */ +object FabledSwordFlatTokens { + val moss = Color(0xFF4A5D3F) + val bronze = Color(0xFF8B7355) + val oxblood = Color(0xFF6B2118) + val warning = Color(0xFF8B6F1E) + val error = Color(0xFFC04A1F) + val info = Color(0xFF3D5A6E) + val accent = Color(0xFF4A6B5C) val onAction = Color(0xFFE8E4D8) - // Corner radii. val radiusSm = 4.dp val radiusMd = 8.dp val radiusLg = 12.dp val radiusXl = 16.dp } + +/** + * Back-compat alias mirroring the Flutter `FabledSwordTokens` class + * (dark surfaces + flat). Existing call sites that read + * `FabledSwordTokens.iron` continue to work but bind to dark-mode + * values regardless of system mode. New code should read from + * `LocalFabledSwordTheme.current` instead. Removed in a follow-up + * once all widgets are migrated. + */ +@Deprecated( + "Use LocalFabledSwordTheme.current — see FabledSwordTheme.kt", + level = DeprecationLevel.WARNING, +) +object FabledSwordTokens { + val obsidian = FabledSwordDarkTokens.obsidian + val iron = FabledSwordDarkTokens.iron + val slate = FabledSwordDarkTokens.slate + val pewter = FabledSwordDarkTokens.pewter + val parchment = FabledSwordDarkTokens.parchment + val vellum = FabledSwordDarkTokens.vellum + val ash = FabledSwordDarkTokens.ash + val moss = FabledSwordFlatTokens.moss + val bronze = FabledSwordFlatTokens.bronze + val oxblood = FabledSwordFlatTokens.oxblood + val warning = FabledSwordFlatTokens.warning + val error = FabledSwordFlatTokens.error + val info = FabledSwordFlatTokens.info + val accent = FabledSwordFlatTokens.accent + val onAction = FabledSwordFlatTokens.onAction + val radiusSm = FabledSwordFlatTokens.radiusSm + val radiusMd = FabledSwordFlatTokens.radiusMd + val radiusLg = FabledSwordFlatTokens.radiusLg + val radiusXl = FabledSwordFlatTokens.radiusXl +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt index 1161418e..b68282f1 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/MinstrelTheme.kt @@ -1,49 +1,96 @@ package com.fabledsword.minstrel.theme +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider /** - * Dark Material 3 color scheme assembled from FabledSword tokens. The accent - * (forest-teal) becomes the M3 `primary`; surfaces/backgrounds come from the - * dark token cohort. Action colors do NOT come from this scheme — see - * `LocalActionColors`. + * The Flutter theme's `ColorScheme` role mapping (theme_data.dart): + * primary = accent (forest-teal) — same both modes + * onPrimary = onAction + * surface = iron + * onSurface = parchment + * secondary = moss + * error = error + * scaffoldBackground = obsidian * - * Light theme is deferred to post-v1 (mirrors Flutter primary mode). + * Reproduced here for both modes. Note that token names ARE semantic + * roles — `iron` is "raised surface" in both modes (#1E2228 dark vs + * #ECE6D5 light), not literal "near-black". */ -private val MinstrelDarkColorScheme = darkColorScheme( - primary = FabledSwordTokens.accent, - onPrimary = FabledSwordTokens.onAction, - primaryContainer = FabledSwordTokens.accent.copy(alpha = 0.25f), - onPrimaryContainer = FabledSwordTokens.parchment, - secondary = FabledSwordTokens.bronze, - onSecondary = FabledSwordTokens.onAction, - background = FabledSwordTokens.obsidian, - onBackground = FabledSwordTokens.parchment, - surface = FabledSwordTokens.iron, - onSurface = FabledSwordTokens.parchment, - surfaceVariant = FabledSwordTokens.slate, - onSurfaceVariant = FabledSwordTokens.ash, - error = FabledSwordTokens.error, - onError = FabledSwordTokens.onAction, - outline = FabledSwordTokens.pewter, - outlineVariant = FabledSwordTokens.slate, -) +private fun darkMaterialColorScheme() = + darkColorScheme( + primary = FabledSwordFlatTokens.accent, + onPrimary = FabledSwordFlatTokens.onAction, + primaryContainer = FabledSwordFlatTokens.accent.copy(alpha = 0.25f), + onPrimaryContainer = FabledSwordDarkTokens.parchment, + secondary = FabledSwordFlatTokens.moss, + onSecondary = FabledSwordFlatTokens.onAction, + background = FabledSwordDarkTokens.obsidian, + onBackground = FabledSwordDarkTokens.parchment, + surface = FabledSwordDarkTokens.iron, + onSurface = FabledSwordDarkTokens.parchment, + surfaceVariant = FabledSwordDarkTokens.slate, + onSurfaceVariant = FabledSwordDarkTokens.ash, + error = FabledSwordFlatTokens.error, + onError = FabledSwordFlatTokens.onAction, + outline = FabledSwordDarkTokens.pewter, + outlineVariant = FabledSwordDarkTokens.slate, + ) +private fun lightMaterialColorScheme() = + lightColorScheme( + primary = FabledSwordFlatTokens.accent, + onPrimary = FabledSwordFlatTokens.onAction, + primaryContainer = FabledSwordFlatTokens.accent.copy(alpha = 0.18f), + onPrimaryContainer = FabledSwordLightTokens.parchment, + secondary = FabledSwordFlatTokens.moss, + onSecondary = FabledSwordFlatTokens.onAction, + background = FabledSwordLightTokens.obsidian, + onBackground = FabledSwordLightTokens.parchment, + surface = FabledSwordLightTokens.iron, + onSurface = FabledSwordLightTokens.parchment, + surfaceVariant = FabledSwordLightTokens.slate, + onSurfaceVariant = FabledSwordLightTokens.ash, + error = FabledSwordFlatTokens.error, + onError = FabledSwordFlatTokens.onAction, + outline = FabledSwordLightTokens.pewter, + outlineVariant = FabledSwordLightTokens.slate, + ) + +/** + * Theme entry point. Selects dark vs light based on system mode and + * provides both the Material 3 [MaterialTheme] and the + * [LocalFabledSwordTheme] for token-level reads. [LocalActionColors] + * is kept for back-compat — same values; consumers should migrate to + * `LocalFabledSwordTheme.current.moss` etc. in a later cleanup. + * + * `darkOverride` lets callers force one mode (Phase 11 Settings will + * carry a user-controlled override). + */ @Composable -fun MinstrelTheme(content: @Composable () -> Unit) { +fun MinstrelTheme( + darkOverride: Boolean? = null, + content: @Composable () -> Unit, +) { + val isDark = darkOverride ?: isSystemInDarkTheme() + val colorScheme = if (isDark) darkMaterialColorScheme() else lightMaterialColorScheme() + val fabledTheme = if (isDark) FabledSwordTheme.Dark else FabledSwordTheme.Light + MaterialTheme( - colorScheme = MinstrelDarkColorScheme, - typography = MinstrelTypography, + colorScheme = colorScheme, + typography = MinstrelTypography, ) { CompositionLocalProvider( + LocalFabledSwordTheme provides fabledTheme, LocalActionColors provides ActionColors( - primary = FabledSwordTokens.moss, - secondary = FabledSwordTokens.bronze, - destructive = FabledSwordTokens.oxblood, - onAction = FabledSwordTokens.onAction, + primary = FabledSwordFlatTokens.moss, + secondary = FabledSwordFlatTokens.bronze, + destructive = FabledSwordFlatTokens.oxblood, + onAction = FabledSwordFlatTokens.onAction, ), content = content, )