Wear Catppuccin Mocha, and move Usage to where the provider is

Two changes to the app, plus the one they turned up.

**The theme.** Catppuccin Mocha, copied from dev-updater rather than
shared: wg-app-link is the *link* -- the tunnel, the pinned CA, enrollment
-- and a palette is not that. The two apps looking alike is a preference
rather than a contract, and the moment one wants a different accent a
shared version becomes a thing to fight. dev-updater's ActionTone and its
ANSI table did not come across; nothing here draws a log or a destructive
button yet, and copying a vocabulary with no speakers is how a file starts
lying about what the app does.

**Usage is no longer a global button.** It belongs to the provider, and
the session view is the only place a provider is currently named, so that
is where the control sits -- beside the line that names it, rather than
collected with the app-wide controls where its scope had to be guessed. It
carries the session back with it, so Back returns to that session rather
than dumping the reader on the list.

Its real home is that provider's own settings, which do not exist yet.

**And the bit that only running it could find.** I first subtitled the
usage screen with the session's provider, which on an echo session put
"echo" directly above a card reading "claude" -- Claude's account-wide
numbers labelled as echo's, a claim about echo that nothing measured. The
subtitle is gone; each card names the service that answered, which is the
true scope, and the reason is written where the subtitle was so it does
not get re-added.

Looked at on the emulator rather than read: the palette, the session
header, the usage screen, and Back landing on the session it came from.
ktfmt, compile and Lint clean.
This commit is contained in:
iris committed 2026-08-28 20:26:12 -04:00
1 parent 4370c467ca
commit 31135e3f22
6 files changed
+135 -7

No files matched your search

@@ -26,7 +26,12 @@ private sealed class Screen {
data object Spawn : Screen() data object Spawn : Screen()
data object Usage : Screen() /**
* Reached from a session rather than from the list, because usage belongs to the provider
* running that session and not to the app. It carries the session back with it so Back returns
* where it came from -- see [Screen.Session].
*/
data class Usage(val from: SessionSummary) : Screen()
data object Models : Screen() data object Models : Screen()
@@ -97,7 +102,6 @@ fun AppRoot(settingsVersion: Int) {
reloadToken = reloadToken, reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onUsage = { screen = Screen.Usage },
onModels = { screen = Screen.Models }, onModels = { screen = Screen.Models },
onSetups = { screen = Screen.Setups }, onSetups = { screen = Screen.Setups },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
@@ -107,6 +111,7 @@ fun AppRoot(settingsVersion: Int) {
settings = current, settings = current,
summary = here.summary, summary = here.summary,
onBack = goToList, onBack = goToList,
onUsage = { screen = Screen.Usage(here.summary) },
) )
is Screen.Spawn -> is Screen.Spawn ->
SpawnScreen( SpawnScreen(
@@ -117,7 +122,13 @@ fun AppRoot(settingsVersion: Int) {
}, },
onBack = goToList, onBack = goToList,
) )
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList) is Screen.Usage ->
UsageScreen(
settings = current,
// Back to the session it was opened from, not to the list: this is a step down
// from that session, so stepping back is the one thing Back can mean here.
onBack = { screen = Screen.Session(here.from) },
)
is Screen.Models -> ModelsScreen(settings = current, onBack = goToList) is Screen.Models -> ModelsScreen(settings = current, onBack = goToList)
is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList) is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList)
is Screen.Settings -> is Screen.Settings ->
@@ -51,7 +51,7 @@ class MainActivity : ComponentActivity() {
handleEnrollment(intent) handleEnrollment(intent)
setContent { setContent {
MaterialTheme { MaterialTheme(colorScheme = AiAppColors) {
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().statusBarsPadding().imePadding()) { Box(modifier = Modifier.fillMaxSize().statusBarsPadding().imePadding()) {
AppRoot(settingsVersion) AppRoot(settingsVersion)
@@ -50,7 +50,6 @@ fun SessionListScreen(
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onUsage: () -> Unit,
onModels: () -> Unit, onModels: () -> Unit,
onSetups: () -> Unit, onSetups: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
@@ -99,7 +98,6 @@ fun SessionListScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
TextButton(onClick = onUsage) { Text("Usage") }
TextButton(onClick = onModels) { Text("Models") } TextButton(onClick = onModels) { Text("Models") }
TextButton(onClick = onSetups) { Text("Setups") } TextButton(onClick = onSetups) { Text("Setups") }
TextButton(onClick = onSettings) { Text("Settings") } TextButton(onClick = onSettings) { Text("Settings") }
@@ -130,7 +130,12 @@ private fun updateTool(
} }
@Composable @Composable
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { fun SessionScreen(
settings: ServerSettings,
summary: SessionSummary,
onBack: () -> Unit,
onUsage: () -> Unit,
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var items by remember { mutableStateOf(listOf<TranscriptItem>()) } var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) } var status by remember { mutableStateOf(summary.status) }
@@ -252,6 +257,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
) )
} }
StatusText(status) StatusText(status)
// Beside the provider it reports on, which is the line directly to its left.
//
// Its real home is this provider's settings, which do not exist yet; until they do,
// the session is the only place the provider is already named, so it is the only
// place the button can sit without inventing a scope for itself. What it shows is
// the paid service's own numbers, so a session on a provider with no such service
// gets an honest "unavailable" rather than a hidden button -- a control that comes
// and goes makes its absence the signal, and absence cannot say why.
TextButton(onClick = onUsage) { Text("Usage") }
} }
(streamError ?: actionError)?.let { message -> (streamError ?: actionError)?.let { message ->
@@ -0,0 +1,100 @@
package com.example.aiapp
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Catppuccin Mocha, as published in `catppuccin/palette`.
*
* Named rather than used as literals at the point of need, so the mapping below reads as the
* decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream
* palette without reading the layout that uses it.
*/
private object Mocha {
val Rosewater = Color(0xFFF5E0DC)
val Mauve = Color(0xFFCBA6F7)
val Red = Color(0xFFF38BA8)
val Peach = Color(0xFFFAB387)
val Yellow = Color(0xFFF9E2AF)
val Green = Color(0xFFA6E3A1)
val Teal = Color(0xFF94E2D5)
val Sky = Color(0xFF89DCEB)
val Blue = Color(0xFF89B4FA)
val Lavender = Color(0xFFB4BEFE)
val Text = Color(0xFFCDD6F4)
val Subtext0 = Color(0xFFA6ADC8)
val Overlay0 = Color(0xFF6C7086)
val Surface2 = Color(0xFF585B70)
val Surface1 = Color(0xFF45475A)
val Surface0 = Color(0xFF313244)
val Base = Color(0xFF1E1E2E)
val Mantle = Color(0xFF181825)
val Crust = Color(0xFF11111B)
}
/**
* The app's colour scheme: Catppuccin Mocha mapped onto Material's roles.
*
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
* is a preference, not a contract, and the moment one wants a different accent the shared version
* becomes a thing to fight rather than a thing to use.
*
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
* 0: one visible step up, which is the whole of what the nesting has to say.
*
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
* than the near-white the roles default to.
*/
val AiAppColors =
darkColorScheme(
primary = Mocha.Mauve,
onPrimary = Mocha.Crust,
primaryContainer = Mocha.Surface1,
onPrimaryContainer = Mocha.Mauve,
secondary = Mocha.Lavender,
onSecondary = Mocha.Crust,
secondaryContainer = Mocha.Surface1,
onSecondaryContainer = Mocha.Lavender,
tertiary = Mocha.Rosewater,
onTertiary = Mocha.Crust,
background = Mocha.Base,
onBackground = Mocha.Text,
surface = Mocha.Base,
onSurface = Mocha.Text,
surfaceVariant = Mocha.Surface0,
onSurfaceVariant = Mocha.Subtext0,
surfaceContainerLowest = Mocha.Crust,
surfaceContainerLow = Mocha.Mantle,
surfaceContainer = Mocha.Base,
surfaceContainerHigh = Mocha.Surface0,
surfaceContainerHighest = Mocha.Surface0,
inverseSurface = Mocha.Text,
inverseOnSurface = Mocha.Base,
inversePrimary = Mocha.Mauve,
outline = Mocha.Overlay0,
outlineVariant = Mocha.Surface2,
error = Mocha.Red,
onError = Mocha.Crust,
errorContainer = Mocha.Surface1,
onErrorContainer = Mocha.Red,
scrim = Mocha.Crust,
)
/** "There is something here": a session that is running. */
val runningColor: Color
@Composable get() = Mocha.Green
/**
* "This went wrong on its own": a session that fell over.
*
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
* though it is the same red. They are the same red for different reasons, and a state is not an
* action -- nothing here is a button.
*/
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
@@ -56,6 +56,11 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Deliberately not subtitled with the provider this was opened from. These numbers
// are the *account's*, reported by whichever paid service answered -- naming the
// session's provider here made an echo session's screen read "echo" above a card
// reading "claude", which is a claim about echo that nothing measured. Each card
// names the service it came from, which is the true scope.
Text( Text(
"Usage", "Usage",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,