From 4a9a1c3ed478033ca6f71a9699a1eef728734930 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 15:40:58 +0200 Subject: [PATCH] feat(clients/gamepad-ui): multi-tone palettes, and a UI that takes its colours from them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass built each palette by rotating ONE colour field's hue, and it showed: every option was a single tone at several brightnesses, which reads flat next to any real gradient. A palette is now an ordered ramp of DISTINCT hues. The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset table, so neighbouring cells land on different parts of it and the colours pool and swirl instead of banding; the control points' existing drift then moves the pools around. Violet keeps its explicit sixteen colours, so the default is untouched. Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss, Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are straight takes on the two reference gradients — foil and poster. `every_palette_is_multi_tone` measures the hue spread across all sixteen cells and fails under 45° (20° for Graphite and Opal, which are meant to be restrained). It caught Ember at 35°, all reds and oranges — the very flatness this rework exists to remove — and Graphite at 3° despite a comment claiming it drifted cool to warm. Both were rebuilt until the numbers matched the prose. The UI follows the palette now, rather than wearing brand violet over whatever happens to be behind it. Each palette carries an accent and a light flag, and an Ink derived from those (foreground, accent, on-accent, glass, scrim and its strength) is published to the whole tree — a thread-local in the console, an environment value on Apple, a CompositionLocal on Android. Pale palettes flip the ink: dark text on white frost, with the materials, tray scrims and every wash that sits under text following suit. Three things only the renders could have told us: - Additive blending blows out over a pale ground. Android's blobs and Apple's legacy field composite with Plus/plusLighter, which over near-white saturates every blob to white — Holo rendered as a grey wash. Pale palettes blend normally. - A white scrim at the dark field's strength BLEACHES the gradient. Mixing toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma it is drawn over. The scrim now carries a per-palette strength. - White glass over a bright field has far less separating it from its backdrop than dark glass over a dark one, and needed more body. Verified: console build + clippy -D warnings + 173 tests, Apple build + 200 tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes on real renders of both the vivid and the pale ends (console CPU rasters; a new Roborazzi light-palette scene, which is what exposed the blend-mode bug). --- .../src/main/kotlin/io/unom/punktfunk/App.kt | 4 +- .../io/unom/punktfunk/ConnectOverlay.kt | 12 +- .../io/unom/punktfunk/GamepadAddHostScreen.kt | 26 +- .../kotlin/io/unom/punktfunk/GamepadChrome.kt | 141 ++++--- .../io/unom/punktfunk/GamepadDialogs.kt | 50 ++- .../kotlin/io/unom/punktfunk/GamepadHome.kt | 20 +- .../kotlin/io/unom/punktfunk/GamepadInk.kt | 89 ++++ .../io/unom/punktfunk/GamepadPalette.kt | 244 ++++++++--- .../unom/punktfunk/GamepadSettingsScreen.kt | 13 +- .../kotlin/io/unom/punktfunk/LibraryScreen.kt | 25 +- .../io/unom/punktfunk/GamepadPaletteTest.kt | 160 ++++--- .../punktfunk/screenshots/ScreenshotTest.kt | 5 + .../unom/punktfunk/screenshots/ShotScenes.kt | 21 +- .../Sources/PunktfunkClient/ContentView.swift | 3 + .../PunktfunkClient/Home/ConnectOverlay.swift | 11 +- .../Home/GamepadAddHostView.swift | 22 +- .../PunktfunkClient/Home/GamepadChrome.swift | 116 +++--- .../Home/GamepadHomeView.swift | 31 +- .../PunktfunkClient/Home/GamepadInk.swift | 91 ++++ .../Home/GamepadKeyboard.swift | 7 +- .../Home/LibraryCoverflowView.swift | 13 +- .../Settings/GamepadSettingsView.swift | 28 +- .../PunktfunkClient/Support/GlassStyle.swift | 12 +- .../PunktfunkShared/GamepadPalette.swift | 200 ++++++--- .../GamepadPaletteTests.swift | 137 +++--- crates/pf-console-ui/src/glyphs.rs | 32 +- crates/pf-console-ui/src/library.rs | 392 ++++++++++++++---- crates/pf-console-ui/src/screens/add_host.rs | 4 +- crates/pf-console-ui/src/screens/home.rs | 44 +- crates/pf-console-ui/src/screens/library.rs | 28 +- crates/pf-console-ui/src/screens/pair.rs | 6 +- crates/pf-console-ui/src/screens/pin_hosts.rs | 6 +- crates/pf-console-ui/src/screens/settings.rs | 4 +- crates/pf-console-ui/src/shell.rs | 55 ++- crates/pf-console-ui/src/shell/overlays.rs | 19 +- crates/pf-console-ui/src/shell/render.rs | 10 +- crates/pf-console-ui/src/shell/tests.rs | 33 +- crates/pf-console-ui/src/theme.rs | 129 +++++- crates/pf-console-ui/src/widgets.rs | 36 +- 39 files changed, 1581 insertions(+), 698 deletions(-) create mode 100644 clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt create mode 100644 clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index f0c78e66..d24bcf08 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -109,8 +109,10 @@ fun App(forceGamepadUi: Boolean = false) { // threaded through every screen that draws a backdrop. Because it is read from the SAME // `settings` state the gamepad settings screen writes, stepping the Background row recolours // the field behind that very row. + val palette = GamepadPalette.named(settings.uiPalette) CompositionLocalProvider( - LocalGamepadPalette provides GamepadPalette.named(settings.uiPalette), + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), ) { AnimatedContent( targetState = session, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt index 1b3ad31a..1b92b249 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectOverlay.kt @@ -191,6 +191,7 @@ internal fun ConnectTakeover( onCancel: () -> Unit, onRetry: () -> Unit, ) { + val ink = LocalGamepadInk.current val copy = connectCopy(phase) val timedOut = phase is ConnectPhase.WakeTimedOut @@ -212,7 +213,7 @@ internal fun ConnectTakeover( Icon( Icons.Filled.Bedtime, contentDescription = null, - tint = Color.White.copy(alpha = 0.9f), + tint = ink.fg(0.9f), modifier = Modifier.size(46.dp), ) } @@ -221,14 +222,14 @@ internal fun ConnectTakeover( } Text( copy.title, - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = 24.sp, textAlign = TextAlign.Center, ) Text( copy.subtitle, - color = Color.White.copy(alpha = 0.65f), + color = ink.fg(0.65f), fontSize = 14.sp, textAlign = TextAlign.Center, fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default, @@ -249,6 +250,7 @@ internal fun ConnectTakeover( */ @Composable private fun PulsingSpinner() { + val ink = LocalGamepadInk.current val transition = rememberInfiniteTransition(label = "connectPulse") val pulse by transition.animateFloat( initialValue = 0f, @@ -262,14 +264,14 @@ private fun PulsingSpinner() { for (i in 0..1) { val p = (pulse + i * 0.5f) % 1f drawCircle( - color = Color(0xFF8678F5).copy(alpha = (1f - p) * 0.35f), + color = ink.accent.copy(alpha = (1f - p) * 0.35f), radius = maxR * (0.42f + p * 0.58f), style = Stroke(width = 2.dp.toPx()), ) } } CircularProgressIndicator( - color = Color.White, + color = ink.fg, strokeWidth = 3.dp, modifier = Modifier.size(54.dp), ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt index 7da0c8a2..00e2cf5a 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadAddHostScreen.kt @@ -79,6 +79,7 @@ fun GamepadAddHostScreen( suggestedMacs: List = emptyList(), onSave: ((KnownHost) -> Unit)? = null, ) { + val ink = LocalGamepadInk.current val context = LocalContext.current val isTv = remember { isTvDevice(context) } val isEdit = editHost != null @@ -245,7 +246,7 @@ fun GamepadAddHostScreen( Text( "Hosts on this network appear automatically — add one by address for everything else.", style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp), ) } @@ -306,6 +307,7 @@ private fun TvAddHostForm( onAdd: () -> Unit, onDismiss: () -> Unit, ) { + val ink = LocalGamepadInk.current BackHandler(onBack = onDismiss) val firstFocus = remember { FocusRequester() } Box(Modifier.fillMaxSize()) { @@ -319,11 +321,11 @@ private fun TvAddHostForm( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White) + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg) Text( "Hosts on this network appear automatically — add one by address for everything else.", style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), ) OutlinedTextField( value = name, onValueChange = onName, singleLine = true, @@ -362,6 +364,7 @@ private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row] @Composable private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused || editing, editing = editing) val shape = RoundedCornerShape(14.dp) Row( @@ -375,25 +378,26 @@ private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () - .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White) + Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg) Spacer(Modifier.weight(1f)) Text( f.value.ifEmpty { f.placeholder }, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White, + color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (editing) Text(" |", color = Color(0xFF8678F5)) + if (editing) Text(" |", color = ink.accent) } } @Composable private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) val labelColor by animateColorAsState( - if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f), + if (enabled) ink.accent else ink.fg(0.35f), tween(160), label = "addLabel", ) @@ -425,6 +429,7 @@ private fun KeyboardGrid( bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over onKey: (Int, Int) -> Unit, ) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(20.dp) val gap = if (compact) 5.dp else 7.dp Column( @@ -433,7 +438,7 @@ private fun KeyboardGrid( .widthIn(max = 640.dp) .clip(shape) .background(Color(0x1FFFFFFF)) - .border(1.dp, Color.White.copy(alpha = 0.12f), shape) + .border(1.dp, ink.fg(0.12f), shape) .padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset), verticalArrangement = Arrangement.spacedBy(gap), ) { @@ -454,14 +459,15 @@ private fun KeyboardGrid( @Composable private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val ink = LocalGamepadInk.current // Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the // trailing key must have faded before the cursor is two keys away — quick, but no longer a snap. val bg by animateColorAsState( - if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF), + if (focused) ink.accent else ink.glass, tween(90), label = "keyBg", ) - val fg by animateColorAsState(if (focused) Color.Black else Color.White, tween(90), label = "keyFg") + val fg by animateColorAsState(if (focused) Color.Black else ink.fg, tween(90), label = "keyFg") Box( modifier = modifier .height(if (compact) 34.dp else 44.dp) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index d33c084e..6e6219b5 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -72,9 +72,12 @@ import kotlin.math.sin // connected-controller status chip. One look across every screen is what makes the console UI read // as a coherent mode rather than a set of themed pages. -/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */ +/** + * One drifting blob of the aurora field: where it sits, how far it wanders, and how fast. Integer + * [sx]/[sy] keep the loop seamless at wrap. The COLOUR is the palette's, taken from its ramp at + * draw time, so the field always shows several of that palette's tones at once. + */ private class AuroraBlob( - val color: Color, val baseX: Float, val baseY: Float, val driftX: Float, @@ -87,35 +90,33 @@ private class AuroraBlob( ) private val auroraBlobs = listOf( - AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet - AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo - AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum - AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue + AuroraBlob(0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), + AuroraBlob(0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), + AuroraBlob(0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), + AuroraBlob(0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), ) -/** The deep base the field sits on — and, scaled, the [calm] lift that flattens it. */ -private val auroraBase = Color(0xFF131126) - /** - * The living console backdrop: soft brand-family blobs drifting over a deep base on slow, seamless - * loops, finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose - * approximation of the Apple client's MeshGradient aurora — same colour family, same "ambience, - * never content" role, and the same [GamepadPalette] setting recolours both. + * The living console backdrop: soft blobs from the palette's ramp drifting over its ground on + * slow, seamless loops, finished with a centre-pooling vignette and top/bottom legibility scrims. + * A Compose approximation of the Apple client's MeshGradient aurora — same colour families, same + * "ambience, never content" role, and the same [GamepadPalette] setting recolours both. * - * [calm] is what the FORM screens wear: the pools dim onto the base so the glass rows keep real - * colour and luminance without the launcher's contrast. Motion is identical either way on purpose — - * only the contrast differs, so moving between screens can't make the field jump. + * [calm] is what the FORM screens wear: the pools dim onto the ground so the glass rows keep real + * colour and luminance without the launcher's contrast. Motion is identical either way on purpose + * — only the contrast differs, so moving between screens can't make the field jump. * * Honours the system's "remove animations" accessibility setting by freezing at a fixed phase, the * same courtesy the Apple client pays Reduce Motion. */ @Composable fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false) { + val ink = LocalGamepadInk.current val palette = LocalGamepadPalette.current val animated = animationsEnabled() val transition = rememberInfiniteTransition(label = "aurora") - // A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap - // so the field never visibly jumps when the animation restarts. + // A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the + // wrap so the field never visibly jumps when the animation restarts. val swept by transition.animateFloat( initialValue = 0f, targetValue = (2 * PI).toFloat(), @@ -123,39 +124,45 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false label = "angle", ) val angle = if (animated) swept else 0f - // Tinting is per-frame-cheap but not free, and the palette changes about once a year. - val blobs = remember(palette.id) { auroraBlobs.map { it to palette.tint(it.color) } } - val base = remember(palette.id) { palette.tint(auroraBase) } + val tones = palette.blobColors + val ground = palette.groundColor + // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark field's + // strength bleaches the chroma straight out of the gradient, so a pale palette gets under + // half — the same scrim strength the desktop console's shader carries. + val scrim = if (palette.light) ink.fg else Color.Black + val strength = if (palette.light) 0.45f else 1f Canvas(modifier) { - drawRect(if (calm) base else Color.Black) + drawRect(ground) val span = max(size.width, size.height) - for ((b, tinted) in blobs) { + for ((i, b) in auroraBlobs.withIndex()) { val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height val r = span * b.radiusFrac - // Calm scales each blob's contribution rather than dimming the whole canvas: the base - // stays put and only the pools come down to meet it, which is the same "lower the - // contrast, keep the colour" the desktop console's `calm` uniform does. + // Calm scales each blob's contribution rather than dimming the whole canvas: the + // ground stays put and only the pools come down to meet it, which is the same "lower + // the contrast, keep the colour" the desktop console's `calm` uniform does. val alpha = if (calm) b.alpha * 0.62f else b.alpha drawCircle( brush = Brush.radialGradient( - colors = listOf(tinted.copy(alpha = alpha), Color.Transparent), + colors = listOf(tones[i].copy(alpha = alpha), Color.Transparent), center = Offset(cx, cy), radius = r, ), center = Offset(cx, cy), radius = r, - blendMode = BlendMode.Plus, + // Additive only works over a DARK ground; over a pale one every blob + // saturates to white and the field turns grey. Pale palettes tint instead. + blendMode = if (palette.light) BlendMode.SrcOver else BlendMode.Plus, ) } - // Cinematic vignette: pool light centre, sink the corners. Halved under calm: a launcher's - // cards sit in the pooled centre, but a form screen's rows run out toward the edges, where - // crushing to black just eats them. (Matches the Apple client and the desktop console.) + // Cinematic vignette: pool light centre, settle the corners toward the scrim. Halved under + // calm: a launcher's cards sit in the pooled centre, but a form screen's rows run out + // toward the edges, where crushing them just eats the list. drawRect( Brush.radialGradient( colors = listOf( Color.Transparent, - Color.Black.copy(alpha = if (calm) 0.22f else 0.44f), + scrim.copy(alpha = (if (calm) 0.22f else 0.44f) * strength), ), center = Offset(size.width / 2, size.height / 2), radius = span * 0.92f, @@ -164,10 +171,10 @@ fun GamepadAuroraBackground(modifier: Modifier = Modifier, calm: Boolean = false // Top/bottom legibility scrim for the pinned title + hint bar. drawRect( Brush.verticalGradient( - 0.0f to Color.Black.copy(alpha = 0.40f), - 0.30f to Color.Black.copy(alpha = 0.05f), - 0.70f to Color.Black.copy(alpha = 0.06f), - 1.0f to Color.Black.copy(alpha = 0.42f), + 0.0f to scrim.copy(alpha = 0.40f * strength), + 0.30f to scrim.copy(alpha = 0.05f * strength), + 0.70f to scrim.copy(alpha = 0.06f * strength), + 1.0f to scrim.copy(alpha = 0.42f * strength), ), ) } @@ -223,6 +230,7 @@ fun ConsoleTabStrip( */ focused: Boolean = false, ) { + val ink = LocalGamepadInk.current val listState = rememberLazyListState() LaunchedEffect(selected) { runCatching { listState.animateScrollToItem(selected.coerceAtLeast(0)) } @@ -236,17 +244,18 @@ fun ConsoleTabStrip( itemsIndexed(titles) { i, title -> val active = i == selected val background by animateColorAsState( - if (active) Color(0xD96656F2) else Color(0x14FFFFFF), + if (active) ink.accent(0.85f) else ink.glass, tween(180), label = "tabBg", ) - val ink by animateColorAsState( - Color.White.copy(alpha = if (active) 1f else 0.55f), + // Not `ink` — that name is the palette's, and shadowing it here cost a compile. + val labelColor by animateColorAsState( + if (active) ink.onAccent else ink.fg(0.55f), tween(180), label = "tabInk", ) val ring by animateColorAsState( - Color.White.copy(alpha = if (active && focused) 0.85f else 0f), + ink.fg(if (active && focused) 0.85f else 0f), tween(180), label = "tabRing", ) @@ -254,7 +263,7 @@ fun ConsoleTabStrip( title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = ink, + color = labelColor, maxLines = 1, modifier = Modifier .clip(RoundedCornerShape(50)) @@ -283,6 +292,7 @@ val ConsoleEdgeInset = 24.dp */ @Composable fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) { + val ink = LocalGamepadInk.current // `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a // LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way. val h = if (horizontalInset) ConsoleEdgeInset else 0.dp @@ -290,7 +300,7 @@ fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp), @@ -347,21 +357,22 @@ class ConsoleFocusVisuals(val scale: Float, val background: Color, val border: C */ @Composable fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocusVisuals { + val ink = LocalGamepadInk.current val scale by animateFloatAsState( targetValue = if (active) 1f else 0.98f, animationSpec = spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), label = "consoleScale", ) val background by animateColorAsState( - if (active) Color(0x336656F2) else Color(0x14FFFFFF), + if (active) ink.accent(0.20f) else ink.glass, tween(160), label = "consoleBg", ) val border by animateColorAsState( when { - editing -> Color(0xB38678F5) - active -> Color.White.copy(alpha = 0.28f) - else -> Color.White.copy(alpha = 0.06f) + editing -> ink.accent(0.70f) + active -> ink.fg(0.28f) + else -> ink.fg(0.06f) }, tween(160), label = "consoleBorder", @@ -376,18 +387,19 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus */ @Composable fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val travel by animateFloatAsState( targetValue = if (on) 1f else 0f, animationSpec = spring(dampingRatio = 0.8f, stiffness = 600f), label = "switchKnob", ) val track by animateColorAsState( - if (on) Color(0xFF6656F2) else Color(0x26FFFFFF), + if (on) ink.accent else Color(0x26FFFFFF), tween(200), label = "switchTrack", ) val outline by animateColorAsState( - Color.White.copy(alpha = if (focused) 0.45f else 0.15f), + ink.fg(if (focused) 0.45f else 0.15f), tween(160), label = "switchOutline", ) @@ -409,7 +421,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) .offset { IntOffset(((trackW - knob - pad * 2).toPx() * travel).roundToInt(), 0) } .size(knob) .clip(CircleShape) - .background(Color.White), + .background(ink.fg), ) } } @@ -417,6 +429,7 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) /** A round face-button badge: a coloured disc with the button letter, like a controller's face. */ @Composable fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( modifier = Modifier .size(size) @@ -426,7 +439,7 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit ) { Text( glyph.toString(), - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = (size.value * 0.52f).sp, textAlign = TextAlign.Center, @@ -437,11 +450,12 @@ fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit /** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */ @Composable private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A), contentAlignment = Alignment.Center, ) { - Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape)) + Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, ink.fg, CircleShape)) } } @@ -506,6 +520,7 @@ internal fun PsFaceGlyph(glyph: Char, size: androidx.compose.ui.unit.Dp = 26.dp) */ @Composable internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.ui.unit.Dp = 26.dp) { + val ink = LocalGamepadInk.current Box( Modifier.size(size).clip(CircleShape).background(PadButtonFace), contentAlignment = Alignment.Center, @@ -517,17 +532,17 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u val corner = RoundedCornerShape(2.dp) Box( Modifier.size(size * 0.32f).align(Alignment.TopEnd) - .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + .border(1.4.dp, ink.fg(0.9f), corner), ) Box( Modifier.size(size * 0.32f).align(Alignment.BottomStart) .clip(corner).background(PadButtonFace) - .border(1.4.dp, Color.White.copy(alpha = 0.9f), corner), + .border(1.4.dp, ink.fg(0.9f), corner), ) } Gamepad.PadStyle.NINTENDO -> Text( "−", - color = Color.White, + color = ink.fg, fontWeight = FontWeight.Bold, fontSize = (size.value * 0.62f).sp, textAlign = TextAlign.Center, @@ -536,7 +551,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u Modifier .size(width = size * 0.58f, height = size * 0.30f) .clip(RoundedCornerShape(50)) - .border(1.6.dp, Color.White.copy(alpha = 0.9f), RoundedCornerShape(50)), + .border(1.6.dp, ink.fg(0.9f), RoundedCornerShape(50)), ) } } @@ -548,6 +563,7 @@ internal fun SelectButtonGlyph(style: Gamepad.PadStyle, size: androidx.compose.u */ @Composable fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, hazeState: HazeState? = null) { + val ink = LocalGamepadInk.current // On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses: // A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the // home's Up/Down handle themselves. A real pad instead picks its glyph FAMILY (Xbox letters / @@ -560,13 +576,13 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze // With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent // scrim below) + a light tint; otherwise fall back to a solid frosted fill. val frosted = if (hazeState != null) { - modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A)) + modifier.clip(shape).hazeEffect(hazeState).background(ink.shade(0.25f)) } else { - modifier.clip(shape).background(Color(0x8C14122A)) + modifier.clip(shape).background(ink.shade(0.55f)) } Row( modifier = frosted - .border(1.dp, Color.White.copy(alpha = 0.14f), shape) + .border(1.dp, ink.fg(0.14f), shape) .padding(horizontal = 16.dp, vertical = 10.dp) // The pill still hugs its content when it fits; when it doesn't (a narrow phone, or a // screen whose legend grew a cell) it scrolls rather than running off the edge and @@ -598,7 +614,7 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze Text( h.text, style = MaterialTheme.typography.labelLarge, - color = Color.White.copy(alpha = 0.9f), + color = ink.fg(0.9f), maxLines = 1, softWrap = false, // never char-wrap a label when several hints crowd a narrow pill ) @@ -610,24 +626,25 @@ fun GamepadHintBar(hints: List, modifier: Modifier = Modifier, haze /** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */ @Composable fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current Row( modifier = modifier .clip(RoundedCornerShape(50)) - .background(Color.White.copy(alpha = 0.08f)) + .background(ink.fg(0.08f)) .padding(horizontal = 12.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Filled.SportsEsports, contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), + tint = ink.fg(0.75f), modifier = Modifier.size(16.dp), ) Spacer(Modifier.width(7.dp)) Text( name, style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), maxLines = 1, ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt index a3f27ec2..c9affdb8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadDialogs.kt @@ -85,6 +85,7 @@ fun GamepadDialog( actions: List, body: @Composable ColumnScope.() -> Unit, ) { + val ink = LocalGamepadInk.current // Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels // like "Request access" without the cramped-row wrapping a horizontal layout caused). var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) } @@ -117,11 +118,11 @@ fun GamepadDialog( .heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) .background(Color(0xF01A1730)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg) Column( Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -139,6 +140,7 @@ fun GamepadDialog( @OptIn(ExperimentalFoundationApi::class) @Composable private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val scale by animateFloatAsState( if (focused) 1.02f else 1f, spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow), @@ -152,19 +154,19 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab // Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping. val bg by animateColorAsState( when { - focused -> Color(0xFF6656F2) - primary -> Color(0x336656F2) - else -> Color(0x14FFFFFF) + focused -> ink.accent + primary -> ink.accent(0.20f) + else -> ink.glass }, tween(160), label = "btnBg", ) val fg by animateColorAsState( when { - !enabled -> Color.White.copy(alpha = 0.35f) - focused -> Color.White - primary -> Color(0xFF8678F5) - else -> Color.White.copy(alpha = 0.85f) + !enabled -> ink.fg(0.35f) + focused -> ink.fg + primary -> ink.accent + else -> ink.fg(0.85f) }, tween(160), label = "btnFg", @@ -198,7 +200,8 @@ private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enab /** Body text helper — a dimmed paragraph. */ @Composable private fun DialogText(text: String) { - Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f)) + val ink = LocalGamepadInk.current + Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f)) } /** @@ -271,6 +274,7 @@ fun GamepadPinHostsDialog( onToggle: (KnownHost) -> Unit, onDismiss: () -> Unit, ) { + val ink = LocalGamepadInk.current // 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS // Done, so it starts focused). var focus by remember { mutableIntStateOf(0) } @@ -304,7 +308,7 @@ fun GamepadPinHostsDialog( .heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) .background(Color(0xF01A1730)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .padding(28.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { @@ -312,7 +316,7 @@ fun GamepadPinHostsDialog( "Pin “$profileName”", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -350,6 +354,7 @@ fun GamepadPinHostsDialog( @OptIn(ExperimentalFoundationApi::class) @Composable private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) // Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short // landscape window pulls itself into view. @@ -376,7 +381,7 @@ private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -518,6 +523,7 @@ fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, on @Composable fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { + val ink = LocalGamepadInk.current GamepadDialog( title = "Waiting for approval", onDismiss = onCancel, @@ -525,8 +531,8 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { ) { val deviceName = Build.MODEL ?: "this device" Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White) - Text("Approve this device on $hostLabel.", color = Color.White) + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = ink.fg) + Text("Approve this device on $hostLabel.", color = ink.fg) } DialogText( "Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " + @@ -542,6 +548,7 @@ fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) { */ @Composable fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) { + val ink = LocalGamepadInk.current val scope = rememberCoroutineScope() val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) } var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button @@ -587,16 +594,16 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: Column( Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight) .clip(RoundedCornerShape(24.dp)) - .background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp)) + .background(Color(0xF01A1730)).border(1.dp, ink.fg(0.12f), RoundedCornerShape(24.dp)) .verticalScroll(rememberScrollState()) .padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp), ) { - Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White) + Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg) Text( "Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.", - style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center, ) Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) } @@ -615,13 +622,14 @@ fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: @Composable private fun PinSlot(value: Int, focused: Boolean) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(12.dp) Box( Modifier.size(54.dp, 66.dp).clip(shape) - .background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF)) - .border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape), + .background(if (focused) ink.accent(0.20f) else ink.glass) + .border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape), contentAlignment = Alignment.Center, ) { - Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace) + Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = ink.fg, fontFamily = FontFamily.Monospace) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt index d42a4643..b590e094 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -247,9 +247,10 @@ fun GamepadHome( /** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */ @Composable private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(26.dp) val wash = if (tile.filled) { - Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A))) + Brush.verticalGradient(listOf(ink.accent(0.20f), Color(0x14100C2A))) } else { Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF))) } @@ -258,7 +259,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { .fillMaxWidth() .clip(shape) .background(wash) - .border(1.dp, Color.White.copy(alpha = 0.16f), shape) + .border(1.dp, ink.fg(0.16f), shape) .padding(22.dp), ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { @@ -269,7 +270,7 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { Icon( Icons.Filled.Lock, contentDescription = "Paired", - tint = Color.White.copy(alpha = 0.7f), + tint = ink.fg(0.7f), modifier = Modifier.padding(end = 6.dp).size(15.dp), ) } @@ -286,14 +287,14 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { tile.title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( tile.subtitle, style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.55f), + color = ink.fg(0.55f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -302,9 +303,10 @@ private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) { @Composable private fun MonogramBadge(tile: HomeTile) { + val ink = LocalGamepadInk.current val shape = RoundedCornerShape(15.dp) val fill = if (tile.filled) { - Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5))) + Brush.verticalGradient(listOf(ink.accent, ink.accent)) } else { Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2))) } @@ -316,18 +318,18 @@ private fun MonogramBadge(tile: HomeTile) { tile.connecting -> CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, - color = Color.White, + color = ink.fg, ) tile.isAdd -> Icon( Icons.Filled.Add, contentDescription = null, - tint = if (tile.filled) Color.White else Color(0xFF8678F5), + tint = if (tile.filled) ink.fg else ink.accent, ) else -> Text( tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "•", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = if (tile.filled) Color.White else Color(0xFF8678F5), + color = if (tile.filled) ink.fg else ink.accent, ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt new file mode 100644 index 00000000..a4546749 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadInk.kt @@ -0,0 +1,89 @@ +package io.unom.punktfunk + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color + +// The ink the console (gamepad) UI draws with under the chosen background palette. +// +// The console screens were white-on-dark throughout with the brand violet hardcoded as the accent. +// Both had to become palette-derived at once: a pale field needs dark text or it is unreadable, +// and a violet focus wash on a copper field is exactly the clash this exists to fix. +// +// Published as a CompositionLocal rather than passed down, so a leaf (a row, a hint pill, a card) +// can ask for the right colour without every caller in between knowing about palettes. The Apple +// client uses an environment value and `pf-console-ui` a thread-local for the same reason. + +/** Everything about the console's look that follows the chosen palette. */ +class GamepadInk( + /** Primary text/glyph colour. */ + val fg: Color, + /** Focus wash, selected tab pill, switch track — the palette's own accent. */ + val accent: Color, + /** What reads ON the accent (a filled pill's label, a switch knob). */ + val onAccent: Color, + /** The base fill every glass surface starts from, at its resting opacity. */ + val glass: Color, + /** What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. */ + val shade: Color, + /** + * How hard those washes go. A pale field needs far less — mixing toward white at the dark + * field's strength bleaches the chroma straight out of the gradient. + */ + val shadeScale: Float, + /** True when the field is pale, for the few places that branch rather than blend. */ + val isLight: Boolean, +) { + /** The foreground at [alpha]. */ + fun fg(alpha: Float): Color = fg.copy(alpha = alpha) + + /** The accent at [alpha]. */ + fun accent(alpha: Float): Color = accent.copy(alpha = alpha) + + /** A wash under text: [alpha] is the dark-field strength, scaled for a pale one. */ + fun shade(alpha: Float): Color = shade.copy(alpha = alpha * shadeScale) + + companion object { + fun of(p: GamepadPalette): GamepadInk { + val accent = p.accentColor + // Chosen by luminance, not by `light`: an accent is picked for contrast against the + // GLASS, not against the field. + val accentLuma = + 0.2126 * p.accent.first + 0.7152 * p.accent.second + 0.0722 * p.accent.third + val onAccent = if (accentLuma > 0.55) Color.Black else Color.White + if (!p.light) { + return GamepadInk( + fg = Color.White, + accent = accent, + onAccent = onAccent, + glass = Color.White.copy(alpha = 0.08f), + shade = Color.Black, + shadeScale = 1f, + isLight = false, + ) + } + val (gr, gg, gb) = p.ground + return GamepadInk( + // Tinted toward the palette's own ground so it doesn't read as a foreign grey. + fg = Color((gr * 0.16).toFloat(), (gg * 0.14).toFloat(), (gb * 0.20).toFloat()), + accent = accent, + onAccent = onAccent, + // More body than the dark glass carries: white frost over a bright gradient has + // far less separating it from its backdrop than dark glass over a dark one. + glass = Color.White.copy(alpha = 0.55f), + shade = Color.White, + shadeScale = 0.45f, + isLight = true, + ) + } + + /** The shipped dark look — what a preview or a test composition gets. */ + val DARK = of(GamepadPalette.named("violet")) + } +} + +/** + * The ink of the palette currently drawing, for everything under [App]. Provided from the live + * settings alongside [LocalGamepadPalette], so a change on the gamepad settings screen re-inks + * every console surface at once. + */ +val LocalGamepadInk = compositionLocalOf { GamepadInk.DARK } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt index 3db75d01..9e4a04d2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadPalette.kt @@ -1,78 +1,194 @@ package io.unom.punktfunk import androidx.compose.ui.graphics.Color -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt -// The console (gamepad) UI's background colour families. +// The console (gamepad) UI's background colour families, and the ink each one calls for. // -// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale -// applied to the ONE field GamepadAuroraBackground already draws, so every palette inherits its -// structure (dark base, bright drifting pools) and the brand default is exactly the shipped look — -// `violet` is the identity transform. +// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The +// field samples that ramp so several tones show at once and pool into each other, the way a real +// gradient poster does. An earlier version rotated ONE field's hue per palette, which is why every +// non-default palette read flat and monotone. // -// The table and the `tint` maths are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the -// Apple client's `GamepadPalette.swift` under the same ids, so the shared `ui_palette` setting -// names the same colour family on every client. Keep the three copies in step: a palette added -// here without the others is a value the other clients will silently render as Violet. +// A palette also owns the UI sitting on it: [accent] is the focus wash / selected pill / switch +// colour, and [light] flips the ink so a pale field gets dark text instead of white. +// +// The table, [ramp] and [CELL_RAMP] are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Apple client's `GamepadPalette.swift` under the same ids, so one `ui_palette` value is one look +// on every client. Keep the three copies in step: a palette added here without the others is a +// value the other clients silently render as Violet. -/** - * One background colour family. [hueDegrees] rotates about the grey axis (positive runs - * red → green → blue) and [saturation] scales saturation about luminance. - */ +/** One background colour family. */ class GamepadPalette( /** The stored `ui_palette` value ([Settings.uiPalette]). */ val id: String, /** What the settings row shows. */ val name: String, - val hueDegrees: Double, - val saturation: Double, -) { - /** True for the identity transform, so the default path skips the per-colour work. */ - val isIdentity: Boolean get() = hueDegrees == 0.0 && saturation == 1.0 - - /** Apply this palette to one packed sRGB colour, keeping its alpha. */ - fun tint(c: Color): Color { - if (isIdentity) return c - val (r, g, b) = tint(Triple(c.red.toDouble(), c.green.toDouble(), c.blue.toDouble())) - return Color(r.toFloat(), g.toFloat(), b.toFloat(), c.alpha) - } - /** - * Rotate `c` about the grey axis by [hueDegrees] (Rodrigues — the same rotation, in the same - * orientation, that the desktop console's shader uses for its ±8° warm/cool sway) and scale - * its saturation about luminance. Clamped, because a large rotation can push a channel out of - * gamut. + * The colour ramp, dark end first. Empty = the brand default's explicit field, kept + * bit-identical to what every install already sees. */ - fun tint(c: Triple): Triple { - val (r, g, b) = c - val a = Math.toRadians(hueDegrees) - val cs = cos(a) - val sn = sin(a) - val invSqrt3 = 1.0 / sqrt(3.0) - val grey = (r + g + b) / 3.0 * (1.0 - cs) - // The `sn` term is cross(k, c) with k = (1,1,1)/√3. - val rr = r * cs + (b - g) * invSqrt3 * sn + grey - val rg = g * cs + (r - b) * invSqrt3 * sn + grey - val rb = b * cs + (g - r) * invSqrt3 * sn + grey - val luma = 0.2126 * rr + 0.7152 * rg + 0.0722 * rb - fun mix(v: Double) = (luma + (v - luma) * saturation).coerceIn(0.0, 1.0) - return Triple(mix(rr), mix(rg), mix(rb)) + val stops: List>, + /** The field's ground — what it settles onto and what the calm mix lifts toward. */ + val ground: Triple, + /** The UI accent: focus wash, selected tab pill, switch track. */ + val accent: Triple, + /** A pale field: the UI flips to dark ink and the legibility scrims go white. */ + val light: Boolean, +) { + /** Four drifting blob colours, spread across the ramp so the field shows several hues. */ + val blobColors: List by lazy { + val s = stops.ifEmpty { VIOLET_BLOBS } + (0..3).map { color(ramp(s, 0.15 + 0.25 * it)) } } + /** The field's ground as a Compose colour. */ + val groundColor: Color by lazy { color(ground) } + + /** The accent as a Compose colour. */ + val accentColor: Color by lazy { color(accent) } + companion object { /** - * The six shipped palettes, in cycling order: the brand violet, then cool → warm, then - * the neutral. + * Where each of the 16 mesh cells samples the ramp on the clients that draw a mesh. Kept + * here so the three ports stay one table even though this client approximates the field + * with blobs. + */ + val CELL_RAMP = listOf( + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, + ) + + /** The brand default's blob ramp — the colours the pre-palette field used. */ + private val VIOLET_BLOBS = listOf( + Triple(0.53, 0.47, 0.96), Triple(0.24, 0.20, 0.72), Triple(0.62, 0.30, 0.80), + Triple(0.22, 0.38, 0.86), Triple(0.53, 0.47, 0.96), + ) + + /** + * The twelve shipped palettes: the brand default, five more dark fields, then six pale + * ones. Cycling order runs dark → light, so stepping the row walks the range one way. */ val ALL = listOf( - GamepadPalette("violet", "Violet", 0.0, 1.0), - GamepadPalette("tide", "Tide", -70.0, 1.0), - GamepadPalette("forest", "Forest", -130.0, 0.9), - GamepadPalette("ember", "Ember", 105.0, 1.0), - GamepadPalette("rose", "Rose", 60.0, 0.95), - GamepadPalette("graphite", "Graphite", 0.0, 0.12), + // --- dark fields (white ink) --- + GamepadPalette( + "violet", "Violet", emptyList(), + ground = Triple(0.075, 0.060, 0.160), + accent = Triple(0.525, 0.471, 0.961), light = false, + ), + GamepadPalette( + // Deep indigo climbing through violet into a hot magenta. + "nebula", "Nebula", + listOf( + Triple(0.07, 0.05, 0.20), Triple(0.26, 0.14, 0.54), Triple(0.52, 0.20, 0.72), + Triple(0.82, 0.26, 0.62), Triple(0.98, 0.46, 0.68), + ), + ground = Triple(0.055, 0.040, 0.135), + accent = Triple(0.95, 0.42, 0.72), light = false, + ), + GamepadPalette( + // Ink-blue water: teal → cerulean → a violet undertow. + "abyss", "Abyss", + listOf( + Triple(0.02, 0.10, 0.17), Triple(0.04, 0.28, 0.42), Triple(0.07, 0.46, 0.63), + Triple(0.16, 0.38, 0.78), Triple(0.26, 0.22, 0.58), + ), + ground = Triple(0.018, 0.070, 0.130), + accent = Triple(0.26, 0.76, 0.92), light = false, + ), + GamepadPalette( + // Banked coals: plum embers → crimson → burnt orange → gold. + "ember", "Ember", + listOf( + Triple(0.16, 0.03, 0.10), Triple(0.45, 0.06, 0.12), Triple(0.72, 0.18, 0.06), + Triple(0.90, 0.42, 0.08), Triple(0.95, 0.68, 0.18), + ), + ground = Triple(0.090, 0.035, 0.040), + accent = Triple(0.98, 0.62, 0.26), light = false, + ), + GamepadPalette( + // Forest floor into moss and a lime break. + "moss", "Moss", + listOf( + Triple(0.03, 0.11, 0.09), Triple(0.06, 0.27, 0.20), Triple(0.09, 0.45, 0.31), + Triple(0.28, 0.61, 0.28), Triple(0.58, 0.77, 0.31), + ), + ground = Triple(0.025, 0.085, 0.070), + accent = Triple(0.48, 0.86, 0.46), light = false, + ), + GamepadPalette( + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone. + "graphite", "Graphite", + listOf( + Triple(0.06, 0.07, 0.11), Triple(0.15, 0.18, 0.25), Triple(0.30, 0.31, 0.35), + Triple(0.45, 0.42, 0.38), Triple(0.60, 0.56, 0.49), + ), + ground = Triple(0.055, 0.055, 0.070), + accent = Triple(0.78, 0.80, 0.86), light = false, + ), + // --- pale fields (dark ink) --- + GamepadPalette( + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + "holo", "Holo", + listOf( + Triple(0.99, 0.72, 0.90), Triple(0.80, 0.60, 0.98), Triple(0.58, 0.62, 0.99), + Triple(0.55, 0.86, 0.98), Triple(0.94, 0.98, 1.00), + ), + ground = Triple(0.96, 0.92, 0.99), + accent = Triple(0.42, 0.28, 0.86), light = true, + ), + GamepadPalette( + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + "sunset", "Sunset", + listOf( + Triple(0.55, 0.45, 0.92), Triple(0.86, 0.31, 0.66), Triple(0.97, 0.26, 0.34), + Triple(0.99, 0.51, 0.18), Triple(1.00, 0.80, 0.22), + ), + ground = Triple(0.98, 0.74, 0.34), + accent = Triple(0.64, 0.13, 0.44), light = true, + ), + GamepadPalette( + // Peach into blush and lilac — the softest of the set. + "bloom", "Bloom", + listOf( + Triple(1.00, 0.86, 0.72), Triple(0.99, 0.73, 0.79), Triple(0.95, 0.65, 0.89), + Triple(0.82, 0.68, 0.96), Triple(0.73, 0.79, 0.99), + ), + ground = Triple(0.99, 0.90, 0.89), + accent = Triple(0.72, 0.24, 0.55), light = true, + ), + GamepadPalette( + // First light: pale gold → coral → lilac. + "dawn", "Dawn", + listOf( + Triple(1.00, 0.92, 0.70), Triple(1.00, 0.80, 0.62), Triple(0.99, 0.66, 0.62), + Triple(0.90, 0.62, 0.78), Triple(0.77, 0.69, 0.95), + ), + ground = Triple(1.00, 0.93, 0.82), + accent = Triple(0.82, 0.33, 0.28), light = true, + ), + GamepadPalette( + // Sea glass: mint → aqua → a pale sky. + "mint", "Mint", + listOf( + Triple(0.82, 0.98, 0.90), Triple(0.62, 0.94, 0.88), Triple(0.55, 0.88, 0.95), + Triple(0.63, 0.82, 0.99), Triple(0.82, 0.87, 1.00), + ), + ground = Triple(0.90, 0.98, 0.96), + accent = Triple(0.04, 0.42, 0.40), light = true, + ), + GamepadPalette( + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + "opal", "Opal", + listOf( + Triple(0.98, 0.92, 0.96), Triple(0.87, 0.93, 0.99), Triple(0.91, 0.99, 0.95), + Triple(0.99, 0.96, 0.88), Triple(0.94, 0.90, 0.99), + ), + ground = Triple(0.97, 0.96, 0.99), + accent = Triple(0.36, 0.32, 0.44), light = true, + ), ) /** @@ -80,5 +196,23 @@ class GamepadPalette( * palette a newer client shipped, not a reason to draw nothing. */ fun named(id: String): GamepadPalette = ALL.firstOrNull { it.id == id } ?: ALL[0] + + /** Sample an ordered colour ramp at [t] ∈ [0, 1] (linear between neighbouring stops). */ + fun ramp( + stops: List>, + t: Double, + ): Triple { + if (stops.isEmpty()) return Triple(0.0, 0.0, 0.0) + if (stops.size == 1) return stops[0] + val x = t.coerceIn(0.0, 1.0) * (stops.size - 1) + val i = x.toInt().coerceAtMost(stops.size - 2) + val f = x - i + val (ar, ag, ab) = stops[i] + val (br, bg, bb) = stops[i + 1] + return Triple(ar + (br - ar) * f, ag + (bg - ag) * f, ab + (bb - ab) * f) + } + + fun color(c: Triple): Color = + Color(c.first.toFloat(), c.second.toFloat(), c.third.toFloat()) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 48911910..7da32711 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -346,6 +346,7 @@ fun GamepadSettingsScreen( @Composable private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick: () -> Unit) { + val ink = LocalGamepadInk.current val visuals = animateConsoleFocus(active = focused) val shape = RoundedCornerShape(14.dp) // The chevrons keep their layout slot and only fade, so the value never jumps sideways when @@ -357,7 +358,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick label = "chevrons", ) val valueColor by animateColorAsState( - Color.White.copy(alpha = if (focused) 1f else 0.6f), + ink.fg(if (focused) 1f else 0.6f), tween(160), label = "valueColor", ) @@ -366,7 +367,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick Text( row.header.uppercase(), style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.45f), + color = ink.fg(0.45f), letterSpacing = 1.4.sp, modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp), ) @@ -392,7 +393,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick fontWeight = FontWeight.SemiBold, // A disabled row (the "No profiles yet" placeholder) dims but stays focusable, // so its detail line can still explain what would go here. - color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f), + color = ink.fg(if (row.enabled) 1f else 0.45f), maxLines = 1, ) Spacer(Modifier.weight(1f)) @@ -400,7 +401,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick // A toggle is a switch, not text — the sliding knob + tinting track IS the value. ConsoleSwitch(on = row.toggled, focused = focused) } else { - Text("‹ ", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Text("‹ ", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) // The value slides in the direction it was stepped and its width animates, so // cycling a choice reads as motion through a list rather than a text swap. AnimatedContent( @@ -421,7 +422,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick overflow = TextOverflow.Ellipsis, ) } - Text(" ›", color = Color.White, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) + Text(" ›", color = ink.fg, modifier = Modifier.graphicsLayer { alpha = chevronAlpha }) } } // The focused row carries its own one-line description — no dedicated (space-eating) @@ -434,7 +435,7 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick Text( row.detail, style = MaterialTheme.typography.bodySmall, - color = Color.White.copy(alpha = 0.6f), + color = ink.fg(0.6f), maxLines = 2, modifier = Modifier.padding(top = 6.dp), ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 34fd3a9b..525ee127 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -90,6 +90,7 @@ fun LibraryScreen( onBack: () -> Unit, navActive: Boolean = true, ) { + val ink = LocalGamepadInk.current BackHandler(onBack = onBack) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -177,8 +178,8 @@ fun LibraryScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), ) { - CircularProgressIndicator(color = Color.White) - Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge) + CircularProgressIndicator(color = ink.fg) + Text("Launching…", color = ink.fg, style = MaterialTheme.typography.bodyLarge) } } } @@ -202,17 +203,19 @@ fun LibraryScreen( @Composable private fun LoadingState() { + val ink = LocalGamepadInk.current Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) { - CircularProgressIndicator(color = Color.White) - Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge) + CircularProgressIndicator(color = ink.fg) + Text("Loading library…", color = ink.fg(0.7f), style = MaterialTheme.typography.bodyLarge) } } @Composable private fun MessageState(text: String) { + val ink = LocalGamepadInk.current Text( text, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 24.dp), @@ -226,6 +229,7 @@ private fun Coverflow( navActive: Boolean, onLaunch: (GameEntry) -> Unit, ) { + val ink = LocalGamepadInk.current BoxWithConstraints(Modifier.fillMaxSize()) { // Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen. val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp) @@ -321,7 +325,7 @@ private fun Coverflow( current?.title ?: " ", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = Color.White, + color = ink.fg, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -330,7 +334,7 @@ private fun Coverflow( if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER" else current.storeLabel.uppercase(), style = MaterialTheme.typography.labelMedium, - color = Color.White.copy(alpha = 0.5f), + color = ink.fg(0.5f), letterSpacing = 2.sp, ) } @@ -342,6 +346,7 @@ private fun Coverflow( /** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */ @Composable private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) { + val ink = LocalGamepadInk.current val candidates = game.art.posterCandidates var idx by remember(game.id) { mutableStateOf(0) } val shape = RoundedCornerShape(16.dp) @@ -349,7 +354,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo modifier = modifier .clip(shape) .background(Color(0xFF241F3D)) - .border(1.dp, Color.White.copy(alpha = 0.12f), shape), + .border(1.dp, ink.fg(0.12f), shape), contentAlignment = Alignment.Center, ) { if (idx < candidates.size) { @@ -368,7 +373,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo if (game.isLauncher) game.storeLabel else game.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - color = Color.White.copy(alpha = 0.75f), + color = ink.fg(0.75f), textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp), ) @@ -378,7 +383,7 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo Text( game.storeLabel, style = MaterialTheme.typography.labelSmall, - color = Color.White, + color = ink.fg, modifier = Modifier .clip(RoundedCornerShape(50)) .background( diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt index 40b40b98..df662de4 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/GamepadPaletteTest.kt @@ -5,92 +5,118 @@ import org.junit.Assert.assertTrue import org.junit.Test // The console UI's background palettes. These assertions are the CONTRACT the Rust -// (`pf-console-ui::library::tint`) and Swift (`GamepadPalette.tint`) ports have to reproduce — the -// same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` value -// names the same colour family on every client. +// (`pf-console-ui::library`) and Swift (`GamepadPalette.swift`) ports reproduce — the same ids in +// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look +// on every client. class GamepadPaletteTest { - /** The brightest pool of the field — the colour a palette is judged by. */ - private val violetPool = Triple(0.49, 0.39, 0.95) - /** - * The brand default must be the IDENTITY transform. Every existing install already sees the - * shipped violet backdrop, and a palette table that quietly restyled it would be a regression - * dressed as a feature. - */ - @Test - fun violetIsTheUntouchedShippedField() { - val violet = GamepadPalette.named("violet") - assertEquals("violet", GamepadPalette.ALL.first().id) - assertTrue(violet.isIdentity) - assertEquals(violetPool, violet.tint(violetPool)) - // An unknown name is a newer client's palette, not an error. - assertEquals("violet", GamepadPalette.named("chartreuse").id) - assertEquals("violet", GamepadPalette.named("").id) + private fun luma(c: Triple) = + 0.2126 * c.first + 0.7152 * c.second + 0.0722 * c.third + + /** Hue angle in degrees, or null for something too grey to have one. */ + private fun hue(c: Triple): Double? { + val (r, g, b) = c + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val d = max - min + if (d < 0.04) return null + val h = when (max) { + r -> 60.0 * (((g - b) / d) % 6.0) + g -> 60.0 * ((b - r) / d + 2.0) + else -> 60.0 * ((r - g) / d + 4.0) + } + return (h + 360.0) % 360.0 } - /** The ids and their order are the cross-client contract (strip order, and the L1/R1 cycle). */ + /** Ids, order and the light/dark split are the cross-client contract. */ @Test fun tableMatchesTheOtherClients() { assertEquals( - listOf("violet", "tide", "forest", "ember", "rose", "graphite"), + listOf( + "violet", "nebula", "abyss", "ember", "moss", "graphite", + "holo", "sunset", "bloom", "dawn", "mint", "opal", + ), GamepadPalette.ALL.map { it.id }, ) - assertEquals( - listOf("Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"), - GamepadPalette.ALL.map { it.name }, - ) + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + val firstLight = GamepadPalette.ALL.indexOfFirst { it.light } + assertEquals(6, firstLight) + assertTrue(GamepadPalette.ALL.drop(firstLight).all { it.light }) + // An unknown name is a newer client's palette, not an error. + assertEquals("violet", GamepadPalette.named("chartreuse").id) + assertEquals("violet", GamepadPalette.named("").id) + // The brand default keeps the shipped field rather than a generated ramp. + assertTrue(GamepadPalette.named("violet").stops.isEmpty()) } /** - * A rotation moves the hue while roughly holding luminance, and the saturation scale collapses - * toward grey — the same four checks the Rust and Swift tests make. + * A palette must read as SEVERAL hues, not one hue at several brightnesses — that was exactly + * the complaint about the hue-rotation model this replaced. */ @Test - fun tintRotatesHueAndScalesSaturation() { - assertTrue(violetPool.third > violetPool.first && violetPool.third > violetPool.second) - - // +105° (Ember) turns the blue-dominant pool red-dominant… - val ember = GamepadPalette.named("ember").tint(violetPool) - assertTrue("$ember should be warm", ember.first > ember.third) - // …−130° (Forest) turns it green-dominant… - val forest = GamepadPalette.named("forest").tint(violetPool) - assertTrue("$forest", forest.second > forest.first && forest.second > forest.third) - // …and −70° (Tide) lands on a cyan whose green and blue both beat red. - val tide = GamepadPalette.named("tide").tint(violetPool) - assertTrue("$tide", tide.second > tide.first && tide.third > tide.first) - - // Graphite's saturation scale leaves the channels nearly equal… - val grey = GamepadPalette.named("graphite").tint(violetPool) - val channels = listOf(grey.first, grey.second, grey.third) - assertTrue("$grey", channels.max() - channels.min() < 0.08) - // …at about the source's luminance (it desaturates, it doesn't dim). - val luma = 0.2126 * violetPool.first + 0.7152 * violetPool.second + 0.0722 * violetPool.third - assertEquals(luma, grey.second, 0.05) - } - - /** - * Every palette stays in gamut on every colour the field is built from — an out-of-range - * channel would clamp differently on each platform's rasteriser. - */ - @Test - fun everyPaletteStaysInGamut() { - val field = listOf( - Triple(0.075, 0.060, 0.160), Triple(0.34, 0.27, 0.72), Triple(0.30, 0.26, 0.74), - Triple(0.42, 0.20, 0.54), Triple(0.49, 0.39, 0.95), Triple(0.28, 0.31, 0.84), - Triple(0.16, 0.26, 0.64), Triple(0.45, 0.23, 0.60), Triple(0.53, 0.31, 0.75), - Triple(0.35, 0.35, 0.91), Triple(0.19, 0.28, 0.70), Triple(0.22, 0.18, 0.54), - Triple(0.24, 0.20, 0.58), - ) - for (palette in GamepadPalette.ALL) { - for (c in field) { - val t = palette.tint(c) - for (v in listOf(t.first, t.second, t.third)) { - assertTrue("${palette.id} $c → $t", v in 0.0..1.0) + fun everyPaletteIsMultiTone() { + for (p in GamepadPalette.ALL) { + val stops = p.stops.ifEmpty { continue } + val hues = stops.mapNotNull { hue(it) } + assertTrue("${p.id}: too few coloured stops", hues.size >= 3) + var spread = 0.0 + for (a in hues) { + for (b in hues) { + val d = Math.abs(a - b) % 360.0 + spread = maxOf(spread, minOf(d, 360.0 - d)) } } + // Graphite and Opal are deliberately near-neutral; the rest must travel. + val floor = if (p.id == "graphite" || p.id == "opal") 20.0 else 45.0 + assertTrue("${p.id} spans only $spread° of hue", spread >= floor) } } + /** A pale palette really is pale — its ink flips, so a mislabelled one is unreadable. */ + @Test + fun palettesAreHonestAboutLightness() { + for (p in GamepadPalette.ALL) { + if (p.light) { + assertTrue("${p.id}'s ground is dark", luma(p.ground) > 0.6) + assertTrue("${p.id}'s accent is too pale", luma(p.accent) < 0.45) + } else { + assertTrue("${p.id}'s ground is light", luma(p.ground) < 0.2) + assertTrue("${p.id}'s accent is too dark", luma(p.accent) > 0.25) + } + } + } + + /** The ramp is the shared sampling rule the Rust and Swift ports reproduce. */ + @Test + fun rampInterpolatesBetweenStops() { + val stops = listOf( + Triple(0.0, 0.0, 0.0), Triple(1.0, 0.0, 0.0), Triple(1.0, 1.0, 1.0), + ) + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.0)) + assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 1.0)) + assertEquals(Triple(1.0, 0.0, 0.0), GamepadPalette.ramp(stops, 0.5)) + assertEquals(0.5, GamepadPalette.ramp(stops, 0.25).first, 1e-9) + // Out of range clamps rather than throwing. + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(stops, -3.0)) + assertEquals(Triple(1.0, 1.0, 1.0), GamepadPalette.ramp(stops, 9.0)) + assertEquals(Triple(0.0, 0.0, 0.0), GamepadPalette.ramp(emptyList(), 0.5)) + } + + /** The ink a palette calls for: white on a dark field, near-black on a pale one. */ + @Test + fun inkFollowsTheField() { + val dark = GamepadInk.of(GamepadPalette.named("violet")) + assertTrue(!dark.isLight) + assertEquals(1f, dark.fg.red, 1e-6f) + assertEquals(1f, dark.shadeScale, 1e-6f) + + val light = GamepadInk.of(GamepadPalette.named("holo")) + assertTrue(light.isLight) + assertTrue("pale fields need dark ink", light.fg.red < 0.3f) + // A pale field's scrims must pull far less, or they bleach the gradient. + assertTrue(light.shadeScale < 0.5f) + } + /** * Every settings row lands in exactly one tab — a row missing from the tab map is a setting * that became unreachable on a TV, which is precisely what this screen exists to prevent. diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 70dd0e57..8af7d299 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -109,6 +109,11 @@ class ScreenshotTest { @Test fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() } + /** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */ + @Test + fun consoleSettingsLight() = + shootRoot("console-settings-light") { ConsoleSettingsScene(paletteId = "holo") } + @Test fun trust() = shootScreen("trust") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 43562f37..de69a24e 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -31,7 +31,12 @@ import io.unom.punktfunk.BrandDark import io.unom.punktfunk.ConnectModal import io.unom.punktfunk.ConnectPhase import io.unom.punktfunk.ConnectTakeover +import androidx.compose.runtime.CompositionLocalProvider +import io.unom.punktfunk.GamepadInk +import io.unom.punktfunk.GamepadPalette import io.unom.punktfunk.GamepadSettingsScreen +import io.unom.punktfunk.LocalGamepadInk +import io.unom.punktfunk.LocalGamepadPalette import io.unom.punktfunk.Settings import io.unom.punktfunk.TouchMode import io.unom.punktfunk.SettingsCategory @@ -415,5 +420,17 @@ internal fun ConnectConsoleScene() = * a layout regression would eat first. */ @Composable -internal fun ConsoleSettingsScene() = - GamepadSettingsScreen(initial = SHOT_SETTINGS, onChange = {}, onBack = {}) +internal fun ConsoleSettingsScene(paletteId: String = "violet") { + // The scene calls the screen directly, so it has to publish the palette locals `App` would + // normally provide — without them a light palette would render with the default DARK ink and + // the shot would silently prove nothing. + val palette = GamepadPalette.named(paletteId) + CompositionLocalProvider( + LocalGamepadPalette provides palette, + LocalGamepadInk provides GamepadInk.of(palette), + ) { + GamepadSettingsScreen( + initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {}, + ) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 75bf05b6..2b16327b 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -536,6 +536,9 @@ struct ContentView: View { waker: waker, gamepadUI: gamepadUIActive, onCancelConnect: { model.disconnect() }) + // The takeover mounts OUTSIDE the gamepad screens (it covers the whole home), so + // it publishes the palette's ink itself rather than inheriting it. + .gamepadPaletteInk() } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift index b8246e98..c3fe8b3e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/ConnectOverlay.swift @@ -47,12 +47,16 @@ struct ConnectOverlay: View { return nil } + @Environment(\.gamepadInk) private var ink + var body: some View { if let phase { ZStack { if gamepadUI { - // Console: an opaque, living aurora over everything. - Color.black.ignoresSafeArea() + // Console: an opaque, living aurora over everything, in the chosen palette. + // The takeover's own text rides `ink`, so a pale palette flips it here too — + // without that this is the one console screen that stays white-on-white. + ink.isLight ? Color.white.ignoresSafeArea() : Color.black.ignoresSafeArea() GamepadScreenBackground().ignoresSafeArea() Color.clear.contentShape(Rectangle()).onTapGesture {} content(phase).padding(40).frame(maxWidth: 460) @@ -70,7 +74,8 @@ struct ConnectOverlay: View { .padding(40) } } - .environment(\.colorScheme, .dark) + // The console takeover follows the palette; the default UI's modal stays dark. + .environment(\.colorScheme, gamepadUI && ink.isLight ? .light : .dark) .transition(.opacity) #if os(iOS) || os(macOS) .background { ConnectControllerInput(waker: waker, onCancelConnect: onCancelConnect) } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 30a820c5..f5af6b36 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -12,6 +12,7 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) struct GamepadAddHostView: View { + @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss let onAdd: (StoredHost) -> Void @@ -47,12 +48,12 @@ struct GamepadAddHostView: View { VStack(spacing: 4) { Text("Add Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) if !compact { Text("Hosts on this network appear automatically — add one by address " + "for everything else.") .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .multilineTextAlignment(.center) .frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72) } @@ -73,6 +74,9 @@ struct GamepadAddHostView: View { } // No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen. .background { GamepadFormBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() // A port can't exceed 5 digits — cap while typing so the row can't grow absurd. .onChange(of: port) { _, value in if value.count > 5 { port = String(value.prefix(5)) } @@ -143,7 +147,7 @@ struct GamepadAddHostView: View { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) @@ -180,22 +184,22 @@ struct GamepadAddHostView: View { if row.isAction { Label("Add Host", systemImage: "plus.circle.fill") .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35)) + .foregroundStyle(canAdd ? ink.accent : ink.fg(0.35)) .frame(maxWidth: .infinity) } else { Text(row.label) .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) Spacer(minLength: 12) Text(row.value.isEmpty ? row.placeholder : row.value) .font(.geistFixed(m.valueFont, .medium)) - .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white) + .foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg) .lineLimit(1) .truncationMode(.head) // keep the end of a long address visible while typing if editing == row.id { // The live-edit caret: this row is what the keyboard tray is typing into. Rectangle() - .fill(Color.brand) + .fill(ink.accent) .frame(width: 2, height: m.labelFont + 2) } } @@ -206,12 +210,12 @@ struct GamepadAddHostView: View { // takes the brand wash, and the edited row keeps its brand caret border. .consoleGlass( RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), - tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil, + tint: (focused || editing == row.id) ? ink.accent(0.30) : nil, interactive: focused) .overlay { RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) .strokeBorder( - editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06), + editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 80c5390e..f0fb8fc4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -89,6 +89,7 @@ struct GamepadHint: Identifiable { /// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over /// the backdrop instead of dissolving into it. struct GamepadHintBar: View { + @Environment(\.gamepadInk) private var ink let hints: [GamepadHint] // 10-foot legend on tvOS, in-hand sizes elsewhere. @@ -108,17 +109,17 @@ struct GamepadHintBar: View { HStack(spacing: 7) { Image(systemName: hint.glyph) .font(.system(size: Self.glyphFont)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) Text(hint.text) } .fixedSize() // keep glyph + label together; never truncate a hint mid-word } } .font(.geist(Self.textFont, .semibold, relativeTo: .subheadline)) - .foregroundStyle(.white.opacity(0.85)) + .foregroundStyle(ink.fg(0.85)) .padding(Self.pad) .consoleGlass(Capsule()) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1)) } } @@ -145,6 +146,7 @@ struct GamepadHintBar: View { /// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's /// header). Honors Reduce Motion by freezing the field at a fixed phase. struct GamepadScreenBackground: View { + @Environment(\.gamepadInk) private var ink /// Quiet the field for a form screen (see the type comment). var calm = false @@ -171,36 +173,41 @@ struct GamepadScreenBackground: View { /// vignette, and the title/hints legibility scrim — in that order, matching the console /// shader's `composite` so the two platforms' backdrops stay the same picture. private func composite(at t: TimeInterval, palette: GamepadPalette) -> some View { - ZStack { - Color.black + // Where the scrims tend, and how hard. Mixing a PALE field toward white at the dark + // field's strength bleaches the chroma straight out of the gradient, so a pale palette + // gets under half — the same `u_scrim.a` the console shader carries. + let scrim: Color = palette.light ? ink.fg : .black + let strength = palette.light ? 0.45 : 1.0 + return ZStack { + Self.color(palette.ground) colorField(at: t, palette: palette) // ±8° over ~5 min — the whole field very slowly warms and cools. .hueRotation(.degrees(sin(t * 0.021) * 8)) - // Calm = col·0.6 + corner·0.4: over black, `.opacity` IS the multiply… + // Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply… .opacity(calm ? 0.6 : 1) if calm { - // …and a plusLighter wash of the palette's own corner colour IS the add. Chosen so - // a corner lands exactly where it was and the bright pools come down to meet it. - Self.color(palette.tint(Self.cornerRGB)) + // …and a plusLighter wash of the palette's own ground IS the add. Chosen so the + // ground lands exactly where it was and the bright pools come down to meet it. + Self.color(palette.ground) .opacity(0.4) .blendMode(.plusLighter) } - // Cinematic vignette: darker toward the edges so the cards sit in the pooled light. - // Soft (extends past the frame) so the corners deepen rather than crush to black. - // Halved under calm: a launcher's cards sit in the pooled centre, but a form screen's - // rows run out toward the edges, where crushing to black just eats them. + // Cinematic vignette: the edges settle toward the scrim so the cards sit in the + // pooled light. Soft (extends past the frame) so the corners deepen rather than + // crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form + // screen's rows run out toward the edges, where crushing them just eats the list. EllipticalGradient( - colors: [.clear, .black.opacity(calm ? 0.21 : 0.42)], + colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)], center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15) // Legibility grounding for the pinned title (top) and hint pill (bottom). This one - // darkens the aurora itself (it's the backdrop's bottom layer — nothing behind it to - // blur), so it stays a gradient, just a light one now. + // works on the field itself (it's the backdrop's bottom layer — nothing behind it to + // blur), so it stays a gradient, just a light one. LinearGradient( stops: [ - .init(color: .black.opacity(0.38), location: 0), - .init(color: .black.opacity(0.06), location: 0.32), - .init(color: .black.opacity(0.08), location: 0.68), - .init(color: .black.opacity(0.40), location: 1), + .init(color: scrim.opacity(0.38 * strength), location: 0), + .init(color: scrim.opacity(0.06 * strength), location: 0.32), + .init(color: scrim.opacity(0.08 * strength), location: 0.68), + .init(color: scrim.opacity(0.40 * strength), location: 1), ], startPoint: .top, endPoint: .bottom) } @@ -211,7 +218,7 @@ struct GamepadScreenBackground: View { MeshGradient( width: 4, height: 4, points: Self.meshPoints(at: t), - colors: Self.meshColors(palette), + colors: palette.meshColors.map(Self.color), smoothsColors: true) } else { LegacyBlobField(t: t, palette: palette) @@ -224,28 +231,6 @@ struct GamepadScreenBackground: View { Color(red: c.x, green: c.y, blue: c.z) } - /// The corner colour — the four pinned corners AND the calm lift's base. - static let cornerRGB = SIMD3(0.075, 0.060, 0.160) - - /// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry - /// mid-tone violets, and the four interior points hold the bright brand family — a violet and a - /// blue-violet up top, a magenta-violet and a violet below — so warm pools on the left, cool on - /// the right, and the silk shifts temperature as those interior points drift. A palette rotates - /// the whole grid; `violet` is the identity, so this array IS what the default draws. - private static let baseMeshRGB: [SIMD3] = [ - cornerRGB, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), cornerRGB, - SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64), - SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), - cornerRGB, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), cornerRGB, - ] - - /// `baseMeshRGB` under a palette. Recomputed per frame rather than cached — sixteen `tint` - /// calls at 30 Hz costs nothing next to rasterising the mesh, and the obvious cache would be - /// mutable global state on a type SwiftUI is free to evaluate off the main actor. - private static func meshColors(_ palette: GamepadPalette) -> [Color] { - baseMeshRGB.map { color(palette.tint($0)) } - } - /// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh /// always fills edge-to-edge — a drifting edge point would shrink the mesh and expose the black /// behind it), while only the four interior points wander on slow, out-of-phase sinusoids @@ -280,9 +265,9 @@ private struct LegacyBlobField: View { let palette: GamepadPalette /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds - /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. + /// (rad/s — periods of 30–90 s), and a radius that slowly breathes. The COLOUR comes from the + /// palette's ramp at draw time (see `blobColors`), so an older OS honours the setting too. private struct Blob { - let rgb: SIMD3 let center: CGPoint let drift: CGSize let speed: (x: Double, y: Double) @@ -293,20 +278,16 @@ private struct LegacyBlobField: View { } private static let blobs: [Blob] = [ - Blob(rgb: SIMD3(0.53, 0.47, 0.96), // brand violet - center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10), + Blob(center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10), speed: (0.111, 0.083), phase: (0.0, 1.9), radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52), - Blob(rgb: SIMD3(0.24, 0.20, 0.72), // deep indigo - center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14), + Blob(center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14), speed: (0.071, 0.096), phase: (2.4, 0.7), radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55), - Blob(rgb: SIMD3(0.62, 0.30, 0.80), // plum - center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09), + Blob(center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09), speed: (0.089, 0.067), phase: (4.1, 3.2), radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42), - Blob(rgb: SIMD3(0.22, 0.38, 0.86), // cool blue - center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08), + Blob(center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08), speed: (0.059, 0.104), phase: (1.2, 5.0), radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38), ] @@ -316,19 +297,21 @@ private struct LegacyBlobField: View { let side = max(geo.size.width, geo.size.height) ZStack { ForEach(Self.blobs.indices, id: \.self) { i in - blobView(Self.blobs[i], in: geo.size, side: side) + blobView(Self.blobs[i], tone: palette.blobColors[i], in: geo.size, side: side) } } .drawingGroup() } } - private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View { + private func blobView( + _ blob: Blob, tone: SIMD3, in size: CGSize, side: CGFloat + ) -> some View { let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x)) let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y)) let r = side * blob.radius * (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x))) - let color = GamepadScreenBackground.color(palette.tint(blob.rgb)) + let color = GamepadScreenBackground.color(tone) return Circle() .fill(RadialGradient( colors: [color, color.opacity(0)], @@ -336,7 +319,9 @@ private struct LegacyBlobField: View { .frame(width: r, height: r) .position(x: x * size.width, y: y * size.height) .opacity(blob.opacity) - .blendMode(.plusLighter) + // Additive only works over a DARK ground; over a pale one every blob saturates to + // white and the field turns grey. Pale palettes tint instead. + .blendMode(palette.light ? .normal : .plusLighter) } } @@ -346,15 +331,17 @@ private struct LegacyBlobField: View { /// the tray's text sits on a softly blurred backdrop that dissolves into the rows. struct GamepadTrayScrim: View { let edge: VerticalEdge + @Environment(\.gamepadInk) private var ink var body: some View { let fromEdge: UnitPoint = edge == .top ? .top : .bottom let toContent: UnitPoint = edge == .top ? .bottom : .top Rectangle() .fill(.ultraThinMaterial) - // These trays always sit on the dark console UI; force dark so the material frosts dark - // (white text stays legible) regardless of the system appearance. - .environment(\.colorScheme, .dark) + // Force the frost to match the PALETTE, not the system appearance: the tray exists + // to keep the pinned title legible, so it has to frost dark under white ink and + // light under dark ink. + .environment(\.colorScheme, ink.isLight ? .light : .dark) // Fade the whole blur out toward the content so it dissolves rather than ending on a line. .mask { LinearGradient( @@ -404,6 +391,7 @@ struct ConsoleBareButtonStyle: ButtonStyle { /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders /// when the pad or its battery state changes. struct ControllerStatusChip: View { + @Environment(\.gamepadInk) private var ink let controller: GamepadManager.DiscoveredController // Legible from the couch on tvOS, quiet in hand elsewhere. @@ -428,15 +416,15 @@ struct ControllerStatusChip: View { Image(systemName: batterySymbol(level)) .font(.system(size: Self.font)) .foregroundStyle(level <= 0.2 && !controller.isCharging - ? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) + ? AnyShapeStyle(.red) : AnyShapeStyle(ink.fg(0.7))) } } .font(.geist(Self.font, .medium, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(ink.fg(0.7)) .padding(.horizontal, Self.hPad) .padding(.vertical, Self.vPad) - .background(Capsule().fill(.white.opacity(0.08))) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + .background(Capsule().fill(ink.fg(0.08))) + .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1)) } private func batterySymbol(_ level: Float) -> String { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 1282fdb8..030ce2dd 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -64,6 +64,7 @@ private struct HomeTile: Identifiable { } struct GamepadHomeView: View { + @Environment(\.gamepadInk) private var ink @ObservedObject var store: HostStore @ObservedObject var model: SessionModel @ObservedObject var discovery: HostDiscovery @@ -115,6 +116,9 @@ struct GamepadHomeView: View { .padding(.top, compact ? 4 : 8) } .background { GamepadScreenBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() .onAppear { discovery.start() } .onDisappear { discovery.stop() } // Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show @@ -186,7 +190,7 @@ struct GamepadHomeView: View { statusChip(hidden: true) Text("Select a Host") .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) .frame(maxWidth: .infinity) @@ -355,6 +359,7 @@ struct GamepadHomeView: View { /// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by /// the caller's `.scrollTransition` so it always tracks the real scroll position. private struct GamepadHostTile: View { + @Environment(\.gamepadInk) private var ink let tile: HomeTile let size: CGSize @@ -394,7 +399,7 @@ private struct GamepadHostTile: View { if tile.isPaired { Image(systemName: "lock.fill") .font(.system(size: Self.statusFont, weight: .semibold)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(ink.fg(0.5)) } if tile.isOnline { Circle() @@ -407,7 +412,7 @@ private struct GamepadHostTile: View { Spacer(minLength: 0) Text(tile.title) .font(.geist(Self.titleFont, .bold, relativeTo: .title2)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.7) if let profile = tile.profile { @@ -417,7 +422,7 @@ private struct GamepadHostTile: View { } Text(tile.subtitle) .font(.geist(Self.subtitleFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .lineLimit(1) .padding(.top, 2) } @@ -427,12 +432,12 @@ private struct GamepadHostTile: View { // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself. .consoleGlass( RoundedRectangle(cornerRadius: Self.corner, style: .continuous), - tint: tile.filled ? Color.brand.opacity(0.20) : nil) + tint: tile.filled ? ink.accent(0.20) : nil) .overlay { RoundedRectangle(cornerRadius: Self.corner, style: .continuous) .strokeBorder( LinearGradient( - colors: [.white.opacity(0.22), .white.opacity(0.04)], + colors: [ink.fg(0.22), ink.fg(0.04)], startPoint: .top, endPoint: .bottom), style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5])) } @@ -444,15 +449,15 @@ private struct GamepadHostTile: View { return ZStack { shape.fill(tile.filled ? AnyShapeStyle(LinearGradient( - colors: [Color.brand, Color.brand.opacity(0.68)], + colors: [ink.accent, ink.accent(0.68)], startPoint: .top, endPoint: .bottom)) - : AnyShapeStyle(Color.brand.opacity(0.16))) + : AnyShapeStyle(ink.accent(0.16))) if tile.isConnecting { - ProgressView().tint(.white) + ProgressView().tint(ink.fg) } else if let icon = tile.icon { Image(systemName: icon) .font(.system(size: Self.iconFont, weight: .semibold)) - .foregroundStyle(Color.brand) + .foregroundStyle(ink.accent) } else if let mark = osIconImage(for: tile.osChain) { // The OS mark stands in for the initial (template asset — tints like the text it // replaces), and carries the label, since nothing else on the tile names the OS. @@ -460,18 +465,18 @@ private struct GamepadHostTile: View { .resizable() .scaledToFit() .frame(width: Self.monogramFont, height: Self.monogramFont) - .foregroundStyle(tile.filled ? .white : Color.brand) + .foregroundStyle(tile.filled ? ink.fg : ink.accent) .accessibilityLabel(tile.osChain ?? "") } else { Text(monogram(tile.title)) .font(.geistFixed(Self.monogramFont, .bold)) - .foregroundStyle(tile.filled ? .white : Color.brand) + .foregroundStyle(tile.filled ? ink.fg : ink.accent) } } .frame(width: Self.badgeSide, height: Self.badgeSide) .overlay { if !tile.filled { - shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1) + shape.strokeBorder(ink.accent(0.5), lineWidth: 1) } } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift new file mode 100644 index 00000000..fbb74b34 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadInk.swift @@ -0,0 +1,91 @@ +// The ink the gamepad UI draws with under the chosen background palette. +// +// The console screens were white-on-dark throughout with the brand violet hardcoded as the +// accent. Both had to become palette-derived at once: a pale field needs dark text or it is +// unreadable, and a violet focus wash on a copper field is exactly the clash this exists to fix. +// +// Handed down the view tree as an environment value rather than passed to each screen, so a +// leaf (a row, a hint pill, a card) can ask for the right colour without every caller in between +// knowing about palettes. `pf-console-ui` does the same thing with a thread-local `Ink`. + +import PunktfunkShared +import SwiftUI + +#if os(iOS) || os(macOS) || os(tvOS) + +struct GamepadInk: Equatable, Sendable { + /// Primary text/glyph colour. + let fg: Color + /// Focus wash, selected tab pill, switch track, caret — the palette's own accent. + let accent: Color + /// What reads ON the accent (a filled pill's label). + let onAccent: Color + /// The base fill every glass surface starts from. + let glass: Color + /// What a wash laid UNDER text tends toward: black on a dark field, white on a pale one. + let shade: Color + /// How hard those washes go. A pale field needs far less — mixing toward white at the dark + /// field's strength bleaches the chroma straight out of the gradient. + let shadeScale: Double + /// True when the field is pale, for the few places that need to branch rather than blend + /// (a material's `colorScheme`, a shadow's presence). + let isLight: Bool + + /// The foreground at `alpha`. + func fg(_ alpha: Double) -> Color { fg.opacity(alpha) } + /// The accent at `alpha`. + func accent(_ alpha: Double) -> Color { accent.opacity(alpha) } + /// A wash under text: `alpha` is the dark-field strength, scaled for a pale one. + func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) } + + static func of(_ p: GamepadPalette) -> GamepadInk { + let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z) + let accentLuma = 0.2126 * p.accent.x + 0.7152 * p.accent.y + 0.0722 * p.accent.z + // Chosen by luminance, not by `light`: an accent is picked for contrast against the + // GLASS, not against the field. + let onAccent: Color = accentLuma > 0.55 ? .black : .white + guard p.light else { + return GamepadInk( + fg: .white, accent: accent, onAccent: onAccent, + glass: Color(red: 0.086, green: 0.086, blue: 0.125), + shade: .black, shadeScale: 1, isLight: false) + } + return GamepadInk( + // Tinted toward the palette's own ground so it doesn't read as a foreign grey. + fg: Color(red: p.ground.x * 0.16, green: p.ground.y * 0.14, blue: p.ground.z * 0.20), + accent: accent, onAccent: onAccent, + glass: .white, + shade: .white, shadeScale: 0.45, isLight: true) + } + + /// The shipped dark look — what a preview or a test composition gets. + static let dark = GamepadInk.of(GamepadPalette.named("violet")) +} + +private struct GamepadInkKey: EnvironmentKey { + static let defaultValue = GamepadInk.dark +} + +extension EnvironmentValues { + /// The ink of the palette currently drawing. Set once, high up (see `GamepadInkModifier`). + var gamepadInk: GamepadInk { + get { self[GamepadInkKey.self] } + set { self[GamepadInkKey.self] = newValue } + } +} + +extension View { + /// Resolve the stored `ui_palette` and publish its ink to everything below. Applied by the + /// gamepad screens' common root so no individual view has to read the setting. + func gamepadPaletteInk() -> some View { modifier(GamepadInkModifier()) } +} + +private struct GamepadInkModifier: ViewModifier { + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + + func body(content: Content) -> some View { + content.environment(\.gamepadInk, GamepadInk.of(GamepadPalette.named(paletteID))) + } +} + +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift index 05882914..88965c6c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift @@ -14,6 +14,7 @@ import SwiftUI #if os(iOS) || os(macOS) struct GamepadKeyboard: View { + @Environment(\.gamepadInk) private var ink @Binding var text: String /// Restricts typed characters (e.g. digits for a port field); backspace always works. var allowed: CharacterSet? @@ -79,7 +80,7 @@ struct GamepadKeyboard: View { } .overlay { RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) + .strokeBorder(ink.fg(0.12), lineWidth: 1) } .sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.impact(weight: .light), trigger: pressTick) @@ -110,11 +111,11 @@ struct GamepadKeyboard: View { .font(.geist(15, .semibold, relativeTo: .callout)) } } - .foregroundStyle(focused ? Color.black : .white) + .foregroundStyle(focused ? Color.black : ink.fg) .frame(maxWidth: .infinity, minHeight: compact ? 34 : 42) .background { RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08))) + .fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(0.08))) } .animation(.smooth(duration: 0.12), value: focused) .contentShape(Rectangle()) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index e4d3593f..5f73b0ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -19,6 +19,7 @@ import SwiftUI import GameController struct LibraryCoverflowView: View { + @Environment(\.gamepadInk) private var ink let games: [GameEntry] let imageSession: URLSession? var onLaunch: ((String) -> Void)? @@ -46,6 +47,9 @@ struct LibraryCoverflowView: View { .padding(.vertical, compact ? 6 : 10) } .background { GamepadScreenBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() } @ViewBuilder private func content(for size: CGSize) -> some View { @@ -97,7 +101,7 @@ struct LibraryCoverflowView: View { } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) + .strokeBorder(ink.fg(0.12), lineWidth: 1) } .shadow(color: .black.opacity(0.5), radius: 16, y: 12) .scrollTransition { content, phase in @@ -131,7 +135,7 @@ struct LibraryCoverflowView: View { return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES") .font(.geist(11, .semibold, relativeTo: .caption2)) .tracking(1.4) - .foregroundStyle(.white.opacity(0.45)) + .foregroundStyle(ink.fg(0.45)) } /// The centered title + store tag — empty (not hidden) so the layout doesn't jump. @@ -140,18 +144,19 @@ struct LibraryCoverflowView: View { VStack(spacing: 6) { Text(game?.title ?? " ") .font(.geist(compact ? 22 : 25, .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) .multilineTextAlignment(.center) if let game { + // main's richer store label, in the palette's ink. Text( game.isLauncher ? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased() ) .font(.geist(11, .semibold, relativeTo: .caption2)) .tracking(1.2) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(ink.fg(0.5)) } } .frame(maxWidth: .infinity) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 355c72d7..c23452cb 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -45,6 +45,7 @@ enum GpSettingsTab: String, CaseIterable, Hashable { } struct GamepadSettingsView: View { + @Environment(\.gamepadInk) private var ink @Environment(\.dismiss) private var dismiss /// The saved-host store — the pin picker writes `setPinned` through it and the profile rows /// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen @@ -136,7 +137,7 @@ struct GamepadSettingsView: View { VStack(spacing: compact ? 4 : 8) { Text(title) .font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .frame(maxWidth: .infinity) .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } // The picker is one layer deeper — its rows aren't sections of anything, so the @@ -151,7 +152,7 @@ struct GamepadSettingsView: View { VStack(alignment: .leading, spacing: 8) { Text(focusedDetail) .font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(ink.fg(0.55)) .lineLimit(2, reservesSpace: true) .animation(.smooth(duration: 0.2), value: focusID) GamepadHintBar(hints: hints) @@ -168,6 +169,9 @@ struct GamepadSettingsView: View { // colour and luminance to lens without the launcher's contrast, and the palette setting // applies here too, so this screen previews the row you're stepping. .background { GamepadFormBackground() } + // Publish the palette's ink to this screen (text, glass, accent, scrims) — a + // pale palette flips all of them, and no leaf should have to read the setting. + .gamepadPaletteInk() .onAppear { gamepads.refresh() gamepads.startDiscovery() @@ -220,7 +224,7 @@ struct GamepadSettingsView: View { let selected = t == tab return Text(t.rawValue) .font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote)) - .foregroundStyle(selected ? .white : .white.opacity(0.55)) + .foregroundStyle(selected ? ink.fg : ink.fg(0.55)) .padding(.horizontal, 13) .padding(.vertical, 7) .background { @@ -228,7 +232,7 @@ struct GamepadSettingsView: View { // in and out — the highlight travels the way the press did. if selected { Capsule() - .fill(Color.brand.opacity(0.85)) + .fill(ink.accent(0.85)) .matchedGeometryEffect(id: "tab", in: tabHighlight) } } @@ -276,7 +280,7 @@ struct GamepadSettingsView: View { Button { dismiss() } label: { Image(systemName: "xmark") .font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide) .glassBackground(Circle(), interactive: true) .contentShape(Circle()) @@ -347,18 +351,18 @@ struct GamepadSettingsView: View { HStack(spacing: 14) { Image(systemName: row.icon) .font(.system(size: m.iconFont)) - .foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) + .foregroundStyle(focused ? ink.accent : ink.fg(0.55)) .frame(width: m.iconWidth) Text(row.label) .font(.geist(m.labelFont, .semibold, relativeTo: .body)) - .foregroundStyle(.white) + .foregroundStyle(ink.fg) .lineLimit(1) Spacer(minLength: 12) HStack(spacing: 9) { Image(systemName: "chevron.left") .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( - .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) + ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0)) // Keyed by the value so a change slides the new option in instead of // hard-swapping the string — a QUIET horizontal slip following the user's // motion (a right-step enters from the right), crossfading over ~14 pt. @@ -369,7 +373,7 @@ struct GamepadSettingsView: View { ZStack { Text(row.value) .font(.geist(m.valueFont, .medium, relativeTo: .callout)) - .foregroundStyle(focused ? .white : .white.opacity(0.6)) + .foregroundStyle(focused ? ink.fg : ink.fg(0.6)) .lineLimit(1) .id(row.value) .transition(.asymmetric( @@ -380,7 +384,7 @@ struct GamepadSettingsView: View { Image(systemName: "chevron.right") .font(.system(size: m.chevronFont, weight: .semibold)) .foregroundStyle( - .white.opacity(focused && row.adjustable && row.enabled ? 0.6 : 0)) + ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0)) } } // Contents only — the glass and border below stay at full strength, so a dimmed row @@ -391,11 +395,11 @@ struct GamepadSettingsView: View { // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press. .consoleGlass( RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous), - tint: focused ? Color.brand.opacity(0.30) : nil, + tint: focused ? ink.accent(0.30) : nil, interactive: focused) .overlay { RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous) - .strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1) + .strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1) } .scaleEffect(focused ? 1.0 : 0.98) .animation(.smooth(duration: 0.18), value: focused) diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index bfa111c7..6f429e44 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -80,6 +80,12 @@ private struct ConsoleGlass: ViewModifier { let shape: S var tint: Color? var interactive = false + /// The console surface follows the background palette: a PALE field needs the material to + /// frost light and the glass to read as white, or the dark ink on top of it disappears. + /// Defaults to the dark ink, so every non-gamepad caller is unchanged. + @Environment(\.gamepadInk) private var ink + + private var scheme: ColorScheme { ink.isLight ? .light : .dark } func body(content: Content) -> some View { #if os(tvOS) @@ -89,16 +95,16 @@ private struct ConsoleGlass: ViewModifier { // the 10-foot platform). The tint rides an overlay so the focused row keeps its wash. content.background { shape.fill(.ultraThinMaterial) - .environment(\.colorScheme, .dark) + .environment(\.colorScheme, scheme) .overlay { if let tint { shape.fill(tint) } } } #else if #available(iOS 26, macOS 26, *) { - content.glassEffect(glass, in: shape) + content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) } else { - content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) } + content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) } } #endif } diff --git a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift index c5c1b976..09b6d702 100644 --- a/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift +++ b/clients/apple/Sources/PunktfunkShared/GamepadPalette.swift @@ -1,14 +1,18 @@ -// The gamepad UI's background colour families. +// The gamepad UI's background colour families, and the ink each one calls for. // -// A palette is NOT a second hand-tuned colour field: it is a hue rotation + saturation scale -// applied to the ONE field GamepadScreenBackground already draws, so every palette inherits its -// structure (dark corners, bright interior pools, warm-left/cool-right) and the brand default is -// exactly the shipped look — `violet` is the identity transform. +// A palette is a short ordered ramp of DISTINCT hues, not one hue at several brightnesses. The +// 4×4 mesh samples that ramp diagonally with a per-cell offset (`cellRamp`), so neighbouring +// cells land on different parts of it and the colours pool and swirl the way a real gradient +// poster does; the mesh's existing control-point drift then moves those pools around. An earlier +// version rotated ONE field's hue per palette, which is why every non-default palette read flat. // -// The table and the `tint` math are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the -// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so the shared `ui_palette` -// setting names the same colour family on every client. Keep the three copies in step: a palette -// added here without the others is a value the other clients will silently render as Violet. +// A palette also owns the UI sitting on it: `accent` is the focus wash / selected pill / switch +// colour, and `light` flips the ink so a pale field gets dark text instead of white. +// +// The table, `ramp` and `cellRamp` are mirrored in `pf-console-ui`'s `library.rs` (Rust) and the +// Android client's `GamepadPalette.kt` (Kotlin) under the same ids, so one `ui_palette` value is +// one look on every client. Keep the three copies in step: a palette added here without the +// others is a value the other clients silently render as Violet. // // It lives in PunktfunkShared rather than next to the views because that is the target the tests // can reach — the arithmetic below is the part that has to agree across three languages. @@ -21,20 +25,121 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable { public let id: String /// What the settings row shows. public let name: String - /// Hue rotation about the grey axis, degrees — positive runs red → green → blue. - public let hueDegrees: Double - /// Saturation scale about luminance; 1 keeps the source saturation. - public let saturation: Double + /// The colour ramp, dark end first. Empty = use `violetMesh` verbatim (the brand default, + /// kept bit-identical to what every install already sees). + public let stops: [SIMD3] + /// The field's ground — what the corners settle onto and what the calm mix lifts toward. + public let ground: SIMD3 + /// The UI accent: focus wash, selected tab pill, switch track, caret. + public let accent: SIMD3 + /// A pale field: the UI flips to dark ink and the legibility scrims go white. + public let light: Bool - /// The six shipped palettes, in cycling order: the brand violet, then cool → warm, then the - /// neutral. + /// Where each of the 16 mesh cells samples the ramp. The base is the diagonal + /// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one — and the + /// per-cell nudges break the banding a pure diagonal would give, so hues pool instead of + /// striping. + static let cellRamp: [Double] = [ + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, + ] + + /// The brand default's 16 mesh colours, row-major 4×4: dark-violet corners sink the frame, + /// the edges carry mid-tone violets, and the interior holds the bright brand family. + public static let violetMesh: [SIMD3] = { + let corner = SIMD3(0.075, 0.060, 0.160) + return [ + corner, SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), corner, + SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), SIMD3(0.16, 0.26, 0.64), + SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), + corner, SIMD3(0.22, 0.18, 0.54), SIMD3(0.24, 0.20, 0.58), corner, + ] + }() + + /// The brand default's blob ramp — the four colours the pre-18/15 legacy field used, kept so + /// `violet` is unchanged on older OSes too. + static let violetBlobs: [SIMD3] = [ + SIMD3(0.53, 0.47, 0.96), SIMD3(0.24, 0.20, 0.72), SIMD3(0.62, 0.30, 0.80), + SIMD3(0.22, 0.38, 0.86), SIMD3(0.53, 0.47, 0.96), + ] + + /// The twelve shipped palettes: the brand default, five more dark fields, then six pale + /// ones. Cycling order runs dark → light, so stepping the row walks the whole range one way. public static let all: [GamepadPalette] = [ - GamepadPalette(id: "violet", name: "Violet", hueDegrees: 0, saturation: 1.0), - GamepadPalette(id: "tide", name: "Tide", hueDegrees: -70, saturation: 1.0), - GamepadPalette(id: "forest", name: "Forest", hueDegrees: -130, saturation: 0.9), - GamepadPalette(id: "ember", name: "Ember", hueDegrees: 105, saturation: 1.0), - GamepadPalette(id: "rose", name: "Rose", hueDegrees: 60, saturation: 0.95), - GamepadPalette(id: "graphite", name: "Graphite", hueDegrees: 0, saturation: 0.12), + // --- dark fields (white ink) --- + GamepadPalette( + id: "violet", name: "Violet", stops: [], + ground: SIMD3(0.075, 0.060, 0.160), accent: SIMD3(0.525, 0.471, 0.961), light: false), + GamepadPalette( + // Deep indigo climbing through violet into a hot magenta. + id: "nebula", name: "Nebula", + stops: [SIMD3(0.07, 0.05, 0.20), SIMD3(0.26, 0.14, 0.54), SIMD3(0.52, 0.20, 0.72), + SIMD3(0.82, 0.26, 0.62), SIMD3(0.98, 0.46, 0.68)], + ground: SIMD3(0.055, 0.040, 0.135), accent: SIMD3(0.95, 0.42, 0.72), light: false), + GamepadPalette( + // Ink-blue water: teal → cerulean → a violet undertow. + id: "abyss", name: "Abyss", + stops: [SIMD3(0.02, 0.10, 0.17), SIMD3(0.04, 0.28, 0.42), SIMD3(0.07, 0.46, 0.63), + SIMD3(0.16, 0.38, 0.78), SIMD3(0.26, 0.22, 0.58)], + ground: SIMD3(0.018, 0.070, 0.130), accent: SIMD3(0.26, 0.76, 0.92), light: false), + GamepadPalette( + // Banked coals: plum embers → crimson → burnt orange → gold. + id: "ember", name: "Ember", + stops: [SIMD3(0.16, 0.03, 0.10), SIMD3(0.45, 0.06, 0.12), SIMD3(0.72, 0.18, 0.06), + SIMD3(0.90, 0.42, 0.08), SIMD3(0.95, 0.68, 0.18)], + ground: SIMD3(0.090, 0.035, 0.040), accent: SIMD3(0.98, 0.62, 0.26), light: false), + GamepadPalette( + // Forest floor into moss and a lime break. + id: "moss", name: "Moss", + stops: [SIMD3(0.03, 0.11, 0.09), SIMD3(0.06, 0.27, 0.20), SIMD3(0.09, 0.45, 0.31), + SIMD3(0.28, 0.61, 0.28), SIMD3(0.58, 0.77, 0.31)], + ground: SIMD3(0.025, 0.085, 0.070), accent: SIMD3(0.48, 0.86, 0.46), light: false), + GamepadPalette( + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone. + id: "graphite", name: "Graphite", + stops: [SIMD3(0.06, 0.07, 0.11), SIMD3(0.15, 0.18, 0.25), SIMD3(0.30, 0.31, 0.35), + SIMD3(0.45, 0.42, 0.38), SIMD3(0.60, 0.56, 0.49)], + ground: SIMD3(0.055, 0.055, 0.070), accent: SIMD3(0.78, 0.80, 0.86), light: false), + // --- pale fields (dark ink) --- + GamepadPalette( + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + id: "holo", name: "Holo", + stops: [SIMD3(0.99, 0.72, 0.90), SIMD3(0.80, 0.60, 0.98), SIMD3(0.58, 0.62, 0.99), + SIMD3(0.55, 0.86, 0.98), SIMD3(0.94, 0.98, 1.00)], + ground: SIMD3(0.96, 0.92, 0.99), accent: SIMD3(0.42, 0.28, 0.86), light: true), + GamepadPalette( + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + id: "sunset", name: "Sunset", + stops: [SIMD3(0.55, 0.45, 0.92), SIMD3(0.86, 0.31, 0.66), SIMD3(0.97, 0.26, 0.34), + SIMD3(0.99, 0.51, 0.18), SIMD3(1.00, 0.80, 0.22)], + ground: SIMD3(0.98, 0.74, 0.34), accent: SIMD3(0.64, 0.13, 0.44), light: true), + GamepadPalette( + // Peach into blush and lilac — the softest of the set. + id: "bloom", name: "Bloom", + stops: [SIMD3(1.00, 0.86, 0.72), SIMD3(0.99, 0.73, 0.79), SIMD3(0.95, 0.65, 0.89), + SIMD3(0.82, 0.68, 0.96), SIMD3(0.73, 0.79, 0.99)], + ground: SIMD3(0.99, 0.90, 0.89), accent: SIMD3(0.72, 0.24, 0.55), light: true), + GamepadPalette( + // First light: pale gold → coral → lilac. + id: "dawn", name: "Dawn", + stops: [SIMD3(1.00, 0.92, 0.70), SIMD3(1.00, 0.80, 0.62), SIMD3(0.99, 0.66, 0.62), + SIMD3(0.90, 0.62, 0.78), SIMD3(0.77, 0.69, 0.95)], + ground: SIMD3(1.00, 0.93, 0.82), accent: SIMD3(0.82, 0.33, 0.28), light: true), + GamepadPalette( + // Sea glass: mint → aqua → a pale sky. + id: "mint", name: "Mint", + stops: [SIMD3(0.82, 0.98, 0.90), SIMD3(0.62, 0.94, 0.88), SIMD3(0.55, 0.88, 0.95), + SIMD3(0.63, 0.82, 0.99), SIMD3(0.82, 0.87, 1.00)], + ground: SIMD3(0.90, 0.98, 0.96), accent: SIMD3(0.04, 0.42, 0.40), light: true), + GamepadPalette( + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + id: "opal", name: "Opal", + stops: [SIMD3(0.98, 0.92, 0.96), SIMD3(0.87, 0.93, 0.99), SIMD3(0.91, 0.99, 0.95), + SIMD3(0.99, 0.96, 0.88), SIMD3(0.94, 0.90, 0.99)], + ground: SIMD3(0.97, 0.96, 0.99), accent: SIMD3(0.36, 0.32, 0.44), light: true), ] /// The palette stored under `id`, falling back to the brand default — an unknown name is a @@ -43,37 +148,30 @@ public struct GamepadPalette: Identifiable, Equatable, Sendable { all.first { $0.id == id } ?? all[0] } - /// `true` for the identity transform, so the default path can skip the per-colour work. - public var isIdentity: Bool { hueDegrees == 0 && saturation == 1 } - - /// Apply this palette to one RGB triple. - public func tint(_ c: SIMD3) -> SIMD3 { - guard !isIdentity else { return c } - return GamepadPalette.tint(c, hueDegrees: hueDegrees, saturation: saturation) + /// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). + public static func ramp(_ stops: [SIMD3], _ t: Double) -> SIMD3 { + guard let first = stops.first else { return SIMD3(0, 0, 0) } + guard stops.count > 1 else { return first } + let x = min(max(t, 0), 1) * Double(stops.count - 1) + let i = min(Int(x.rounded(.down)), stops.count - 2) + let f = x - Double(i) + return stops[i] + (stops[i + 1] - stops[i]) * f } - /// Rotate `c` about the grey axis by `hueDegrees` (Rodrigues — the same rotation the field's - /// own ±8° warm/cool sway uses, in the same orientation) and scale its saturation about - /// luminance. Clamped, because a large rotation can push a channel out of gamut. - /// - /// Deliberately computed here rather than left to SwiftUI's `.hueRotation`: that modifier's - /// exact behaviour is the framework's, and the Rust and Kotlin clients have no equivalent — - /// doing the arithmetic on the COLOURS keeps the three implementations identical. - public static func tint( - _ c: SIMD3, hueDegrees: Double, saturation: Double - ) -> SIMD3 { - let a = hueDegrees * .pi / 180 - let cs = cos(a) - let sn = sin(a) - let invSqrt3 = 1 / 3.0.squareRoot() - let grey = (c.x + c.y + c.z) / 3 * (1 - cs) - // The `sn` term is cross(k, c) with k = (1,1,1)/√3. - let rot = SIMD3( - c.x * cs + (c.z - c.y) * invSqrt3 * sn + grey, - c.y * cs + (c.x - c.z) * invSqrt3 * sn + grey, - c.z * cs + (c.y - c.x) * invSqrt3 * sn + grey) - let luma = 0.2126 * rot.x + 0.7152 * rot.y + 0.0722 * rot.z - func mix(_ v: Double) -> Double { min(max(luma + (v - luma) * saturation, 0), 1) } - return SIMD3(mix(rot.x), mix(rot.y), mix(rot.z)) + /// The 16 mesh colours for this palette: the ramp sampled per cell, or `violetMesh` verbatim + /// for the brand default. + public var meshColors: [SIMD3] { + guard !stops.isEmpty else { return Self.violetMesh } + return (0..<16).map { i in + let (x, y) = (Double(i % 4) / 3.0, Double(i / 4) / 3.0) + return Self.ramp(stops, 0.5 * (x + y) + Self.cellRamp[i]) + } + } + + /// Four drifting blob colours for the pre-18/15 legacy field. Spread across the ramp so it + /// still shows several hues at once. + public var blobColors: [SIMD3] { + let s = stops.isEmpty ? Self.violetBlobs : stops + return (0..<4).map { Self.ramp(s, 0.15 + 0.25 * Double($0)) } } } diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift index 8b2ab4f7..2a5f7b28 100644 --- a/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadPaletteTests.swift @@ -1,81 +1,110 @@ // The gamepad UI's background palettes. These assertions are the CONTRACT the Rust -// (`pf-console-ui::library::tint`) and Kotlin (`GamepadPalette.tint`) ports have to reproduce — -// the same ids, the same rotation orientation, the same in-gamut results — so one `ui_palette` -// value names the same colour family on every client. +// (`pf-console-ui::library`) and Kotlin (`GamepadPalette.kt`) ports reproduce — the same ids in +// the same order, the same light/dark split, the same ramp — so one `ui_palette` value is one look +// on every client. import XCTest import simd @testable import PunktfunkShared final class GamepadPaletteTests: XCTestCase { - /// The brightest interior pool of the mesh field — the colour a palette is judged by. - private let violetPool = SIMD3(0.49, 0.39, 0.95) + private func luma(_ c: SIMD3) -> Double { + 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z + } - /// The brand default must be the IDENTITY transform. Every existing install already sees the - /// shipped violet backdrop, and a palette table that quietly restyled it would be a + /// Hue angle in degrees, or nil for something too grey to have one. + private func hue(_ c: SIMD3) -> Double? { + let maxV = max(c.x, c.y, c.z) + let minV = min(c.x, c.y, c.z) + let d = maxV - minV + guard d >= 0.04 else { return nil } + let h: Double + if maxV == c.x { + h = 60 * (((c.y - c.z) / d).truncatingRemainder(dividingBy: 6)) + } else if maxV == c.y { + h = 60 * ((c.z - c.x) / d + 2) + } else { + h = 60 * ((c.x - c.y) / d + 4) + } + return (h + 360).truncatingRemainder(dividingBy: 360) + } + + /// The brand default must still be the SHIPPED field, colour for colour. Every install + /// already sees it, and a palette table that quietly restyled the default would be a /// regression dressed as a feature. func testVioletIsTheUntouchedShippedField() { let violet = GamepadPalette.named("violet") XCTAssertEqual(GamepadPalette.all.first?.id, "violet") - XCTAssertTrue(violet.isIdentity) - XCTAssertEqual(violet.tint(violetPool), violetPool) + XCTAssertTrue(violet.stops.isEmpty, "the default is the explicit grid") + XCTAssertEqual(violet.meshColors, GamepadPalette.violetMesh) // An unknown name is a newer client's palette, not an error. XCTAssertEqual(GamepadPalette.named("chartreuse").id, "violet") XCTAssertEqual(GamepadPalette.named("").id, "violet") } - /// The ids and their order are the cross-client contract (the strip order, and the order - /// L1/R1 and A cycle through). + /// Ids, order and the light/dark split are the cross-client contract. func testTableMatchesTheOtherClients() { XCTAssertEqual( GamepadPalette.all.map(\.id), - ["violet", "tide", "forest", "ember", "rose", "graphite"]) - XCTAssertEqual( - GamepadPalette.all.map(\.name), - ["Violet", "Tide", "Forest", "Ember", "Rose", "Graphite"]) + ["violet", "nebula", "abyss", "ember", "moss", "graphite", + "holo", "sunset", "bloom", "dawn", "mint", "opal"]) + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + let firstLight = GamepadPalette.all.firstIndex { $0.light } + XCTAssertEqual(firstLight, 6) + XCTAssertTrue(GamepadPalette.all.dropFirst(6).allSatisfy(\.light)) } - /// A rotation moves the hue while roughly holding luminance, and the saturation scale - /// collapses toward grey — the same four checks the Rust test makes. - func testTintRotatesHueAndScalesSaturation() { - XCTAssertTrue(violetPool.z > violetPool.x && violetPool.z > violetPool.y, "blue-dominant") - - // +105° (Ember) turns the blue-dominant pool red-dominant… - let ember = GamepadPalette.named("ember").tint(violetPool) - XCTAssertGreaterThan(ember.x, ember.z, "\(ember) should be warm") - // …−130° (Forest) turns it green-dominant… - let forest = GamepadPalette.named("forest").tint(violetPool) - XCTAssertTrue(forest.y > forest.x && forest.y > forest.z, "\(forest)") - // …and −70° (Tide) lands on a cyan whose green and blue both beat red. - let tide = GamepadPalette.named("tide").tint(violetPool) - XCTAssertTrue(tide.y > tide.x && tide.z > tide.x, "\(tide)") - - // Graphite's saturation scale leaves the channels nearly equal… - let grey = GamepadPalette.named("graphite").tint(violetPool) - let spread = max(grey.x, grey.y, grey.z) - min(grey.x, grey.y, grey.z) - XCTAssertLessThan(spread, 0.08, "\(grey)") - // …at about the source's luminance (it desaturates, it doesn't dim). - let luma = 0.2126 * violetPool.x + 0.7152 * violetPool.y + 0.0722 * violetPool.z - XCTAssertEqual(grey.y, luma, accuracy: 0.05) - } - - /// Every palette stays in gamut on every colour the field is built from — an out-of-range - /// channel would clamp differently on each platform's rasteriser. - func testEveryPaletteStaysInGamut() { - let field: [SIMD3] = [ - SIMD3(0.075, 0.060, 0.160), SIMD3(0.34, 0.27, 0.72), SIMD3(0.30, 0.26, 0.74), - SIMD3(0.42, 0.20, 0.54), SIMD3(0.49, 0.39, 0.95), SIMD3(0.28, 0.31, 0.84), - SIMD3(0.16, 0.26, 0.64), SIMD3(0.45, 0.23, 0.60), SIMD3(0.53, 0.31, 0.75), - SIMD3(0.35, 0.35, 0.91), SIMD3(0.19, 0.28, 0.70), SIMD3(0.22, 0.18, 0.54), - SIMD3(0.24, 0.20, 0.58), - ] - for palette in GamepadPalette.all { - for c in field { - let t = palette.tint(c) - for v in [t.x, t.y, t.z] { - XCTAssertTrue((0...1).contains(v), "\(palette.id) \(c) → \(t)") + /// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was + /// exactly the complaint about the hue-rotation model this replaced. + func testEveryPaletteIsMultiTone() { + for p in GamepadPalette.all { + let hues = p.meshColors.compactMap(hue) + XCTAssertGreaterThanOrEqual(hues.count, 8, "\(p.id): too few coloured cells") + var spread = 0.0 + for a in hues { + for b in hues { + let d = abs(a - b).truncatingRemainder(dividingBy: 360) + spread = max(spread, min(d, 360 - d)) } } + // Graphite and Opal are deliberately near-neutral; the rest must travel. + let floor = (p.id == "graphite" || p.id == "opal") ? 20.0 : 45.0 + XCTAssertGreaterThanOrEqual(spread, floor, "\(p.id) spans only \(spread)° of hue") + } + } + + /// Every colour stays in gamut, and a pale palette really is pale — its ink flips, so a + /// mislabelled one would put dark text on a dark field. + func testPalettesAreInGamutAndHonestAboutLightness() { + for p in GamepadPalette.all { + for c in p.meshColors + p.blobColors { + for v in [c.x, c.y, c.z] { + XCTAssertTrue((0...1).contains(v), "\(p.id) \(c)") + } + } + let mean = p.meshColors.map(luma).reduce(0, +) / Double(p.meshColors.count) + if p.light { + XCTAssertGreaterThan(mean, 0.5, "\(p.id) is flagged light") + XCTAssertGreaterThan(luma(p.ground), 0.6, "\(p.id)'s ground is dark") + XCTAssertLessThan(luma(p.accent), 0.45, "\(p.id)'s accent is too pale") + } else { + XCTAssertLessThan(mean, 0.45, "\(p.id) is flagged dark") + XCTAssertLessThan(luma(p.ground), 0.2, "\(p.id)'s ground is light") + XCTAssertGreaterThan(luma(p.accent), 0.25, "\(p.id)'s accent is too dark") + } } } + + /// The ramp is the shared sampling rule the Rust and Kotlin ports reproduce. + func testRampInterpolatesBetweenStops() { + let stops = [SIMD3(0.0, 0.0, 0.0), SIMD3(1.0, 0.0, 0.0), SIMD3(1.0, 1.0, 1.0)] + XCTAssertEqual(GamepadPalette.ramp(stops, 0), SIMD3(0.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 1), SIMD3(1.0, 1.0, 1.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 0.5), SIMD3(1.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 0.25).x, 0.5, accuracy: 1e-9) + // Out of range clamps rather than trapping. + XCTAssertEqual(GamepadPalette.ramp(stops, -3), SIMD3(0.0, 0.0, 0.0)) + XCTAssertEqual(GamepadPalette.ramp(stops, 9), SIMD3(1.0, 1.0, 1.0)) + XCTAssertEqual(GamepadPalette.ramp([], 0.5), SIMD3(0.0, 0.0, 0.0)) + } } diff --git a/crates/pf-console-ui/src/glyphs.rs b/crates/pf-console-ui/src/glyphs.rs index 72422b44..fa224355 100644 --- a/crates/pf-console-ui/src/glyphs.rs +++ b/crates/pf-console-ui/src/glyphs.rs @@ -5,9 +5,9 @@ //! no pad at all the legend swaps to keyboard keycaps — the console stays fully //! drivable either way. -use crate::theme::{white, Fonts, W}; +use crate::theme::{fg, Fonts, W}; use punktfunk_core::config::GamepadPref; -use skia_safe::{Canvas, Color4f, Paint, Path, Point, RRect, Rect}; +use skia_safe::{Canvas, Paint, Path, Point, RRect, Rect}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum GlyphStyle { @@ -94,13 +94,13 @@ pub(crate) fn hint_bar( let rect = Rect::from_xywh((x) as f32, (bottom - h) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.30), None), + &Paint::new(crate::theme::shade(0.30), None), ); canvas.draw_rrect( RRect::new_rect_xy(rect, (h / 2.0) as f32, (h / 2.0) as f32), - &Paint::new(white(0.06), None), + &Paint::new(fg(0.06), None), ); - let mut sp = Paint::new(white(0.12), None); + let mut sp = Paint::new(fg(0.12), None); sp.set_style(skia_safe::PaintStyle::Stroke); sp.set_stroke_width(1.0); sp.set_anti_alias(true); @@ -122,7 +122,7 @@ pub(crate) fn hint_bar( cy + LABEL_SIZE * k * 0.36, W::SemiBold, LABEL_SIZE * k, - white(0.85), + fg(0.85), ); pen += lw + gap_hint; } @@ -199,8 +199,8 @@ fn draw_glyph( Resolved::Badge(face) => { let r = BADGE_D * k / 2.0; let center = Point::new((x + r) as f32, cy as f32); - canvas.draw_circle(center, r as f32, &Paint::new(white(0.10), None)); - let mut ring = Paint::new(white(0.32), None); + canvas.draw_circle(center, r as f32, &Paint::new(fg(0.10), None)); + let mut ring = Paint::new(fg(0.32), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width((1.2 * k) as f32); ring.set_anti_alias(true); @@ -223,7 +223,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); } } @@ -235,7 +235,7 @@ fn draw_glyph( let rect = Rect::from_xywh(pen as f32, (cy - h / 2.0) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (4.0 * k) as f32, (4.0 * k) as f32), - &Paint::new(white(0.10), None), + &Paint::new(fg(0.10), None), ); let size = 10.0 * k; let tw = fonts.measure(label, W::SemiBold, size) as f64; @@ -246,7 +246,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); pen += w + 3.0 * k; } @@ -257,7 +257,7 @@ fn draw_glyph( let (cx, cyf) = ((x + r) as f32, cy as f32); let (tw, th) = ((4.5 * k) as f32, (5.5 * k) as f32); let gap = (2.6 * k) as f32; - let paint = Paint::new(white(0.85), None); + let paint = Paint::new(fg(0.85), None); let mut left = Path::new(); left.move_to((cx - gap, cyf - th)); left.line_to((cx - gap - tw, cyf)); @@ -277,9 +277,9 @@ fn draw_glyph( let rect = Rect::from_xywh(x as f32, (cy - h / 2.0) as f32, w as f32, h as f32); canvas.draw_rrect( RRect::new_rect_xy(rect, (5.0 * k) as f32, (5.0 * k) as f32), - &Paint::new(white(0.10), None), + &Paint::new(fg(0.10), None), ); - let mut ring = Paint::new(white(0.28), None); + let mut ring = Paint::new(fg(0.28), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -296,7 +296,7 @@ fn draw_glyph( cy + size * 0.36, W::SemiBold, size, - white(0.92), + fg(0.92), ); } } @@ -305,7 +305,7 @@ fn draw_glyph( /// The PlayStation face shapes, stroked inside the badge: Confirm=✕, Back=○, X-position /// =□, Y-position=△ (the DualSense's physical layout). fn draw_ps_shape(canvas: &Canvas, face: Face, center: Point, r: f32, stroke: f32) { - let mut p = Paint::new(white(0.92), None); + let mut p = Paint::new(fg(0.92), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width(stroke); p.set_stroke_cap(skia_safe::PaintCap::Round); diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index 0e7f6cf1..53158e13 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -205,62 +205,158 @@ pub const MESH_INTERIOR: [(f64, f64, f64, f64, f64, f64); 4] = [ // --- Background palettes ------------------------------------------------------------------- -/// One background colour family for the console's living backdrop. A palette is NOT a second -/// hand-tuned 16-colour grid: it is a hue rotation + saturation scale applied to -/// [`MESH_COLORS`], so every palette inherits the field's structure (dark corners, bright -/// interior pools, warm-left/cool-right) and the brand default is exactly the shipped look — -/// `violet` is the identity transform. The Apple and Android clients carry the same table and -/// the same [`tint`] math, so a palette reads as the same colour family on every client. +/// One background colour family for the gamepad UI's living backdrop. +/// +/// A palette is a short ordered ramp of [`Palette::stops`] — several DISTINCT hues, not one hue +/// at several brightnesses. The 4×4 mesh samples that ramp diagonally with a per-cell offset +/// ([`CELL_RAMP`]), so neighbouring cells land on different parts of it and the colours pool and +/// swirl the way a real gradient poster does; the interior points' existing domain warp then +/// drifts those pools around. An earlier version rotated ONE field's hue per palette, which is +/// why every non-default palette read as flat and monotone. +/// +/// A palette also owns the UI it sits under: [`Palette::accent`] is the focus wash / selected +/// pill / switch colour, and [`Palette::light`] flips the ink (see [`crate::theme::Ink`]) so a +/// pale field gets dark text instead of white. The Apple and Android clients carry the same +/// table under the same ids, so one `ui_palette` value is one look everywhere. pub struct Palette { /// The stored `ui_palette` value (see `trust::Settings::ui_palette`). pub id: &'static str, /// What the settings row shows. pub name: &'static str, - /// Hue rotation about the grey axis, degrees — positive runs red → green → blue. - pub hue_deg: f64, - /// Saturation scale about luminance; `1.0` keeps the source saturation. - pub sat: f64, + /// The colour ramp, dark end first. `None` = use [`MESH_COLORS`] verbatim (the brand + /// default, kept bit-identical to what every install already sees). + pub stops: Option<&'static [(f64, f64, f64)]>, + /// The field's ground — what the corners settle onto and what the calm mix lifts toward. + pub ground: (f64, f64, f64), + /// The UI accent: focus wash, selected tab pill, switch track, caret. + pub accent: (f64, f64, f64), + /// A pale field: the UI flips to dark ink and the legibility scrims go white. + pub light: bool, } -/// The six shipped palettes, in cycling order (the brand violet first, then cool → warm, -/// then the neutral). Adding one here adds it to every console settings screen; the Apple -/// and Android tables must gain the same entry to keep the `ui_palette` key portable. -pub const PALETTES: [Palette; 6] = [ +/// Where each of the 16 mesh cells samples the ramp. The base is the diagonal +/// `0.5·(x + y)` — top-left is the ramp's dark end, bottom-right its bright one, like both +/// reference gradients — and the per-cell nudges break the banding that a pure diagonal would +/// give, so hues pool instead of striping. +#[rustfmt::skip] +const CELL_RAMP: [f64; 16] = [ + 0.10, -0.06, 0.04, -0.12, + -0.08, 0.14, -0.10, 0.06, + 0.06, -0.12, 0.16, -0.04, + -0.10, 0.08, -0.06, 0.12, +]; + +/// The twelve shipped palettes: the brand default, five more dark fields, then six pale ones. +/// Cycling order runs dark → light, so stepping the row walks the whole range in one direction. +/// Adding one here adds it to every console settings screen; the Apple and Android tables must +/// gain the same entry to keep the `ui_palette` key portable. +#[rustfmt::skip] +pub const PALETTES: [Palette; 12] = [ + // --- dark fields (white ink) --- Palette { - id: "violet", - name: "Violet", - hue_deg: 0.0, - sat: 1.0, + id: "violet", name: "Violet", stops: None, + ground: (0.075, 0.060, 0.160), accent: (0.525, 0.471, 0.961), light: false, }, Palette { - id: "tide", - name: "Tide", - hue_deg: -70.0, - sat: 1.0, + // Deep indigo climbing through violet into a hot magenta. + id: "nebula", name: "Nebula", + stops: Some(&[ + (0.07, 0.05, 0.20), (0.26, 0.14, 0.54), (0.52, 0.20, 0.72), + (0.82, 0.26, 0.62), (0.98, 0.46, 0.68), + ]), + ground: (0.055, 0.040, 0.135), accent: (0.95, 0.42, 0.72), light: false, }, Palette { - id: "forest", - name: "Forest", - hue_deg: -130.0, - sat: 0.9, + // Ink-blue water: teal → cerulean → a violet undertow. + id: "abyss", name: "Abyss", + stops: Some(&[ + (0.02, 0.10, 0.17), (0.04, 0.28, 0.42), (0.07, 0.46, 0.63), + (0.16, 0.38, 0.78), (0.26, 0.22, 0.58), + ]), + ground: (0.018, 0.070, 0.130), accent: (0.26, 0.76, 0.92), light: false, }, Palette { - id: "ember", - name: "Ember", - hue_deg: 105.0, - sat: 1.0, + // Banked coals: plum embers → crimson → burnt orange → gold. + id: "ember", name: "Ember", + stops: Some(&[ + (0.16, 0.03, 0.10), (0.45, 0.06, 0.12), (0.72, 0.18, 0.06), + (0.90, 0.42, 0.08), (0.95, 0.68, 0.18), + ]), + ground: (0.090, 0.035, 0.040), accent: (0.98, 0.62, 0.26), light: false, }, Palette { - id: "rose", - name: "Rose", - hue_deg: 60.0, - sat: 0.95, + // Forest floor into moss and a lime break. + id: "moss", name: "Moss", + stops: Some(&[ + (0.03, 0.11, 0.09), (0.06, 0.27, 0.20), (0.09, 0.45, 0.31), + (0.28, 0.61, 0.28), (0.58, 0.77, 0.31), + ]), + ground: (0.025, 0.085, 0.070), accent: (0.48, 0.86, 0.46), light: false, }, Palette { - id: "graphite", - name: "Graphite", - hue_deg: 0.0, - sat: 0.12, + // Neutral, but never flat: barely-there saturation that still travels from a cool + // charcoal to a warm stone, so even the restrained option has somewhere to go. + id: "graphite", name: "Graphite", + stops: Some(&[ + (0.06, 0.07, 0.11), (0.15, 0.18, 0.25), (0.30, 0.31, 0.35), + (0.45, 0.42, 0.38), (0.60, 0.56, 0.49), + ]), + ground: (0.055, 0.055, 0.070), accent: (0.78, 0.80, 0.86), light: false, + }, + // --- pale fields (dark ink) --- + Palette { + // The holographic foil: rose → lilac → periwinkle → aqua, with a white bloom. + id: "holo", name: "Holo", + stops: Some(&[ + (0.99, 0.72, 0.90), (0.80, 0.60, 0.98), (0.58, 0.62, 0.99), + (0.55, 0.86, 0.98), (0.94, 0.98, 1.00), + ]), + ground: (0.96, 0.92, 0.99), accent: (0.42, 0.28, 0.86), light: true, + }, + Palette { + // The poster sunset: periwinkle → magenta → scarlet → tangerine → gold. + id: "sunset", name: "Sunset", + stops: Some(&[ + (0.55, 0.45, 0.92), (0.86, 0.31, 0.66), (0.97, 0.26, 0.34), + (0.99, 0.51, 0.18), (1.00, 0.80, 0.22), + ]), + ground: (0.98, 0.74, 0.34), accent: (0.64, 0.13, 0.44), light: true, + }, + Palette { + // Peach into blush and lilac — the softest of the set. + id: "bloom", name: "Bloom", + stops: Some(&[ + (1.00, 0.86, 0.72), (0.99, 0.73, 0.79), (0.95, 0.65, 0.89), + (0.82, 0.68, 0.96), (0.73, 0.79, 0.99), + ]), + ground: (0.99, 0.90, 0.89), accent: (0.72, 0.24, 0.55), light: true, + }, + Palette { + // First light: pale gold → coral → lilac. + id: "dawn", name: "Dawn", + stops: Some(&[ + (1.00, 0.92, 0.70), (1.00, 0.80, 0.62), (0.99, 0.66, 0.62), + (0.90, 0.62, 0.78), (0.77, 0.69, 0.95), + ]), + ground: (1.00, 0.93, 0.82), accent: (0.82, 0.33, 0.28), light: true, + }, + Palette { + // Sea glass: mint → aqua → a pale sky. + id: "mint", name: "Mint", + stops: Some(&[ + (0.82, 0.98, 0.90), (0.62, 0.94, 0.88), (0.55, 0.88, 0.95), + (0.63, 0.82, 0.99), (0.82, 0.87, 1.00), + ]), + ground: (0.90, 0.98, 0.96), accent: (0.04, 0.42, 0.40), light: true, + }, + Palette { + // Near-white, but iridescent rather than flat — rose, sky, mint and cream in turn. + id: "opal", name: "Opal", + stops: Some(&[ + (0.98, 0.92, 0.96), (0.87, 0.93, 0.99), (0.91, 0.99, 0.95), + (0.99, 0.96, 0.88), (0.94, 0.90, 0.99), + ]), + ground: (0.97, 0.96, 0.99), accent: (0.36, 0.32, 0.44), light: true, }, ]; @@ -270,35 +366,58 @@ pub fn palette(id: &str) -> &'static Palette { PALETTES.iter().find(|p| p.id == id).unwrap_or(&PALETTES[0]) } -/// Rotate `(r, g, b)` about the grey axis by `deg` (Rodrigues — the same rotation the shader -/// already uses for the ±8° warm/cool sway) and scale its saturation about luminance. Clamped, -/// because a large rotation can push a channel out of gamut. Ported verbatim to Swift and -/// Kotlin: keep the three copies in step or the palettes drift apart between clients. -pub fn tint(c: (f64, f64, f64), deg: f64, sat: f64) -> (f64, f64, f64) { - let (r, g, b) = c; - let a = deg.to_radians(); - let (sn, cs) = a.sin_cos(); - let inv_sqrt3 = 1.0 / 3.0f64.sqrt(); - let grey = (r + g + b) / 3.0 * (1.0 - cs); - // The `sn` term is `cross(k, c)` with k = (1,1,1)/√3 — the SAME orientation the shader's - // own `hue()` uses, so a palette rotation and the ±8° sway agree on which way is warmer. - let rot = ( - r * cs + (b - g) * inv_sqrt3 * sn + grey, - g * cs + (r - b) * inv_sqrt3 * sn + grey, - b * cs + (g - r) * inv_sqrt3 * sn + grey, - ); - let luma = 0.2126 * rot.0 + 0.7152 * rot.1 + 0.0722 * rot.2; - let mix = |v: f64| (luma + (v - luma) * sat).clamp(0.0, 1.0); - (mix(rot.0), mix(rot.1), mix(rot.2)) +/// Sample an ordered colour ramp at `t` ∈ [0, 1] (linear between neighbouring stops). Ported +/// verbatim to Swift and Kotlin — keep the three copies in step or a palette drifts between +/// clients. +pub fn ramp(stops: &[(f64, f64, f64)], t: f64) -> (f64, f64, f64) { + match stops.len() { + 0 => (0.0, 0.0, 0.0), + 1 => stops[0], + n => { + let x = t.clamp(0.0, 1.0) * (n - 1) as f64; + let i = (x.floor() as usize).min(n - 2); + let f = x - i as f64; + let (a, b) = (stops[i], stops[i + 1]); + ( + a.0 + (b.0 - a.0) * f, + a.1 + (b.1 - a.1) * f, + a.2 + (b.2 - a.2) * f, + ) + } + } } impl Palette { - /// [`MESH_COLORS`] under this palette's transform. + /// The 16 mesh colours for this palette: the ramp sampled per cell (see [`CELL_RAMP`]), or + /// [`MESH_COLORS`] verbatim for the brand default. pub fn mesh_colors(&self) -> [(f64, f64, f64); 16] { - core::array::from_fn(|i| tint(MESH_COLORS[i], self.hue_deg, self.sat)) + let Some(stops) = self.stops else { + return MESH_COLORS; + }; + core::array::from_fn(|i| { + let (x, y) = ((i % 4) as f64 / 3.0, (i / 4) as f64 / 3.0); + ramp(stops, 0.5 * (x + y) + CELL_RAMP[i]) + }) + } + + /// Four drifting blob colours, for the clients that approximate the mesh with a blob field + /// (Android). Spread across the ramp so the field still shows several hues at once. + pub fn blob_colors(&self) -> [(f64, f64, f64); 4] { + let stops = self.stops.unwrap_or(&VIOLET_BLOBS); + core::array::from_fn(|i| ramp(stops, 0.15 + 0.25 * i as f64)) } } +/// The brand default's blob ramp — the four colours the pre-palette Android/legacy-Apple field +/// used, kept so `violet` is unchanged there too. +const VIOLET_BLOBS: [(f64, f64, f64); 5] = [ + (0.53, 0.47, 0.96), + (0.24, 0.20, 0.72), + (0.62, 0.30, 0.80), + (0.22, 0.38, 0.86), + (0.53, 0.47, 0.96), +]; + /// The mesh gradient as SkSL, palette + motion baked into the source (resolution, time and /// the calm mix are uniforms). A smooth bicubic blend of the 16 colours — a separable /// cubic-Bézier basis in x then y, C∞ and edge-to-edge, the fragment-shader analogue of @@ -337,6 +456,11 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String { // rgb = the palette's corner colour scaled for the calm lift; a is unused (float4\n\ // so the uniform block stays 16-byte aligned under any packing rule).\n\ uniform float4 u_lift;\n\ + // rgb = what the vignette and scrims tend toward (black under a dark palette, white\n\ + // under a pale one — darkening a pastel field would strand the dark text on it), and\n\ + // a = how hard. A pale field needs far less: mixing toward white at the dark field's\n\ + // strength bleaches the chroma straight out of the gradient.\n\ + uniform float4 u_scrim;\n\ \n\ // Cubic-Bézier basis over four control values — the smooth 4-point blend per axis.\n\ float bz(float t, float a, float b, float c, float d) {{\n\ @@ -380,15 +504,16 @@ pub fn mesh_sksl(colors: &[(f64, f64, f64); 16]) -> String { \x20 // Halved under calm: a launcher's cards sit in the pooled centre, but a form\n\ \x20 // screen's rows run out toward the edges, where crushing to black just eats them.\n\ \x20 float2 e = (xy / u_res - 0.5) * 2.0;\n\ - \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0) * mix(0.42, 0.21, calm);\n\ - \x20 col *= 1.0 - vig;\n\ + \x20 float vig = clamp((length(e) - 0.25) / 0.90, 0.0, 1.0)\n\ + \x20 * mix(0.42, 0.21, calm) * u_scrim.a;\n\ + \x20 col = mix(col, u_scrim.rgb, vig);\n\ \n\ \x20 // Vertical legibility scrim: black 0.38/0.06/0.08/0.40 at 0/0.32/0.68/1.\n\ \x20 float v = xy.y / u_res.y;\n\ \x20 float s = v < 0.32 ? mix(0.38, 0.06, v / 0.32)\n\ \x20 : v < 0.68 ? mix(0.06, 0.08, (v - 0.32) / 0.36)\n\ \x20 : mix(0.08, 0.40, (v - 0.68) / 0.32);\n\ - \x20 col *= 1.0 - s;\n\ + \x20 col = mix(col, u_scrim.rgb, s * u_scrim.a);\n\ \n\ \x20 return half4(half3(col), 1.0);\n\ }}\n", @@ -661,49 +786,132 @@ mod tests { assert_eq!(src.matches('{').count(), src.matches('}').count()); } - /// The brand default must be the IDENTITY transform — the shipped violet backdrop is - /// what every existing install already sees, and a palette table that quietly restyled - /// it would be a regression dressed as a feature. + /// The brand default must still be the SHIPPED field, colour for colour. Every install + /// already sees it, and a palette table that quietly restyled the default would be a + /// regression dressed as a feature. #[test] fn violet_is_the_untouched_shipped_field() { assert_eq!(PALETTES[0].id, "violet"); - for (a, b) in palette("violet").mesh_colors().iter().zip(&MESH_COLORS) { - assert!((a.0 - b.0).abs() < 1e-9, "{a:?} vs {b:?}"); - assert!((a.1 - b.1).abs() < 1e-9, "{a:?} vs {b:?}"); - assert!((a.2 - b.2).abs() < 1e-9, "{a:?} vs {b:?}"); - } + assert!( + PALETTES[0].stops.is_none(), + "the default is the explicit grid" + ); + assert_eq!(palette("violet").mesh_colors(), MESH_COLORS); // An unknown name is a newer client's palette, not an error. assert_eq!(palette("chartreuse").id, "violet"); assert_eq!(palette("").id, "violet"); } - /// The transform's two knobs do what they claim: a rotation moves the hue while holding - /// roughly the same luminance, and the saturation scale collapses toward grey. These are - /// the numbers the Swift and Kotlin ports have to reproduce. + /// Hue angle in degrees, or `None` for something too grey to have one. + fn hue(c: (f64, f64, f64)) -> Option { + let (r, g, b) = c; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let d = max - min; + if d < 0.04 { + return None; + } + let h = if max == r { + 60.0 * (((g - b) / d) % 6.0) + } else if max == g { + 60.0 * ((b - r) / d + 2.0) + } else { + 60.0 * ((r - g) / d + 4.0) + }; + Some((h + 360.0) % 360.0) + } + + /// A palette must read as SEVERAL hues, not one hue at several brightnesses — that was + /// exactly the complaint about the hue-rotation model this replaced. Measured as the + /// widest gap between any two of the 16 mesh colours' hue angles. #[test] - fn tint_rotates_hue_and_scales_saturation() { - let violet = MESH_COLORS[5]; // the brightest interior pool: blue dominates - assert!(violet.2 > violet.0 && violet.2 > violet.1); - // +105° (Ember) turns the blue-dominant pool red-dominant. - let ember = tint(violet, 105.0, 1.0); - assert!(ember.0 > ember.2, "{ember:?} should be warm"); - // −130° (Forest) turns it green-dominant. - let forest = tint(violet, -130.0, 1.0); - assert!(forest.1 > forest.0 && forest.1 > forest.2, "{forest:?}"); - // Graphite's saturation scale leaves the three channels nearly equal… - let grey = tint(violet, 0.0, 0.12); - let spread = grey.0.max(grey.1).max(grey.2) - grey.0.min(grey.1).min(grey.2); - assert!(spread < 0.08, "{grey:?} spread {spread}"); - // …at about the source's luminance (it desaturates, it doesn't dim). - let luma = 0.2126 * violet.0 + 0.7152 * violet.1 + 0.0722 * violet.2; - assert!((grey.1 - luma).abs() < 0.05, "{grey:?} vs luma {luma}"); - // Every palette stays in gamut on every mesh colour. + fn every_palette_is_multi_tone() { for p in &PALETTES { - for c in p.mesh_colors() { + let hues: Vec = p.mesh_colors().iter().filter_map(|c| hue(*c)).collect(); + assert!(hues.len() >= 8, "{}: too few coloured cells", p.id); + let spread = hues + .iter() + .flat_map(|a| { + hues.iter().map(move |b| { + let d = (a - b).abs() % 360.0; + d.min(360.0 - d) + }) + }) + .fold(0.0f64, f64::max); + // Graphite and Opal are deliberately near-neutral; everything else must carry a + // real hue journey. + let floor = if matches!(p.id, "graphite" | "opal") { + 20.0 + } else { + 45.0 + }; + assert!(spread >= floor, "{} spans only {spread:.0}° of hue", p.id); + } + } + + /// Ids, order and the light/dark split are the cross-client contract — the Apple and + /// Android tables must match this exactly. + #[test] + fn table_matches_the_other_clients() { + let ids: Vec<&str> = PALETTES.iter().map(|p| p.id).collect(); + assert_eq!( + ids, + [ + "violet", "nebula", "abyss", "ember", "moss", "graphite", "holo", "sunset", + "bloom", "dawn", "mint", "opal", + ] + ); + // Dark fields lead, pale ones follow, so stepping the row walks one direction. + let first_light = PALETTES + .iter() + .position(|p| p.light) + .expect("some are light"); + assert!(PALETTES[first_light..].iter().all(|p| p.light)); + assert_eq!(first_light, 6); + } + + /// Every colour a palette produces stays in gamut, and a pale palette really is pale — + /// its ink flips, so a mislabelled one would put dark text on a dark field. + #[test] + fn palettes_are_in_gamut_and_honest_about_lightness() { + let luma = |c: (f64, f64, f64)| 0.2126 * c.0 + 0.7152 * c.1 + 0.0722 * c.2; + for p in &PALETTES { + for c in p.mesh_colors().iter().chain(p.blob_colors().iter()) { for v in [c.0, c.1, c.2] { assert!((0.0..=1.0).contains(&v), "{} {c:?}", p.id); } } + let mean = p.mesh_colors().iter().map(|c| luma(*c)).sum::() / 16.0; + if p.light { + assert!(mean > 0.5, "{} is flagged light but means {mean:.2}", p.id); + assert!(luma(p.ground) > 0.6, "{}'s ground is dark", p.id); + } else { + assert!(mean < 0.45, "{} is flagged dark but means {mean:.2}", p.id); + assert!(luma(p.ground) < 0.2, "{}'s ground is light", p.id); + } + // The accent tints glass of the OPPOSITE polarity to the field, so it has to be + // legible there: dark accents on white frost, bright ones on dark glass. + let a = luma(p.accent); + if p.light { + assert!(a < 0.45, "{}'s accent is too pale for white glass", p.id); + } else { + assert!(a > 0.25, "{}'s accent is too dark for dark glass", p.id); + } } } + + /// The ramp is the shared sampling rule the Swift and Kotlin ports reproduce. + #[test] + fn ramp_interpolates_between_stops() { + let stops = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 1.0)]; + assert_eq!(ramp(&stops, 0.0), (0.0, 0.0, 0.0)); + assert_eq!(ramp(&stops, 1.0), (1.0, 1.0, 1.0)); + assert_eq!(ramp(&stops, 0.5), (1.0, 0.0, 0.0)); + let q = ramp(&stops, 0.25); + assert!((q.0 - 0.5).abs() < 1e-9 && q.1 == 0.0); + // Out of range clamps rather than panicking. + assert_eq!(ramp(&stops, -3.0), (0.0, 0.0, 0.0)); + assert_eq!(ramp(&stops, 9.0), (1.0, 1.0, 1.0)); + assert_eq!(ramp(&[], 0.5), (0.0, 0.0, 0.0)); + } } diff --git a/crates/pf-console-ui/src/screens/add_host.rs b/crates/pf-console-ui/src/screens/add_host.rs index c60347e5..71f815a2 100644 --- a/crates/pf-console-ui/src/screens/add_host.rs +++ b/crates/pf-console-ui/src/screens/add_host.rs @@ -7,7 +7,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::ConsoleCmd; use crate::screens::{Ctx, Outbox}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -214,7 +214,7 @@ impl AddHostScreen { "Hosts on this network appear automatically — add one by address for everything else.", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + 2.0 * k, f64::from(rect.width()) * 0.72, diff --git a/crates/pf-console-ui/src/screens/home.rs b/crates/pf-console-ui/src/screens/home.rs index 482e6213..7165af33 100644 --- a/crates/pf-console-ui/src/screens/home.rs +++ b/crates/pf-console-ui/src/screens/home.rs @@ -10,7 +10,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::library::{step_cursor, StepResult, BUMP_C, BUMP_K, BUMP_PX, SPRING_C, SPRING_K}; use crate::model::{ConsoleCmd, HostRow}; use crate::screens::{ConnectIntent, Ctx, Outbox, Screen}; -use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, ONLINE_GREEN, W, WHITE}; +use crate::theme::{accent, fg, Fonts, PanelStroke, ONLINE_GREEN, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Color4f, MaskFilter, Paint, Path, Point, RRect, Rect}; @@ -251,7 +251,7 @@ impl HomeScreen { "Hosts on this network appear automatically — add one by address for everything else.", W::Regular, 13.0 * k, - DIM, + fg(0.55), f64::from(rect.left) + w / 2.0, cy + tile_h / 2.0 + 24.0 * k, w * 0.7, @@ -265,7 +265,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 canvas, rect, TILE_CORNER as f32, - h.saved.then(|| brand(0.20)), + h.saved.then(|| accent(0.20)), if h.saved { PanelStroke::Gradient } else { @@ -327,7 +327,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); let x = l + addr_w + 8.0 * k; @@ -352,7 +352,7 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); } @@ -364,22 +364,22 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6 sub_base - 22.0 * k, W::Bold, 23.0 * k, - WHITE, + fg(1.0), max_w, ); } -/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed +/// A profile's `#RRGGBB` accent as a color, defaulting to the PALETTE's accent. Parsed /// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring. -fn accent_color(accent: Option<&str>) -> skia_safe::Color4f { - let Some(hex) = accent +fn accent_color(hex: Option<&str>) -> skia_safe::Color4f { + let Some(hex) = hex .and_then(|a| a.strip_prefix('#')) .filter(|h| h.len() == 6) else { - return BRAND; + return accent(1.0); }; let Ok(v) = u32::from_str_radix(hex, 16) else { - return BRAND; + return accent(1.0); }; skia_safe::Color4f::new( ((v >> 16) & 0xff) as f32 / 255.0, @@ -404,9 +404,9 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { let badge = Rect::from_xywh(l as f32, t as f32, (52.0 * k) as f32, (52.0 * k) as f32); canvas.draw_rrect( RRect::new_rect_xy(badge, (15.0 * k) as f32, (15.0 * k) as f32), - &Paint::new(brand(0.16), None), + &Paint::new(accent(0.16), None), ); - let mut ring = Paint::new(brand(0.5), None); + let mut ring = Paint::new(accent(0.5), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -415,7 +415,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { &ring, ); let (bcx, bcy) = (l + 26.0 * k, t + 26.0 * k); - let mut p = Paint::new(BRAND, None); + let mut p = Paint::new(accent(1.0), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width((3.0 * k) as f32); p.set_stroke_cap(skia_safe::PaintCap::Round); @@ -441,7 +441,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { sub_base, W::Regular, 13.0 * k, - white(0.55), + fg(0.55), max_w, ); fonts.draw_clipped( @@ -451,7 +451,7 @@ fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) { sub_base - 22.0 * k, W::Bold, 23.0 * k, - WHITE, + fg(1.0), max_w, ); } @@ -467,8 +467,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 Point::new(badge.left, badge.bottom), ), skia_safe::gradient_shader::GradientShaderColors::Colors(&[ - BRAND.to_color(), - brand(0.68).to_color(), + accent(1.0).to_color(), + accent(0.68).to_color(), ]), None, skia_safe::TileMode::Clamp, @@ -477,8 +477,8 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 )); canvas.draw_rrect(rr, &p); } else { - canvas.draw_rrect(rr, &Paint::new(brand(0.16), None)); - let mut ring = Paint::new(brand(0.5), None); + canvas.draw_rrect(rr, &Paint::new(accent(0.16), None)); + let mut ring = Paint::new(accent(0.5), None); ring.set_style(skia_safe::PaintStyle::Stroke); ring.set_stroke_width(1.0); ring.set_anti_alias(true); @@ -499,13 +499,13 @@ fn draw_monogram(canvas: &Canvas, fonts: &Fonts, name: &str, filled: bool, x: f6 y + 26.0 * k + size * 0.36, W::Bold, size, - if filled { WHITE } else { BRAND }, + if filled { fg(1.0) } else { accent(1.0) }, ); } /// A small padlock: filled body + stroked shackle (the paired-identity mark). fn draw_lock(canvas: &Canvas, x: f64, y: f64, k: f64) { - let ink = white(0.5); + let ink = fg(0.5); let body_w = 11.0 * k; let body_h = 8.0 * k; let body_top = y + 5.0 * k; diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index 558abd7b..3f62a2a3 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -12,7 +12,7 @@ use crate::library::{ }; use crate::model::{ConsoleCmd, HostRow}; use crate::screens::{ConnectIntent, Ctx, Outbox}; -use crate::theme::{brand, white, Fonts, DIM, W, WHITE}; +use crate::theme::{accent, fg, Fonts, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44}; use std::collections::HashMap; @@ -237,7 +237,7 @@ impl LibraryScreen { "Loading library…", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 16.0 * k, w * 0.8, @@ -249,7 +249,7 @@ impl LibraryScreen { "No games found", W::Bold, 22.0 * k, - WHITE, + fg(1.0), cx, cy_all - 20.0 * k, w * 0.8, @@ -259,7 +259,7 @@ impl LibraryScreen { "Install Steam titles or add custom entries in the host's web console.", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 12.0 * k, w * 0.8, @@ -271,7 +271,7 @@ impl LibraryScreen { &title, W::Bold, 22.0 * k, - WHITE, + fg(1.0), cx, cy_all - 32.0 * k, w * 0.8, @@ -281,7 +281,7 @@ impl LibraryScreen { &body, W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, cy_all + 4.0 * k, (600.0 * k).min(w * 0.85), @@ -315,7 +315,7 @@ impl LibraryScreen { heading, W::SemiBold, 12.0 * k, - white(0.5), + fg(0.5), f64::from(rect.left) + w / 2.0, cy - card_h / 2.0 - 22.0 * k, w * 0.5, @@ -382,9 +382,9 @@ impl LibraryScreen { }; canvas.draw_rect(crect, &Paint::new(face, None)); let (glyph, size, ink) = if game.launcher { - (store_label(&game.store).to_string(), 22.0 * k, white(0.85)) + (store_label(&game.store).to_string(), 22.0 * k, fg(0.85)) } else { - (initials(&game.title), 38.0 * k, white(0.45)) + (initials(&game.title), 38.0 * k, fg(0.45)) }; let font = fonts.font(W::Bold, size); let tw = font.measure_str(&glyph, None).0; @@ -409,9 +409,9 @@ impl LibraryScreen { // Brand-filled for a launcher, smoked glass for a game — the one cue that // survives being three cards deep in the recede. let pill = if game.launcher { - brand(0.85) + accent(0.85) } else { - Color4f::new(0.0, 0.0, 0.0, 0.55) + crate::theme::shade(0.55) }; canvas.draw_rrect( RRect::new_rect_xy( @@ -428,7 +428,7 @@ impl LibraryScreen { py + 14.0 * k, W::SemiBold, size, - WHITE, + fg(1.0), ); } // The brightness recede: an opaque-black veil, never whole-card alpha. @@ -452,7 +452,7 @@ impl LibraryScreen { &g.title, W::Bold, 27.0 * k, - WHITE, + fg(1.0), cx, f64::from(rect.bottom) - 64.0 * k, w * 0.8, @@ -467,7 +467,7 @@ impl LibraryScreen { &sub, W::Regular, 12.0 * k, - white(0.5), + fg(0.5), cx, f64::from(rect.bottom) - 30.0 * k, w * 0.5, diff --git a/crates/pf-console-ui/src/screens/pair.rs b/crates/pf-console-ui/src/screens/pair.rs index 20a87703..45244d69 100644 --- a/crates/pf-console-ui/src/screens/pair.rs +++ b/crates/pf-console-ui/src/screens/pair.rs @@ -7,7 +7,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::{ConsoleCmd, HostRow, PairPhase}; use crate::screens::{ConnectIntent, Ctx, Outbox}; -use crate::theme::{Fonts, DIM, ERROR, W}; +use crate::theme::{fg, Fonts, ERROR, W}; use crate::widgets::{permits, Charset, KeyMsg, Keyboard, ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -297,7 +297,7 @@ impl PairScreen { intro, W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + 2.0 * k, f64::from(rect.width()) * 0.72, @@ -336,7 +336,7 @@ impl PairScreen { "Pairing… confirm the PIN on the host", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx + 10.0 * k, status_y, f64::from(rect.width()) * 0.6, diff --git a/crates/pf-console-ui/src/screens/pin_hosts.rs b/crates/pf-console-ui/src/screens/pin_hosts.rs index ad7dab4e..924b643b 100644 --- a/crates/pf-console-ui/src/screens/pin_hosts.rs +++ b/crates/pf-console-ui/src/screens/pin_hosts.rs @@ -8,7 +8,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::model::ConsoleCmd; use crate::screens::{Ctx, Outbox}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{ListMsg, MenuList, RowSpec}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use skia_safe::{Canvas, Rect}; @@ -115,7 +115,7 @@ impl PinHostsScreen { "No saved hosts yet — pair with a host first, then pin this profile to it.", W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.top) + f64::from(rect.height()) / 2.0, f64::from(rect.width()) * 0.7, @@ -157,7 +157,7 @@ impl PinHostsScreen { "A pinned profile appears as its own card on the host — one press connects with it.", W::Regular, 13.0 * k, - DIM, + fg(0.55), cx, f64::from(rect.bottom) - detail_h + 6.0 * k, f64::from(rect.width()) * 0.8, diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 4fe1d83b..550d29d7 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -11,7 +11,7 @@ use crate::glyphs::{Hint, HintKey}; use crate::screens::{Ctx, Outbox, Screen}; -use crate::theme::{Fonts, DIM, W}; +use crate::theme::{fg, Fonts, W}; use crate::widgets::{ListMsg, MenuList, RowSpec, TabStrip, TAB_STRIP_H}; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_client_core::trust::{MouseMode, StatsVerbosity, TouchMode}; @@ -405,7 +405,7 @@ impl SettingsScreen { detail, W::Regular, 13.0 * k, - DIM, + fg(0.55), f64::from(rect.left) + f64::from(rect.width()) / 2.0, f64::from(rect.bottom) - detail_h + 6.0 * k, f64::from(rect.width()) * 0.8, diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index f92cc23c..ab146c41 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -86,10 +86,16 @@ pub(crate) struct Shell { /// re-colours under the cursor as the row is stepped, which is the whole point of putting /// the picker on a screen the backdrop is behind. mesh_palette: String, - /// The palette's corner colour × 0.4 — the calm lift, precomputed with `mesh`. Chosen so - /// `col*0.6 + lift` leaves a corner EXACTLY where it was and pulls the bright pools down + /// The palette's ground × 0.4 — the calm lift, precomputed with `mesh`. Chosen so + /// `col*0.6 + lift` leaves the ground EXACTLY where it was and pulls the bright pools down /// to it: the form screens lose the launcher's contrast, not its colour. mesh_lift: [f32; 3], + /// The backdrop's scrim under this palette: rgb = what the vignette and scrims tend + /// toward (black on a dark field, white on a pale one), a = how hard. Kept with the ink. + mesh_scrim: [f32; 4], + /// The text/accent/glass the palette calls for, published to the whole crate once per + /// frame (see [`crate::theme::set_ink`]). + ink: crate::theme::Ink, /// 0 = launcher aurora, 1 = the calm form field — chased, so the backdrop settles into /// (or out of) calm alongside the screen transition. bg_mix: f64, @@ -110,7 +116,7 @@ impl Shell { ) -> Result { anyhow::ensure!(!stack.is_empty(), "the console needs a root screen"); let settings = trust::Settings::load(); - let (mesh, mesh_lift) = build_mesh(&settings.ui_palette)?; + let (mesh, mesh_lift, mesh_scrim, ink) = build_mesh(&settings.ui_palette)?; let bg_mix = match stack.last().expect("non-empty").background() { Bg::Aurora => 0.0, Bg::Form => 1.0, @@ -135,6 +141,8 @@ impl Shell { toast: None, mesh, mesh_lift, + mesh_scrim, + ink, bg_mix, glyphs: GlyphStyle::Keyboard, chip: None, @@ -206,9 +214,11 @@ impl Shell { // because someone picked a colour. if self.settings.ui_palette != self.mesh_palette { match build_mesh(&self.settings.ui_palette) { - Ok((mesh, lift)) => { + Ok((mesh, lift, scrim, ink)) => { self.mesh = mesh; self.mesh_lift = lift; + self.mesh_scrim = scrim; + self.ink = ink; self.mesh_palette = self.settings.ui_palette.clone(); } Err(e) => { @@ -468,8 +478,9 @@ impl Shell { /// screens sit on; the shell chases it, so there is only ever ONE backdrop pass — the /// former aurora-over-static-form crossfade is now a single uniform. fn draw_aurora(&self, canvas: &Canvas, w: f64, h: f64, t: f64, calm: f64) { - // Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4). - let uniforms: [f32; 8] = [ + // Laid out to match the SkSL block: u_res (float2), u_tc (float2), u_lift (float4), + // u_scrim (float4). + let uniforms: [f32; 12] = [ w as f32, h as f32, t as f32, @@ -478,11 +489,15 @@ impl Shell { self.mesh_lift[1], self.mesh_lift[2], 0.0, + self.mesh_scrim[0], + self.mesh_scrim[1], + self.mesh_scrim[2], + self.mesh_scrim[3], ]; - // SAFETY: `uniforms` is a local `[f32; 8]` — exactly 32 bytes — and `f32` has no padding or - // invalid bit patterns, so reading it as bytes is sound; the slice is copied by + // SAFETY: `uniforms` is a local `[f32; 12]` — exactly 48 bytes — and `f32` has no padding + // or invalid bit patterns, so reading it as bytes is sound; the slice is copied by // `Data::new_copy` before `uniforms` goes out of scope. - let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 32) }; + let bytes = unsafe { std::slice::from_raw_parts(uniforms.as_ptr().cast::(), 48) }; match self.mesh.make_shader(Data::new_copy(bytes), &[], None) { Some(shader) => { let mut paint = Paint::default(); @@ -496,28 +511,30 @@ impl Shell { } } -/// Compile the mesh shader for a palette, returning it with its precomputed calm lift. +/// Compile the mesh shader for a palette and resolve everything else that palette decides: +/// the calm lift, the scrim direction, and the ink the whole UI draws with. /// `uniform_size` is checked rather than assumed: the byte buffer [`Shell::draw_aurora`] /// hands Skia is hand-packed, and a silent layout change would feed the field garbage /// instead of failing. -fn build_mesh(palette_id: &str) -> Result<(RuntimeEffect, [f32; 3])> { +type MeshLook = (RuntimeEffect, [f32; 3], [f32; 4], crate::theme::Ink); + +fn build_mesh(palette_id: &str) -> Result { let p = palette(palette_id); let colors = p.mesh_colors(); let effect = RuntimeEffect::make_for_shader(mesh_sksl(&colors), None) .map_err(|e| anyhow!("mesh-gradient SkSL: {e}"))?; anyhow::ensure!( - effect.uniform_size() == 32, - "mesh uniform block is {} bytes, expected 32 (u_res, u_tc, u_lift)", + effect.uniform_size() == 48, + "mesh uniform block is {} bytes, expected 48 (u_res, u_tc, u_lift, u_scrim)", effect.uniform_size() ); - let corner = colors[0]; + let ink = crate::theme::Ink::of(p); + let g = p.ground; Ok(( effect, - [ - (corner.0 * 0.4) as f32, - (corner.1 * 0.4) as f32, - (corner.2 * 0.4) as f32, - ], + [(g.0 * 0.4) as f32, (g.1 * 0.4) as f32, (g.2 * 0.4) as f32], + [ink.scrim.r, ink.scrim.g, ink.scrim.b, ink.scrim.a], + ink, )) } diff --git a/crates/pf-console-ui/src/shell/overlays.rs b/crates/pf-console-ui/src/shell/overlays.rs index 24601855..4a042d8c 100644 --- a/crates/pf-console-ui/src/shell/overlays.rs +++ b/crates/pf-console-ui/src/shell/overlays.rs @@ -2,8 +2,8 @@ use crate::anim::{approach, ease_out_cubic}; use crate::glyphs::{hint_bar, Hint, HintKey}; -use crate::theme::{white, Fonts, PanelStroke, DIM, W, WHITE}; -use skia_safe::{gradient_shader, Canvas, Color4f, Paint, Point, Rect, TileMode}; +use crate::theme::{fg, Fonts, PanelStroke, W}; +use skia_safe::{gradient_shader, Canvas, Paint, Point, Rect, TileMode}; use super::{Shell, BOTTOM_BAND}; @@ -118,7 +118,7 @@ impl Shell { let rect = Rect::from_xywh(bx as f32, by as f32, bw as f32, bh as f32); canvas.draw_rrect( skia_safe::RRect::new_rect_xy(rect, (bh / 2.0) as f32, (bh / 2.0) as f32), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.6), None), + &Paint::new(crate::theme::shade(0.6), None), ); crate::theme::panel( canvas, @@ -135,7 +135,7 @@ impl Shell { by + bh / 2.0 + size * 0.36, W::Medium, size, - white(0.92), + fg(0.92), ); canvas.restore(); } @@ -167,14 +167,15 @@ impl Shell { // Opaque aurora — the same living backdrop the home wears, so the takeover reads as the // console taking over rather than a card popping up. self.draw_aurora(canvas, w, h, t, 0.0); - // A soft pool of shade under the centre seats the white text against a bright aurora. + // A soft pool of shade under the centre seats the text against a bright field — + // dark on a dark palette, light on a pale one, so it always separates. let mut vignette = Paint::default(); vignette.set_shader(gradient_shader::radial( Point::new(cx as f32, (h / 2.0) as f32), (w.max(h) * 0.42) as f32, gradient_shader::GradientShaderColors::Colors(&[ - Color4f::new(0.0, 0.0, 0.0, 0.5).to_color(), - Color4f::new(0.0, 0.0, 0.0, 0.0).to_color(), + crate::theme::shade(0.5).to_color(), + crate::theme::shade(0.0).to_color(), ]), None, TileMode::Clamp, @@ -193,7 +194,7 @@ impl Shell { title, W::SemiBold, 23.0 * k, - WHITE, + fg(1.0), cx, title_y, w * 0.82, @@ -204,7 +205,7 @@ impl Shell { body, W::Regular, 14.0 * k, - DIM, + fg(0.55), cx, title_y + 32.0 * k, w * 0.66, diff --git a/crates/pf-console-ui/src/shell/render.rs b/crates/pf-console-ui/src/shell/render.rs index c9c99eec..a5728d0a 100644 --- a/crates/pf-console-ui/src/shell/render.rs +++ b/crates/pf-console-ui/src/shell/render.rs @@ -5,7 +5,7 @@ use crate::glyphs::{hint_bar, GlyphStyle}; use crate::library::LibraryShared; use crate::model::HostRow; use crate::screens::{Bg, Ctx, Screen}; -use crate::theme::{white, Fonts, PanelStroke, W, WHITE}; +use crate::theme::{fg, Fonts, PanelStroke, W}; use pf_client_core::gamepad::PadInfo; use pf_client_core::trust; use skia_safe::{Canvas, Rect}; @@ -31,6 +31,10 @@ impl Shell { .replace(now) .map_or(1.0 / 60.0, |t| (now - t).as_secs_f64().clamp(0.0, 0.05)); self.sync(); + // Publish the palette's ink before ANYTHING draws — every widget, glyph and panel in + // the crate reads it (see `theme::set_ink`), so a frame that skipped this would paint + // the previous palette's text over the new palette's field. + crate::theme::set_ink(self.ink); self.pads = pads.to_vec(); self.glyphs = GlyphStyle::from_pref(pad_pref); self.chip = Some(pad.map_or_else( @@ -168,7 +172,7 @@ impl Shell { 18.0 * k + 16.0 * k, W::Medium, size, - white(0.7), + fg(0.7), ); } @@ -224,7 +228,7 @@ impl LayerEnv<'_> { &screen.title(&ctx), W::Bold, 30.0 * self.k, - WHITE, + fg(1.0), self.w / 2.0, 18.0 * self.k, self.w * 0.7, diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index da58255f..0c0af972 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -249,25 +249,34 @@ fn dump_console_screens() { dump(&mut s, 3, 25, "02-transition", true); dump(&mut s, 40, 8, "03-settings", true); - // The Interface tab (5 shoulder presses along) leads with the Background row, so this frame - // shows both the strip mid-list and the palette picker… + // The Interface tab (5 shoulder presses along) leads with the Background row, so these + // frames show the strip mid-list AND the palette picker. Palettes are set directly rather + // than by counting Confirm presses, so reordering the table can't silently shoot the wrong + // one. Each is a whole LOOK, not just a backdrop: accent, ink and scrim move together, so + // the pale ones must be eyeballed with dark text on them. for _ in 0..5 { s.handle_menu(MenuEvent::JumpForward); } - dump(&mut s, 40, 8, "03b-settings-interface", true); - // …and cycling it three times lands on Ember, which is the whole point: the CALM backdrop - // behind these rows recolours live. - for _ in 0..3 { - s.handle_menu(MenuEvent::Confirm); - } - dump(&mut s, 40, 8, "03c-settings-ember", true); - // Back to the brand default and the first tab so the later scenes look like they always did. - for _ in 0..3 { - s.handle_menu(MenuEvent::Confirm); + for id in ["violet", "ember", "abyss", "holo", "sunset", "mint"] { + s.settings.ui_palette = id.to_string(); + dump(&mut s, 40, 8, &format!("03-settings-{id}"), true); } + // Back to the first tab so the later scenes look like they always did. for _ in 0..5 { s.handle_menu(MenuEvent::JumpBack); } + // …and the LAUNCHER at full contrast under a few of them — the backdrop's loudest form, + // and the one the palettes are really chosen by. + s.handle_menu(MenuEvent::Back); + dump(&mut s, 20, 8, "_settle", true); + for id in ["nebula", "sunset", "holo"] { + s.settings.ui_palette = id.to_string(); + dump(&mut s, 40, 8, &format!("01-home-{id}"), true); + } + s.settings.ui_palette = "violet".to_string(); + dump(&mut s, 20, 8, "_settle2", true); + s.handle_menu(MenuEvent::Tertiary); // back into Settings for the scenes below + dump(&mut s, 20, 8, "_settle3", true); // Add Host with the keyboard tray up (keyboard glyph style: no pad). s.handle_menu(MenuEvent::Back); diff --git a/crates/pf-console-ui/src/theme.rs b/crates/pf-console-ui/src/theme.rs index fb59b0e9..ac58f7db 100644 --- a/crates/pf-console-ui/src/theme.rs +++ b/crates/pf-console-ui/src/theme.rs @@ -14,28 +14,117 @@ use skia_safe::{ Point, RRect, Rect, TileMode, Typeface, }; -// --- Palette ----------------------------------------------------------------------------- +// --- Ink ---------------------------------------------------------------------------------- -/// The punktfunk brand violet — the DARK-appearance value (#8678F5); the console UI is -/// always dark. (Light surfaces use #6656F2; nothing here is light.) -pub(crate) const BRAND: Color4f = Color4f::new(0.525, 0.471, 0.961, 1.0); -pub(crate) const WHITE: Color4f = Color4f::new(1.0, 1.0, 1.0, 1.0); -pub(crate) const DIM: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.55); -pub(crate) const FAINT: Color4f = Color4f::new(1.0, 1.0, 1.0, 0.35); -/// The error/status red (the GTK client's #ff938a). +/// The error/status red (the GTK client's #ff938a). Fixed: a warning must not change meaning +/// with the wallpaper. pub(crate) const ERROR: Color4f = Color4f::new(1.0, 0.576, 0.541, 1.0); pub(crate) const ONLINE_GREEN: Color4f = Color4f::new(0.20, 0.84, 0.29, 1.0); -pub(crate) fn white(alpha: f32) -> Color4f { - Color4f::new(1.0, 1.0, 1.0, alpha) +/// Everything about the console's look that follows the chosen background palette: which way +/// the text runs, what the glass is made of, and the accent that marks focus. +/// +/// The console UI was white-on-dark throughout, with the brand violet hardcoded as the accent. +/// Both had to become palette-derived at once: a pale field needs dark text or it is +/// unreadable, and a violet focus wash on a copper field is the clash this exists to fix. +#[derive(Clone, Copy)] +pub(crate) struct Ink { + /// Primary text/glyph colour, opaque. + fg: Color4f, + /// Focus wash, selected pill, caret — the palette's own accent. + accent: Color4f, + /// The base fill every glass panel starts from. + glass: Color4f, + /// What the vignette and legibility scrims tend toward — black under a dark field, white + /// under a pale one (darkening a pastel field would strand the dark text on it) — with the + /// alpha carrying HOW HARD. A pale field needs far less: mixing toward white at the dark + /// field's strength bleaches the chroma straight out of the gradient. + pub(crate) scrim: Color4f, } -pub(crate) fn brand(alpha: f32) -> Color4f { - Color4f::new(BRAND.r, BRAND.g, BRAND.b, alpha) +/// The shipped dark look — also what a test or a preview gets before any palette is applied. +const DARK_INK: Ink = Ink { + fg: Color4f::new(1.0, 1.0, 1.0, 1.0), + // The punktfunk brand violet, DARK-appearance value (#8678F5). + accent: Color4f::new(0.525, 0.471, 0.961, 1.0), + glass: Color4f::new(0.086, 0.086, 0.125, 0.62), + scrim: Color4f::new(0.0, 0.0, 0.0, 1.0), +}; + +impl Ink { + /// The ink a palette calls for. On a pale field the text goes near-black (tinted toward the + /// palette's own ground so it doesn't read as a foreign grey) and the glass turns to white + /// frost, which is what keeps a row legible over a bright gradient. + pub(crate) fn of(p: &crate::library::Palette) -> Ink { + let accent = Color4f::new(p.accent.0 as f32, p.accent.1 as f32, p.accent.2 as f32, 1.0); + if !p.light { + return Ink { accent, ..DARK_INK }; + } + let g = p.ground; + Ink { + fg: Color4f::new( + (g.0 * 0.16) as f32, + (g.1 * 0.14) as f32, + (g.2 * 0.20) as f32, + 1.0, + ), + accent, + // More body than the dark glass carries: white frost over a bright gradient has + // far less to separate it from its backdrop than dark glass over a dark one. + glass: Color4f::new(1.0, 1.0, 1.0, 0.66), + scrim: Color4f::new(1.0, 1.0, 1.0, 0.45), + } + } } -/// The dark-glass base fill every panel starts from. -const GLASS_BASE: Color4f = Color4f::new(0.086, 0.086, 0.125, 0.62); +thread_local! { + /// The ink the CURRENT frame draws with. A thread-local rather than a parameter because + /// every widget, glyph and panel in the crate reads it and the console renders on exactly + /// one thread — threading an `Ink` through ~90 call sites would be all cost and no safety. + /// [`crate::shell::Shell::render`] sets it once per frame, before anything draws. + static INK: std::cell::Cell = const { std::cell::Cell::new(DARK_INK) }; +} + +pub(crate) fn set_ink(ink: Ink) { + INK.with(|i| i.set(ink)); +} + +pub(crate) fn ink() -> Ink { + INK.with(std::cell::Cell::get) +} + +/// The foreground at `alpha` — white on a dark palette, near-black on a pale one. +pub(crate) fn fg(alpha: f32) -> Color4f { + let c = ink().fg; + Color4f::new(c.r, c.g, c.b, alpha) +} + +/// The palette's accent at `alpha`. +pub(crate) fn accent(alpha: f32) -> Color4f { + let c = ink().accent; + Color4f::new(c.r, c.g, c.b, alpha) +} + +/// A wash laid UNDER text to seat it against the field — black on a dark palette, white on a +/// pale one. `alpha` is the dark-field strength; a pale field needs less (see [`Ink::scrim`]), +/// so it is scaled the same way the backdrop's own scrims are. +pub(crate) fn shade(alpha: f32) -> Color4f { + let s = ink().scrim; + Color4f::new(s.r, s.g, s.b, alpha * s.a) +} + +/// Ink that reads ON the accent (a filled key, a selected pill): whichever of black or white +/// the accent has more room for. Chosen by luminance rather than by `light`, because an accent +/// is picked for contrast against the GLASS, not against the field. +pub(crate) fn on_accent() -> Color4f { + let a = ink().accent; + let luma = 0.2126 * a.r + 0.7152 * a.g + 0.0722 * a.b; + if luma > 0.55 { + Color4f::new(0.0, 0.0, 0.0, 1.0) + } else { + Color4f::new(1.0, 1.0, 1.0, 1.0) + } +} // --- Panels (the Liquid Glass stand-in) -------------------------------------------------- @@ -61,7 +150,7 @@ pub(crate) fn panel( k: f32, ) { let rr = RRect::new_rect_xy(rect, corner * k, corner * k); - canvas.draw_rrect(rr, &Paint::new(GLASS_BASE, None)); + canvas.draw_rrect(rr, &Paint::new(ink().glass, None)); if let Some(tint) = tint { canvas.draw_rrect(rr, &Paint::new(tint, None)); } @@ -71,10 +160,10 @@ pub(crate) fn panel( sp.set_anti_alias(true); match stroke { PanelStroke::Plain(alpha) => { - sp.set_color4f(white(alpha), None); + sp.set_color4f(fg(alpha), None); } PanelStroke::Brand(alpha) => { - sp.set_color4f(brand(alpha), None); + sp.set_color4f(accent(alpha), None); } PanelStroke::Gradient | PanelStroke::GradientDashed => { sp.set_shader(gradient_shader::linear( @@ -83,8 +172,8 @@ pub(crate) fn panel( Point::new(rect.left, rect.bottom), ), gradient_shader::GradientShaderColors::Colors(&[ - white(0.22).to_color(), - white(0.04).to_color(), + fg(0.22).to_color(), + fg(0.04).to_color(), ]), None, TileMode::Clamp, @@ -122,7 +211,7 @@ pub(crate) fn drop_shadow(canvas: &Canvas, rect: Rect, corner: f32, k: f32, alph /// The loading/connecting spinner: a rotating 270° arc driven by the shell clock. pub(crate) fn spinner(canvas: &Canvas, cx: f64, cy: f64, r: f64, t: f64) { let start = (t * 300.0) % 360.0; - let mut paint = Paint::new(white(0.85), None); + let mut paint = Paint::new(fg(0.85), None); paint.set_style(skia_safe::PaintStyle::Stroke); paint.set_stroke_width((r / 5.0) as f32); paint.set_stroke_cap(skia_safe::PaintCap::Round); diff --git a/crates/pf-console-ui/src/widgets.rs b/crates/pf-console-ui/src/widgets.rs index b5f9fa0b..916888cd 100644 --- a/crates/pf-console-ui/src/widgets.rs +++ b/crates/pf-console-ui/src/widgets.rs @@ -6,7 +6,7 @@ use crate::anim::{approach, Spring, TRAY_C, TRAY_K}; use crate::library::{BUMP_C, BUMP_K}; -use crate::theme::{brand, white, Fonts, PanelStroke, BRAND, DIM, FAINT, W, WHITE}; +use crate::theme::{accent, fg, Fonts, PanelStroke, W}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Paint, Path, RRect, Rect}; @@ -209,7 +209,7 @@ impl MenuList { W::SemiBold, 12.0 * k, 1.4 * k, - white(0.45), + fg(0.45), ); } // Focus scale eases 0.98 → 1.0 about the row center. @@ -226,9 +226,9 @@ impl MenuList { PanelStroke::Plain(0.06 + 0.22 * f as f32) }; let tint = if row.caret { - Some(brand(0.30)) + Some(accent(0.30)) } else if f > 0.01 { - Some(brand(0.30 * f as f32)) + Some(accent(0.30 * f as f32)) } else { None }; @@ -237,7 +237,7 @@ impl MenuList { let baseline = cy + 16.0 * k * 0.36; if row.value.is_none() { // Action row: centered label, brand when actionable. - let color = if row.enabled { BRAND } else { FAINT }; + let color = if row.enabled { accent(1.0) } else { fg(0.35) }; let tw = fonts.measure(&row.label, W::SemiBold, 16.0 * k) as f64; fonts.draw( canvas, @@ -256,15 +256,15 @@ impl MenuList { baseline, W::SemiBold, 16.0 * k, - if row.enabled { WHITE } else { DIM }, + if row.enabled { fg(1.0) } else { fg(0.55) }, ); let value = row.value.as_deref().unwrap_or_default(); let vcolor = if row.value_dim { - FAINT + fg(0.35) } else if f > 0.5 { - WHITE + fg(1.0) } else { - white(0.6 + 0.4 * f as f32) + fg(0.6 + 0.4 * f as f32) }; let chevron_w = if row.adjustable { 18.0 * k } else { 0.0 }; let caret_w = if row.caret { 8.0 * k } else { 0.0 }; @@ -282,7 +282,7 @@ impl MenuList { (2.0 * k) as f32, (18.0 * k) as f32, ), - &Paint::new(BRAND, None), + &Paint::new(accent(1.0), None), ); } if row.adjustable && f > 0.01 { @@ -362,7 +362,7 @@ impl TabStrip { canvas, Rect::from_xywh(ix as f32, top as f32, iw as f32, pill_h as f32), (pill_h / 2.0 / k) as f32, - Some(brand(0.85)), + Some(accent(0.85)), PanelStroke::Plain(0.22), k as f32, ); @@ -382,7 +382,7 @@ impl TabStrip { baseline, W::SemiBold, size, - white(0.5 + 0.5 * covered), + fg(0.5 + 0.5 * covered), ); x += widths[i] + gap; } @@ -407,7 +407,7 @@ fn truncate_head(fonts: &Fonts, text: &str, w: W, size: f64, max_w: f64) -> Stri fn chevron(canvas: &Canvas, x: f64, cy: f64, r: f64, left: bool, alpha: f32) { let dir = if left { -1.0 } else { 1.0 }; - let mut p = Paint::new(white(alpha), None); + let mut p = Paint::new(fg(alpha), None); p.set_style(skia_safe::PaintStyle::Stroke); p.set_stroke_width((1.8 * r / 4.0) as f32); p.set_stroke_cap(skia_safe::PaintCap::Round); @@ -594,7 +594,7 @@ impl Keyboard { let focused = r == self.row && c == self.col; let kr = Rect::from_xywh(x as f32, y as f32, key_w as f32, key_h as f32); let fill = if focused { - let mut b = BRAND; + let mut b = accent(1.0); if self.key_flash > 0.02 { // A just-typed key flashes brighter, then eases back. let f = self.key_flash as f32; @@ -607,16 +607,18 @@ impl Keyboard { } b } else { - white(0.08) + fg(0.08) }; canvas.draw_rrect( RRect::new_rect_xy(kr, (9.0 * k) as f32, (9.0 * k) as f32), &Paint::new(fill, None), ); + // The focused key is filled with the accent, so its letter needs ink that + // reads on THAT, not on the field. let ink = if focused { - skia_safe::Color4f::new(0.0, 0.0, 0.0, 1.0) + crate::theme::on_accent() } else { - WHITE + fg(1.0) }; let (cx, cy) = (x + key_w / 2.0, y + key_h / 2.0); match key { -- 2.54.0