From 31135e3f22cbdd5f1b2fd40d2806a5cf922cd0da Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 28 Aug 2026 20:26:12 -0400 Subject: [PATCH] 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. --- .../main/kotlin/com/example/aiapp/AppRoot.kt | 17 ++- .../kotlin/com/example/aiapp/MainActivity.kt | 2 +- .../com/example/aiapp/SessionListScreen.kt | 2 - .../kotlin/com/example/aiapp/SessionScreen.kt | 16 ++- .../main/kotlin/com/example/aiapp/Theme.kt | 100 ++++++++++++++++++ .../kotlin/com/example/aiapp/UsageScreen.kt | 5 + 6 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index 5765f9c..b47b26a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -26,7 +26,12 @@ private sealed class 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() @@ -97,7 +102,6 @@ fun AppRoot(settingsVersion: Int) { reloadToken = reloadToken, onOpen = { screen = Screen.Session(it) }, onSpawn = { screen = Screen.Spawn }, - onUsage = { screen = Screen.Usage }, onModels = { screen = Screen.Models }, onSetups = { screen = Screen.Setups }, onSettings = { screen = Screen.Settings }, @@ -107,6 +111,7 @@ fun AppRoot(settingsVersion: Int) { settings = current, summary = here.summary, onBack = goToList, + onUsage = { screen = Screen.Usage(here.summary) }, ) is Screen.Spawn -> SpawnScreen( @@ -117,7 +122,13 @@ fun AppRoot(settingsVersion: Int) { }, 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.Setups -> SetupsScreen(settings = current, onBack = goToList) is Screen.Settings -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index 898acfa..c577548 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -51,7 +51,7 @@ class MainActivity : ComponentActivity() { handleEnrollment(intent) setContent { - MaterialTheme { + MaterialTheme(colorScheme = AiAppColors) { Surface(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize().statusBarsPadding().imePadding()) { AppRoot(settingsVersion) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 92b3e57..5d63643 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -50,7 +50,6 @@ fun SessionListScreen( reloadToken: Int, onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, - onUsage: () -> Unit, onModels: () -> Unit, onSetups: () -> Unit, onSettings: () -> Unit, @@ -99,7 +98,6 @@ fun SessionListScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - TextButton(onClick = onUsage) { Text("Usage") } TextButton(onClick = onModels) { Text("Models") } TextButton(onClick = onSetups) { Text("Setups") } TextButton(onClick = onSettings) { Text("Settings") } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 1edf39e..cad9841 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -130,7 +130,12 @@ private fun updateTool( } @Composable -fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { +fun SessionScreen( + settings: ServerSettings, + summary: SessionSummary, + onBack: () -> Unit, + onUsage: () -> Unit, +) { val scope = rememberCoroutineScope() var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(summary.status) } @@ -252,6 +257,15 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () ) } 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 -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt new file mode 100644 index 0000000..e5a0090 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt index de5df77..18b9dea 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt @@ -56,6 +56,11 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { 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( "Usage", style = MaterialTheme.typography.headlineSmall,