diff --git a/app/src/main/kotlin/com/shonar/desktop/Components.kt b/app/src/main/kotlin/com/shonar/desktop/Components.kt new file mode 100644 index 0000000..31da4a3 --- /dev/null +++ b/app/src/main/kotlin/com/shonar/desktop/Components.kt @@ -0,0 +1,288 @@ +package com.shonar.desktop + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +/** + * Reusable SHONAR UI primitives — every button, card, pill, and field in + * the app routes through these so the design language stays uniform. + * Behavior is plain pass-through: onClick fires exactly as before. + */ + +/** App-wide card: elevated surface, hairline border, 14dp radius, + * no drop shadow (reference cards sit flush in the page). */ +@Composable +fun SonarCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val clickModifier = if (onClick != null) + Modifier.clickable(onClick = onClick) else Modifier + Surface( + modifier = modifier.then(clickModifier), + shape = Shonar.ShapeCard, + color = Shonar.Surface, + border = BorderStroke(1.dp, Shonar.Border), + content = content, + ) +} + +/** Primary action: filled mint, dark ink label, 12dp radius. */ +@Composable +fun ShonarButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier.height(Shonar.ControlH), + enabled = enabled, + shape = Shonar.ShapeButton, + elevation = null, + colors = ButtonDefaults.buttonColors( + containerColor = Shonar.Accent, + contentColor = Shonar.InkOnAccent, + disabledContainerColor = Shonar.SurfaceAlt, + disabledContentColor = Shonar.TextMuted, + ), + content = content, + ) +} + +/** Secondary action: quiet outlined pill-button, hairline border. */ +@Composable +fun ShonarOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.height(Shonar.ControlH), + enabled = enabled, + shape = Shonar.ShapeButton, + border = BorderStroke(1.dp, Shonar.Border), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Shonar.TextPrimary, + disabledContentColor = Shonar.TextMuted, + ), + content = content, + ) +} + +/** Ghost action: text-only, teal label (nav links, row actions). */ +@Composable +fun ShonarGhostButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = Shonar.ShapeButton, + colors = ButtonDefaults.textButtonColors( + contentColor = Shonar.Accent, + disabledContentColor = Shonar.TextMuted, + ), + content = content, + ) +} + +/** Destructive action: quiet red outline — visible without shouting. */ +@Composable +fun ShonarDangerButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.height(Shonar.ControlH), + enabled = enabled, + shape = Shonar.ShapeButton, + border = BorderStroke(1.dp, Shonar.Error.copy(alpha = 0.5f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Shonar.Error, + disabledContentColor = Shonar.TextMuted, + ), + content = content, + ) +} + +/** + * Selection pill (sort/show/filters/voices): ghost outline by default; + * selected = teal border + teal label + soft teal glow — the one place + * the glow appears, matching the reference's active "Newest" pill. + */ +@Composable +fun PillChip( + selected: Boolean, + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val borderColor = when { + !enabled -> Shonar.Border.copy(alpha = 0.5f) + selected -> Shonar.Accent + else -> Shonar.Border + } + val textColor = when { + !enabled -> Shonar.TextMuted + selected -> Shonar.Accent + else -> Shonar.TextSecondary + } + Surface( + onClick = onClick, + enabled = enabled, + modifier = modifier + .height(32.dp) + .then( + if (selected) Modifier.shadow( + elevation = 8.dp, shape = Shonar.ShapePill, clip = false, + ambientColor = Shonar.AccentGlow, spotColor = Shonar.AccentGlow, + ) else Modifier + ), + shape = Shonar.ShapePill, + color = if (selected) Shonar.SurfaceAlt else Shonar.Surface, + border = BorderStroke(1.dp, borderColor), + ) { + Row( + Modifier.padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.labelLarge, color = textColor) + } + } +} + +/** + * Filled dark search/input field with leading icon slot: hairline + * border, 14dp radius, teal-tinted border while focused. Used for + * library search, in-recording search, rename, and text entry. + */ +@Composable +fun SonarField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + singleLine: Boolean = true, + leadingIcon: (@Composable () -> Unit)? = null, + imeAction: ImeAction = ImeAction.Done, + onDone: () -> Unit = {}, +) { + var isFocused by androidx.compose.runtime.remember { + androidx.compose.runtime.mutableStateOf(false) + } + Surface( + modifier = modifier.onFocusChanged { isFocused = it.hasFocus }, + shape = Shonar.ShapeCard, + color = Shonar.SurfaceAlt, + border = BorderStroke( + 1.dp, if (isFocused) Shonar.Accent else Shonar.Border), + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = if (singleLine) Alignment.CenterVertically + else Alignment.Top, + ) { + leadingIcon?.let { + it() + Spacer(Modifier.width(Shonar.Space2)) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + textStyle = MaterialTheme.typography.bodyLarge + .copy(color = Shonar.TextPrimary), + cursorBrush = SolidColor(Shonar.Accent), + keyboardOptions = KeyboardOptions(imeAction = imeAction), + keyboardActions = KeyboardActions(onDone = { onDone() }), + modifier = Modifier.fillMaxWidth(), + decorationBox = { inner -> + if (value.isEmpty()) { + Text(placeholder, + style = MaterialTheme.typography.bodyLarge, + color = Shonar.TextMuted) + } + inner() + }, + ) + } + } +} + +/** Section header row: title left, muted trailing meta right. */ +@Composable +fun SectionHeader( + title: String, + meta: String? = null, + modifier: Modifier = Modifier, +) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f)) + if (meta != null) { + Text(meta, style = MaterialTheme.typography.bodySmall, + color = Shonar.TextMuted) + } + } +} + +/** Muted helper/explainer text used under controls. */ +@Composable +fun HelperText( + text: String, + modifier: Modifier = Modifier, + error: Boolean = false, +) { + Text(text, + style = MaterialTheme.typography.bodySmall, + color = if (error) MaterialTheme.colorScheme.error + else Shonar.TextSecondary, + modifier = modifier) +} diff --git a/app/src/main/kotlin/com/shonar/desktop/Theme.kt b/app/src/main/kotlin/com/shonar/desktop/Theme.kt index dc4dc16..1a231be 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Theme.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Theme.kt @@ -1,13 +1,19 @@ package com.shonar.desktop import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +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.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -16,38 +22,132 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp /** - * SHONAR Desktop theme — the "deep-sea sonar" palette: dark navy + - * teal ping. Seven role overrides; everything else falls through to - * Material3 defaults. + * 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. * - * Status semantics in the library list map to scheme roles: + * Everything visual 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. + * + * Status semantics map to scheme roles app-wide: * done -> primary (teal), failed -> error (red), queued/running -> - * onSurfaceVariant with a primary progress bar. + * onSurfaceVariant text with a primary progress bar. */ -private val Teal = Color(0xFF4FD1C5) -private val TealDark = Color(0xFF2A9D94) -private val DeepNavy = Color(0xFF0B1220) -private val NavySurface = Color(0xFF121C2E) -private val Ink = Color(0xFF10141A) +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) -private val ShonarDarkScheme = darkColorScheme( - primary = Teal, - onPrimary = Ink, - secondary = TealDark, - background = DeepNavy, - surface = NavySurface, - onBackground = Color(0xFFE6EDF3), - onSurface = Color(0xFFE6EDF3), + // ---- shape tokens ---- + val RadiusCard = 14.dp // cards + fields + val RadiusButton = 12.dp // rectangular buttons + val ShapeCard = RoundedCornerShape(RadiusCard) + val ShapeButton = RoundedCornerShape(RadiusButton) + val ShapePill = RoundedCornerShape(50) // pills + round controls + + // ---- 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 CardPadding = 14.dp + val RowGap = 8.dp // gap between list cards + val ControlH = 40.dp // standard button height + + // ---- icon sizing ---- + val IconSm = 18.dp + val IconMd = 20.dp +} + +private val ShonarShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = Shonar.ShapeCard, // cards & fields + large = Shonar.ShapeButton, + extraLarge = RoundedCornerShape(20.dp), ) -/** Wrap the app content in the SHONAR dark sonar theme. */ +// 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. +private val ShonarTypography = Typography( + headlineSmall = TextStyle( + fontSize = 22.sp, fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, color = Shonar.TextPrimary, + ), + titleMedium = TextStyle( + fontSize = 16.sp, fontWeight = FontWeight.SemiBold, + color = Shonar.TextPrimary, + ), + titleSmall = TextStyle( + fontSize = 15.sp, fontWeight = FontWeight.SemiBold, + color = Shonar.TextPrimary, + ), + bodyLarge = TextStyle(fontSize = 15.sp, color = Shonar.TextPrimary), + bodyMedium = TextStyle(fontSize = 14.sp, color = Shonar.TextPrimary), + bodySmall = TextStyle(fontSize = 13.sp, color = Shonar.TextSecondary), + labelLarge = TextStyle( + fontSize = 14.sp, fontWeight = FontWeight.Medium, + ), + labelMedium = TextStyle( + fontSize = 13.sp, fontWeight = FontWeight.Medium, + color = Shonar.TextSecondary, + ), + labelSmall = TextStyle(fontSize = 12.sp, color = Shonar.TextMuted), +) + +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, +) + +/** Wrap the app content in the SHONAR dark sonar design system. */ @Composable fun ShonarTheme(content: @Composable () -> Unit) { - MaterialTheme(colorScheme = ShonarDarkScheme, content = content) + MaterialTheme( + colorScheme = ShonarDarkScheme, + shapes = ShonarShapes, + typography = ShonarTypography, + content = content, + ) } /** @@ -61,7 +161,7 @@ fun SonarLogo(size: Dp = 32.dp, modifier: Modifier = Modifier) { 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(Teal, radius = 6f * s, center = c(54f, 54f)) + drawCircle(Shonar.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) { @@ -73,24 +173,43 @@ fun SonarLogo(size: Dp = 32.dp, modifier: Modifier = Modifier) { style = arcStyle, ) } - arc(Teal, -90f, 38f) - arc(Teal, -90f, 26f) + arc(Shonar.Accent, -90f, 38f) + arc(Shonar.Accent, -90f, 26f) // Lower-left quarter arcs (teal-dark). - arc(TealDark, 90f, 38f) - arc(TealDark, 90f, 26f) + arc(Shonar.AccentStrong, 90f, 38f) + arc(Shonar.AccentStrong, 90f, 26f) } } -/** Wordmark row shared by screen headers: ping logo + S.H.O.N.A.R. + tagline. */ +/** The reference header's logo treatment: ping inside a hairline + * rounded tile. */ +@Composable +fun SonarLogoTile(modifier: Modifier = Modifier, size: Dp = 40.dp) { + Surface( + modifier = modifier.size(size).border(1.dp, Shonar.Border, Shonar.ShapeCard), + color = Shonar.Surface, + shape = Shonar.ShapeCard, + ) { + Column( + Modifier.padding(Shonar.Space1), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SonarLogo(size = size - Shonar.Space1 * 2) + } + } +} + +/** Wordmark row shared by screen headers: bordered logo tile + + * S.H.O.N.A.R. (wide tracking) + tagline, per the reference header. */ @Composable fun ShonarWordmark( modifier: Modifier = Modifier, logoSize: Dp = 28.dp, - style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.headlineSmall, + style: TextStyle = MaterialTheme.typography.headlineSmall, ) { Row(modifier, verticalAlignment = Alignment.CenterVertically) { - SonarLogo(logoSize) - Spacer(Modifier.width(10.dp)) + SonarLogoTile(size = logoSize + Shonar.Space3) + Spacer(Modifier.width(Shonar.Space3)) Column { Text("S.H.O.N.A.R.", style = style) Text(