Design system: Shonar tokens + shared UI primitives (redesign phase 2)

Tokens centralize the reference design language: near-black navy page,
one elevated surface, hairline borders, mint-teal accent reserved for
active/live/selected, soft glow only on the selected pill; radius,
4dp spacing scale, icon sizes, and the full type hierarchy (22/700
page title through 12/regular labels) all live in one place.

Components.kt adds the primitives every screen will use: SonarCard,
ShonarButton (filled mint primary), ShonarOutlinedButton,
ShonarGhostButton, ShonarDangerButton, PillChip (the reference's
selected-pill-with-glow), SonarField (filled dark input with teal
focus border), SectionHeader, HelperText. Material3 scheme derived
from the same tokens so leftover defaults inherit the language.
This commit is contained in:
avi 2026-09-18 18:13:09 -05:00
commit 5c0d1edbef
2 changed files with 435 additions and 28 deletions

View file

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

View file

@ -1,13 +1,19 @@
package com.shonar.desktop package com.shonar.desktop
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment 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.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** /**
* SHONAR Desktop theme — the "deep-sea sonar" palette: dark navy + * SHONAR Desktop design system — the "deep-sea sonar" language from the
* teal ping. Seven role overrides; everything else falls through to * reference design: near-black navy page, one elevated surface for all
* Material3 defaults. * 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 -> * 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) object Shonar {
private val TealDark = Color(0xFF2A9D94) // ---- color tokens ----
private val DeepNavy = Color(0xFF0B1220) val Background = Color(0xFF0A0E15) // page: near-black cool navy
private val NavySurface = Color(0xFF121C2E) val Surface = Color(0xFF121A25) // cards, fields, pills
private val Ink = Color(0xFF10141A) 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( // ---- shape tokens ----
primary = Teal, val RadiusCard = 14.dp // cards + fields
onPrimary = Ink, val RadiusButton = 12.dp // rectangular buttons
secondary = TealDark, val ShapeCard = RoundedCornerShape(RadiusCard)
background = DeepNavy, val ShapeButton = RoundedCornerShape(RadiusButton)
surface = NavySurface, val ShapePill = RoundedCornerShape(50) // pills + round controls
onBackground = Color(0xFFE6EDF3),
onSurface = Color(0xFFE6EDF3), // ---- 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 @Composable
fun ShonarTheme(content: @Composable () -> Unit) { 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 val s = this.size.minDimension / 108f
fun c(x: Float, y: Float) = Offset(x * s, y * s) fun c(x: Float, y: Float) = Offset(x * s, y * s)
// Center dot: r=6 at (54,54) // 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. // Upper-right quarter arcs (teal), centered on the dot.
val arcStyle = Stroke(width = 4f * s, cap = StrokeCap.Round) val arcStyle = Stroke(width = 4f * s, cap = StrokeCap.Round)
fun arc(color: Color, startDeg: Float, boxTopLeft: Float) { fun arc(color: Color, startDeg: Float, boxTopLeft: Float) {
@ -73,24 +173,43 @@ fun SonarLogo(size: Dp = 32.dp, modifier: Modifier = Modifier) {
style = arcStyle, style = arcStyle,
) )
} }
arc(Teal, -90f, 38f) arc(Shonar.Accent, -90f, 38f)
arc(Teal, -90f, 26f) arc(Shonar.Accent, -90f, 26f)
// Lower-left quarter arcs (teal-dark). // Lower-left quarter arcs (teal-dark).
arc(TealDark, 90f, 38f) arc(Shonar.AccentStrong, 90f, 38f)
arc(TealDark, 90f, 26f) 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 @Composable
fun ShonarWordmark( fun ShonarWordmark(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
logoSize: Dp = 28.dp, logoSize: Dp = 28.dp,
style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.headlineSmall, style: TextStyle = MaterialTheme.typography.headlineSmall,
) { ) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) { Row(modifier, verticalAlignment = Alignment.CenterVertically) {
SonarLogo(logoSize) SonarLogoTile(size = logoSize + Shonar.Space3)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(Shonar.Space3))
Column { Column {
Text("S.H.O.N.A.R.", style = style) Text("S.H.O.N.A.R.", style = style)
Text( Text(