From f3a84a2333d5004609ac3ee20654accc3d5f158c Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 21 Sep 2026 12:04:29 -0500 Subject: [PATCH] Design tokens: theme-aware palette via CompositionLocal, light theme, shape/elevation/motion tokens, persisted ui.theme pref with Settings toggle --- .../kotlin/com/shonar/desktop/DesktopState.kt | 14 + .../main/kotlin/com/shonar/desktop/Main.kt | 3 +- .../main/kotlin/com/shonar/desktop/Screens.kt | 13 + .../main/kotlin/com/shonar/desktop/Theme.kt | 282 +++++++++++++----- 4 files changed, 240 insertions(+), 72 deletions(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index e0aa473..213bd51 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -151,6 +151,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) { val libraryFilter: StateFlow = _libraryFilter.asStateFlow() fun setLibraryFilter(f: String) { _libraryFilter.value = f } + /** UI theme: dark is the signature mode; light is opt-in and + * persisted (an appearance choice, unlike the filter lens). */ + private val _themeMode = MutableStateFlow(ThemeMode.DARK) + val themeMode: StateFlow = _themeMode.asStateFlow() + fun setThemeMode(mode: ThemeMode) { + _themeMode.value = mode + scope.launch { + prefs.putString(KEY_THEME, if (mode == ThemeMode.LIGHT) "light" else "dark") + } + } + /** Files the pump is running or has queued this session (by name). */ private val inFlight = mutableSetOf() @@ -180,6 +191,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) { prefs.getString(KEY_SORT)?.takeIf { it in SORT_ORDERS } ?.let { _sortOrder.value = it } _summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto" + _themeMode.value = if (prefs.getString(KEY_THEME) == "light") + ThemeMode.LIGHT else ThemeMode.DARK loadNotifyState() prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } ?.let { setFolder(it, silent = true) } @@ -2030,6 +2043,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } /** Summarizer choice: "auto" | "lan" | "local" (see setSummarizer). */ const val KEY_SUMMARIZER = "llm.summarizer" + const val KEY_THEME = "ui.theme" const val KEY_UNSEEN_SUMMARIES = "notify.unseen_summaries" const val KEY_ANNOUNCED_SUMMARIES = "notify.announced_summaries" /** Preferred local summarization model (Ollama). */ diff --git a/app/src/main/kotlin/com/shonar/desktop/Main.kt b/app/src/main/kotlin/com/shonar/desktop/Main.kt index 7860e5a..c26fd56 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -98,7 +98,8 @@ fun main() = application { state = rememberWindowState(width = 1100.dp, height = 800.dp), ) { val baseDensity = LocalDensity.current - ShonarTheme { + val themeMode by state.themeMode.collectAsState() + ShonarTheme(mode = themeMode) { Surface( Modifier.fillMaxSize().onPreviewKeyEvent { e -> if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 847402a..ce06b7d 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -1293,6 +1293,19 @@ fun SettingsScreen(state: DesktopState) { Text("Settings", style = MaterialTheme.typography.headlineSmall) } Text("Local engine: $url", style = MaterialTheme.typography.bodySmall) + SectionHeader("Appearance") + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2), + verticalAlignment = Alignment.CenterVertically) { + val mode = state.themeMode.collectAsState().value + PillChip(selected = mode == ThemeMode.DARK, + onClick = { state.setThemeMode(ThemeMode.DARK) }, + label = "Dark") + PillChip(selected = mode == ThemeMode.LIGHT, + onClick = { state.setThemeMode(ThemeMode.LIGHT) }, + label = "Light") + } + HelperText("Dark is the app's native look; light mirrors the same " + + "hierarchy on paper tones. Applied instantly.") SectionHeader("Recordings folder") Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { diff --git a/app/src/main/kotlin/com/shonar/desktop/Theme.kt b/app/src/main/kotlin/com/shonar/desktop/Theme.kt index 763a378..0a81d03 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Theme.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Theme.kt @@ -1,5 +1,7 @@ package com.shonar.desktop +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing import androidx.compose.foundation.Canvas import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column @@ -15,7 +17,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -29,56 +33,146 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** - * SHONAR Desktop design system — the "deep-sea sonar" language from the - * reference design: near-black navy page, one elevated surface for all - * cards/fields, hairline borders, a single mint-teal accent used with - * restraint (active controls, live status, selection), and a soft teal - * glow reserved for the selected pill. + * SHONAR Desktop design system — a calm, dark-first workspace language: + * a deep cool-navy page, one elevated surface for all cards/fields, + * hairline borders, and a single mint-teal accent reserved for active + * states, live status, and primary actions. A light theme mirrors the + * same hierarchy on paper tones (light accent darkened for contrast). * - * Everything visual routes through [Shonar] tokens or the MaterialTheme + * Every visual value routes through [Shonar] tokens or the MaterialTheme * roles derived from them — screens must not hardcode colors, radii, or * spacing. Reusable primitives live in Components.kt. * + * Theming: colors resolve through [Shonar.LocalPalette], so all token + * names keep working in both modes; [ShonarTheme] swaps the palette and + * the derived [MaterialTheme.colorScheme]. Spacing, shapes, type scale, + * and motion are theme-independent. + * * Status semantics map to scheme roles app-wide: * done -> primary (teal), failed -> error (red), queued/running -> * onSurfaceVariant text with a primary progress bar. */ + +/** A complete set of theme-dependent colors. Both [DarkPalette] and + * [LightPalette] provide every slot; nothing outside this file should + * hardcode a color. */ +data class ShonarPalette( + val background: Color, + val surface: Color, + val surfaceAlt: Color, + val border: Color, + val accent: Color, + val accentStrong: Color, + val accentGlow: Color, + val inkOnAccent: Color, + val textPrimary: Color, + val textSecondary: Color, + val textMuted: Color, + val error: Color, + val onError: Color, +) + +/** The signature near-black cool-navy workspace. */ +private val DarkPalette = ShonarPalette( + background = Color(0xFF0A0E15), // page: near-black cool navy + surface = Color(0xFF121A25), // cards, fields, pills + surfaceAlt = Color(0xFF0E151F), // pressed / hover / inset + border = Color(0xFF1E2A38), // hairline on dark + accent = Color(0xFF5EEAD4), // mint-teal: icons, selection + accentStrong = Color(0xFF4FD1C5), // filled-accent hover depth + accentGlow = Color(0x405EEAD4), // soft halo (~25% mint) + inkOnAccent = Color(0xFF06231F), // text on filled accent + textPrimary = Color(0xFFE8EEF4), + textSecondary = Color(0xFF8A97A8), + textMuted = Color(0xFF6B7686), + error = Color(0xFFF27272), // red readable on navy + onError = Color(0xFF1A0B0D), +) + +/** Light mode: cool paper tones, same three-step surface ramp + * (page < card < inset). The accent darkens to teal-700 so accent + * text on paper meets AA contrast; filled accent takes white ink. */ +private val LightPalette = ShonarPalette( + background = Color(0xFFF4F6F9), // page: cool paper + surface = Color(0xFFFFFFFF), // cards, fields, pills + surfaceAlt = Color(0xFFEAEFF5), // pressed / hover / inset + border = Color(0xFFD5DDE7), // hairline on light + accent = Color(0xFF0F766E), // deep teal: readable on paper + accentStrong = Color(0xFF115E59), // filled-accent hover depth + accentGlow = Color(0x330E9488), // soft halo (~20% teal) + inkOnAccent = Color(0xFFF6FFFD), // text on filled accent + textPrimary = Color(0xFF18222E), + textSecondary = Color(0xFF52606F), + textMuted = Color(0xFF7A8695), + error = Color(0xFFBA1A1A), // AA red on white + onError = Color(0xFFFFF8F7), +) + +/** Which palette the app renders in. Persisted as "dark" / "light". */ +enum class ThemeMode { DARK, LIGHT } + object Shonar { - // ---- color tokens ---- - val Background = Color(0xFF0A0E15) // page: near-black cool navy - val Surface = Color(0xFF121A25) // cards, fields, pills - val SurfaceAlt = Color(0xFF0E151F) // pressed / hover / inset - val Border = Color(0xFF1E2A38) // hairline on dark - val Accent = Color(0xFF5EEAD4) // mint-teal: icons, selection - val AccentStrong = Color(0xFF4FD1C5) // filled-accent hover depth - val AccentGlow = Color(0x405EEAD4) // soft halo (~25% mint) - val InkOnAccent = Color(0xFF06231F) // text on filled accent - val TextPrimary = Color(0xFFE8EEF4) - val TextSecondary = Color(0xFF8A97A8) - val TextMuted = Color(0xFF6B7686) - val Error = Color(0xFFF27272) // red readable on navy - val OnError = Color(0xFF1A0B0D) + // ---- palette routing ---- + /** Active palette; [ShonarTheme] provides the mode's instance. + * Defaults to dark so previews/strays get the signature look. */ + val LocalPalette = staticCompositionLocalOf { DarkPalette } + + // ---- color tokens (theme-aware: readable inside any composable) ---- + val Background: Color @Composable get() = LocalPalette.current.background + val Surface: Color @Composable get() = LocalPalette.current.surface + val SurfaceAlt: Color @Composable get() = LocalPalette.current.surfaceAlt + val Border: Color @Composable get() = LocalPalette.current.border + val Accent: Color @Composable get() = LocalPalette.current.accent + val AccentStrong: Color @Composable get() = LocalPalette.current.accentStrong + val AccentGlow: Color @Composable get() = LocalPalette.current.accentGlow + val InkOnAccent: Color @Composable get() = LocalPalette.current.inkOnAccent + val TextPrimary: Color @Composable get() = LocalPalette.current.textPrimary + val TextSecondary: Color @Composable get() = LocalPalette.current.textSecondary + val TextMuted: Color @Composable get() = LocalPalette.current.textMuted + val Error: Color @Composable get() = LocalPalette.current.error + val OnError: Color @Composable get() = LocalPalette.current.onError // ---- shape tokens ---- - val RadiusCard = 14.dp // cards + fields - val RadiusButton = 12.dp // rectangular buttons + val RadiusCard = 14.dp // cards + fields + val RadiusButton = 12.dp // rectangular buttons + val RadiusPanel = 16.dp // sidebar / large panels val ShapeCard = RoundedCornerShape(RadiusCard) val ShapeButton = RoundedCornerShape(RadiusButton) + val ShapePanel = RoundedCornerShape(RadiusPanel) val ShapePill = RoundedCornerShape(50) // pills + round controls + // ---- border + elevation tokens ---- + val BorderW = 1.dp // hairline everywhere + val FocusW = 2.dp // keyboard focus ring + // Restrained card lift: cards read mostly by border, the shadow is + // barely-there separation, never a floating card. + val ElevationCard = 2.dp + // ---- spacing scale (4dp rhythm; compact but comfortable) ---- val Space1 = 4.dp val Space2 = 8.dp val Space3 = 12.dp val Space4 = 16.dp - val Space5 = 20.dp // page padding + val Space5 = 20.dp // page padding val CardPadding = 14.dp - val RowGap = 8.dp // gap between list cards - val ControlH = 40.dp // standard button height + val RowGap = 8.dp // gap between list cards + val ControlH = 40.dp // standard button height + val RailW = 64.dp // collapsed sidebar (icon rail) + val SidebarW = 232.dp // expanded sidebar // ---- icon sizing ---- val IconSm = 18.dp val IconMd = 20.dp + + // ---- motion (ease-out for entrances, in-out for state swaps) ---- + object Motion { + val InstantMs = 80 // pressed feedback + val FastMs = 140 // hover/focus/selection + val NormalMs = 220 // panels, theme crossfade + val SlowMs = 320 // sidebar collapse/expand + val EaseOut: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f) + val EaseInOut: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f) + } } private val ShonarShapes = Shapes( @@ -89,78 +183,120 @@ private val ShonarShapes = Shapes( extraLarge = RoundedCornerShape(20.dp), ) -// Typography hierarchy per the reference: page title 22/700 wide -// tracking; section 16/600; item title 15/600; body 14; metadata 13 -// muted; labels 12–13. Line heights are explicit everywhere: the -// summary is the product's reading surface and 1.0 leading read as a -// dense wall; each tier now separates by size AND rhythm, not weight -// alone. +// Typography hierarchy: page title 22/700 wide tracking; section 16/600; +// item title 15/600; body 14; metadata 13 muted; labels 12–13. Line +// heights are explicit everywhere: the summary is the product's reading +// surface and 1.0 leading read as a dense wall; each tier separates by +// size AND rhythm, not weight alone. Colors are NOT set here — the +// MaterialTheme roles carry them so both palettes work; screens pick +// tiers, not ink. private val ShonarTypography = Typography( headlineSmall = TextStyle( fontSize = 22.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, lineHeight = 28.sp, - color = Shonar.TextPrimary, ), titleMedium = TextStyle( fontSize = 17.sp, fontWeight = FontWeight.SemiBold, - lineHeight = 24.sp, color = Shonar.TextPrimary, + lineHeight = 24.sp, ), titleSmall = TextStyle( fontSize = 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.2.sp, lineHeight = 22.sp, - color = Shonar.TextPrimary, ), bodyLarge = TextStyle( - fontSize = 16.sp, lineHeight = 25.sp, color = Shonar.TextPrimary, + fontSize = 16.sp, lineHeight = 25.sp, ), bodyMedium = TextStyle( - fontSize = 14.sp, lineHeight = 21.sp, color = Shonar.TextPrimary, + fontSize = 14.sp, lineHeight = 21.sp, ), bodySmall = TextStyle( - fontSize = 13.sp, lineHeight = 18.sp, color = Shonar.TextSecondary, + fontSize = 13.sp, lineHeight = 18.sp, ), labelLarge = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Medium, lineHeight = 18.sp, ), labelMedium = TextStyle( fontSize = 13.sp, fontWeight = FontWeight.Medium, lineHeight = 17.sp, - color = Shonar.TextSecondary, ), labelSmall = TextStyle( - fontSize = 12.sp, lineHeight = 16.sp, color = Shonar.TextMuted, + fontSize = 12.sp, lineHeight = 16.sp, ), ) +/** Derive the full Material role set from a palette so anything still + * reading MaterialTheme.colorScheme follows the active theme. */ +private fun schemeFor(p: ShonarPalette) = + if (p === DarkPalette) ShonarDarkScheme else ShonarLightScheme + private val ShonarDarkScheme = darkColorScheme( - primary = Shonar.AccentStrong, - onPrimary = Shonar.InkOnAccent, - primaryContainer = Shonar.AccentGlow, - onPrimaryContainer = Shonar.Accent, - secondary = Shonar.Accent, - onSecondary = Shonar.InkOnAccent, - secondaryContainer = Shonar.SurfaceAlt, - onSecondaryContainer = Shonar.TextSecondary, - background = Shonar.Background, - onBackground = Shonar.TextPrimary, - surface = Shonar.Surface, - onSurface = Shonar.TextPrimary, - surfaceVariant = Shonar.SurfaceAlt, - onSurfaceVariant = Shonar.TextSecondary, - error = Shonar.Error, - onError = Shonar.OnError, - outline = Shonar.Border, - outlineVariant = Shonar.Border, + primary = DarkPalette.accentStrong, + onPrimary = DarkPalette.inkOnAccent, + primaryContainer = DarkPalette.accentGlow, + onPrimaryContainer = DarkPalette.accent, + secondary = DarkPalette.accent, + onSecondary = DarkPalette.inkOnAccent, + secondaryContainer = DarkPalette.surfaceAlt, + onSecondaryContainer = DarkPalette.textSecondary, + background = DarkPalette.background, + onBackground = DarkPalette.textPrimary, + surface = DarkPalette.surface, + onSurface = DarkPalette.textPrimary, + surfaceVariant = DarkPalette.surfaceAlt, + onSurfaceVariant = DarkPalette.textSecondary, + error = DarkPalette.error, + onError = DarkPalette.onError, + outline = DarkPalette.border, + outlineVariant = DarkPalette.border, ) -/** Wrap the app content in the SHONAR dark sonar design system. */ +private val ShonarLightScheme = lightColorScheme( + primary = LightPalette.accent, + onPrimary = LightPalette.inkOnAccent, + primaryContainer = LightPalette.accentGlow, + onPrimaryContainer = LightPalette.accentStrong, + secondary = LightPalette.accent, + onSecondary = LightPalette.inkOnAccent, + secondaryContainer = LightPalette.surfaceAlt, + onSecondaryContainer = LightPalette.textSecondary, + background = LightPalette.background, + onBackground = LightPalette.textPrimary, + surface = LightPalette.surface, + onSurface = LightPalette.textPrimary, + surfaceVariant = LightPalette.surfaceAlt, + onSurfaceVariant = LightPalette.textSecondary, + error = LightPalette.error, + onError = LightPalette.onError, + outline = LightPalette.border, + outlineVariant = LightPalette.border, +) + +/** Wrap the app content in the SHONAR design system in [mode] + * (dark is the signature default). */ @Composable -fun ShonarTheme(content: @Composable () -> Unit) { - MaterialTheme( - colorScheme = ShonarDarkScheme, - shapes = ShonarShapes, - typography = ShonarTypography, - content = content, - ) +fun ShonarTheme( + mode: ThemeMode = ThemeMode.DARK, + content: @Composable () -> Unit, +) { + val palette = if (mode == ThemeMode.LIGHT) LightPalette else DarkPalette + // Muted tiers carry secondary/muted ink per palette (call sites may + // still override with an explicit color). + val typography = androidx.compose.runtime.remember(palette) { + ShonarTypography.copy( + bodySmall = ShonarTypography.bodySmall.copy(color = palette.textSecondary), + labelMedium = ShonarTypography.labelMedium.copy(color = palette.textSecondary), + labelSmall = ShonarTypography.labelSmall.copy(color = palette.textMuted), + ) + } + androidx.compose.runtime.CompositionLocalProvider( + Shonar.LocalPalette provides palette, + ) { + MaterialTheme( + colorScheme = schemeFor(palette), + shapes = ShonarShapes, + typography = typography, + content = content, + ) + } } /** @@ -170,11 +306,14 @@ fun ShonarTheme(content: @Composable () -> Unit) { */ @Composable fun SonarLogo(size: Dp = 32.dp, modifier: Modifier = Modifier) { + // Canvas draws run outside composition: hoist palette reads to locals. + val accent = Shonar.Accent + val accentStrong = Shonar.AccentStrong Canvas(modifier = modifier.size(size)) { val s = this.size.minDimension / 108f fun c(x: Float, y: Float) = Offset(x * s, y * s) // Center dot: r=6 at (54,54) - drawCircle(Shonar.Accent, radius = 6f * s, center = c(54f, 54f)) + drawCircle(accent, radius = 6f * s, center = c(54f, 54f)) // Upper-right quarter arcs (teal), centered on the dot. val arcStyle = Stroke(width = 4f * s, cap = StrokeCap.Round) fun arc(color: Color, startDeg: Float, boxTopLeft: Float) { @@ -186,11 +325,11 @@ fun SonarLogo(size: Dp = 32.dp, modifier: Modifier = Modifier) { style = arcStyle, ) } - arc(Shonar.Accent, -90f, 38f) - arc(Shonar.Accent, -90f, 26f) + arc(accent, -90f, 38f) + arc(accent, -90f, 26f) // Lower-left quarter arcs (teal-dark). - arc(Shonar.AccentStrong, 90f, 38f) - arc(Shonar.AccentStrong, 90f, 26f) + arc(accentStrong, 90f, 38f) + arc(accentStrong, 90f, 26f) } } @@ -199,7 +338,8 @@ fun SonarLogo(size: Dp = 32.dp, modifier: Modifier = Modifier) { @Composable fun SonarLogoTile(modifier: Modifier = Modifier, size: Dp = 40.dp) { Surface( - modifier = modifier.size(size).border(1.dp, Shonar.Border, Shonar.ShapeCard), + modifier = modifier.size(size) + .border(Shonar.BorderW, Shonar.Border, Shonar.ShapeCard), color = Shonar.Surface, shape = Shonar.ShapeCard, ) {