refactor(android): split tokens into Dark/Light/Flat + LocalFabledSwordTheme + light mode

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:40:17 -04:00
parent c7dc525157
commit 2ab20e3958
7 changed files with 267 additions and 66 deletions
@@ -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()) {
@@ -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(
@@ -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(
@@ -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,
)
}
@@ -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 }
@@ -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
}
@@ -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,
)