ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m59s
android / android (pull_request) Successful in 5m53s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m36s
ci / rust (pull_request) Successful in 9m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m41s
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).
92 lines
3.9 KiB
Swift
92 lines
3.9 KiB
Swift
// 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
|