Design tokens: theme-aware palette via CompositionLocal, light theme, shape/elevation/motion tokens, persisted ui.theme pref with Settings toggle

This commit is contained in:
avi 2026-09-21 12:04:29 -05:00
commit f3a84a2333
4 changed files with 240 additions and 72 deletions

View file

@ -151,6 +151,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
val libraryFilter: StateFlow<String> = _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> = _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<String>()
@ -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). */

View file

@ -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

View file

@ -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)) {

View file

@ -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,43 +33,121 @@ 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 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
@ -75,10 +157,22 @@ object Shonar {
val CardPadding = 14.dp
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,79 +183,121 @@ 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) {
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 = ShonarDarkScheme,
colorScheme = schemeFor(palette),
shapes = ShonarShapes,
typography = ShonarTypography,
typography = typography,
content = content,
)
}
}
/**
* The SHONAR sonar-ping logo — same geometry as the brand's launcher
@ -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,
) {