Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,35 +145,35 @@ data class LitterResolvedTheme(
): LitterResolvedTheme {
val colors = definition.colors
val background =
colorFromHex(
tokenColorFromHex(
colors["editor.background"],
fallback = if (definition.type == LitterColorThemeType.DARK) Color(0xFF111111) else Color.White,
)
val foreground =
colorFromHex(
tokenColorFromHex(
colors["editor.foreground"],
fallback = if (definition.type == LitterColorThemeType.DARK) Color(0xFFFCFCFC) else Color(0xFF0D0D0D),
)
val surface =
colors["sideBar.background"]?.let(::colorFromHex)
colors["sideBar.background"]?.let(::tokenColorFromHex)
?: adjustBrightness(background, if (definition.type == LitterColorThemeType.DARK) 0.03f else -0.02f)
val surfaceLight =
colors["activityBar.background"]?.let(::colorFromHex)
colors["activityBar.background"]?.let(::tokenColorFromHex)
?: adjustBrightness(surface, if (definition.type == LitterColorThemeType.DARK) 0.04f else -0.03f)
val accent =
colors["textLink.foreground"]?.let(::colorFromHex)
?: colors["button.background"]?.let(::colorFromHex)
colors["textLink.foreground"]?.let(::tokenColorFromHex)
?: colors["button.background"]?.let(::tokenColorFromHex)
?: if (definition.type == LitterColorThemeType.DARK) Color(0xFFB0B0B0) else Color(0xFF4A4A4A)
val accentStrong =
colors["button.background"]?.let(::colorFromHex)
?: colors["textLink.foreground"]?.let(::colorFromHex)
colors["button.background"]?.let(::tokenColorFromHex)
?: colors["textLink.foreground"]?.let(::tokenColorFromHex)
?: accent
val border =
colors["editorGroup.border"]?.let(::colorFromHex)
?: colors["sideBar.border"]?.let(::colorFromHex)
colors["editorGroup.border"]?.let(::tokenColorFromHex)
?: colors["sideBar.border"]?.let(::tokenColorFromHex)
?: adjustBrightness(surface, if (definition.type == LitterColorThemeType.DARK) 0.05f else -0.05f)
val separator =
colors["panel.border"]?.let(::colorFromHex)
colors["panel.border"]?.let(::tokenColorFromHex)
?: adjustBrightness(background, if (definition.type == LitterColorThemeType.DARK) 0.04f else -0.04f)

return LitterResolvedTheme(
Expand All @@ -184,8 +184,8 @@ data class LitterResolvedTheme(
surface = surface,
surfaceLight = surfaceLight,
textPrimary = foreground,
textSecondary = colors["sideBar.foreground"]?.let(::colorFromHex) ?: dimColor(foreground, 0.55f),
textMuted = colors["editorLineNumber.foreground"]?.let(::colorFromHex) ?: dimColor(foreground, 0.35f),
textSecondary = colors["sideBar.foreground"]?.let(::tokenColorFromHex) ?: dimColor(foreground, 0.55f),
textMuted = colors["editorLineNumber.foreground"]?.let(::tokenColorFromHex) ?: dimColor(foreground, 0.35f),
textBody = dimColor(foreground, 0.88f),
textSystem = dimColor(foreground, 0.7f),
accent = accent,
Expand Down Expand Up @@ -239,18 +239,40 @@ data class LitterResolvedTheme(
internal fun colorFromHex(
hex: String?,
fallback: Color = Color.Transparent,
): Color {
val normalized = hex?.trim()?.takeIf { it.isNotEmpty() } ?: return fallback
// Theme JSON uses CSS/VS Code #RRGGBBAA, not Android #AARRGGBB.
// App theme tokens are solid colors, matching the Material3 generator.
val rgb = when {
Regex("#[0-9a-fA-F]{3}").matches(normalized) ->
normalized.drop(1).map { "$it$it" }.joinToString("")
Regex("#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?").matches(normalized) ->
normalized.substring(1, 7)
else -> return fallback
): Color = parseColorFromHex(hex) ?: fallback

/// Parses theme tokens as opaque colors: CSS hex may carry alpha, but app
/// theme tokens must stay solid so they match iOS and the generated Material
/// schemes, which both drop alpha when a theme loads.
internal fun tokenColorFromHex(
hex: String?,
fallback: Color = Color.Transparent,
): Color = parseColorFromHex(hex)?.copy(alpha = 1f) ?: fallback

private fun parseColorFromHex(hex: String?): Color? {
val normalized = hex?.trim()?.takeIf { it.isNotEmpty() } ?: return null
// Theme JSON uses CSS/VS Code hex with alpha last: #RGB, #RGBA,
// #RRGGBB, #RRGGBBAA. Android's Color(Long) is ARGB (alpha first),
// so an alpha byte must be moved to the front, not dropped.
var digits = normalized.removePrefix("#").lowercase().toList()
if (digits.any { it !in '0'..'9' && it !in 'a'..'f' }) {
return null
}
return Color(0xFF000000 or rgb.toLong(16))
if (digits.size == 3 || digits.size == 4) {
digits = digits.flatMap { listOf(it, it) }
}
val value =
when (digits.size) {
6, 8 -> digits.joinToString("").toLongOrNull(16) ?: return null
else -> return null
}
return Color(
if (digits.size == 8) {
((value and 0xFF) shl 24) or (value ushr 8)
} else {
0xFF000000L or value
},
)
}

object LitterThemeManager {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,35 @@ import org.junit.Test

class ThemeColorTest {
@Test
fun alphaLastThemeColorsMatchGeneratedMaterialRoles() {
val accent = colorFromHex("#45858880")
assertEquals(Color(0xFF458588), accent)
assertEquals(accent, LitterMaterialSchemes.rolesFor("gruvbox-dark-medium", true)?.primary)
fun alphaLastThemeColorsPreserveCssAlpha() {
// Theme JSON is CSS hex with alpha last; Android ARGB is alpha first.
assertEquals(Color(0x80458588), colorFromHex("#45858880"))
assertEquals(Color(0x804585AA), colorFromHex("#4585AA80"))
// #RGBA shorthand expands each nibble.
assertEquals(Color(0xAAAA88AA), colorFromHex("#A8AA"))
}

@Test
fun themeTokensStripAlphaToMatchGeneratedMaterialRoles() {
assertEquals(Color(0xFF458588), tokenColorFromHex("#45858880"))
assertEquals(Color(0xFF000000), tokenColorFromHex("#0000"))
assertEquals(Color(0xFF458588), tokenColorFromHex("#458588"))
assertEquals(
tokenColorFromHex("#45858880"),
LitterMaterialSchemes.rolesFor("gruvbox-dark-medium", true)?.primary,
)
}

@Test
fun shorthandAndInvalidColorsResolveWithoutAndroidFramework() {
assertEquals(Color(0xFFAABBCC), colorFromHex(" #abc "))
assertEquals(Color(0xFF123456), colorFromHex("#123456"))
assertEquals(Color.Red, colorFromHex("#invalid", Color.Red))
assertEquals(Color.Red, colorFromHex("#1234567", Color.Red))
assertEquals(Color.Red, colorFromHex(null, Color.Red))
// A leading sign is not a hex digit and must fall back instead of
// letting toLongOrNull(16) parse it.
assertEquals(Color.Red, colorFromHex("#-12345", Color.Red))
assertEquals(Color.Red, colorFromHex("#+12345", Color.Red))
}
}
1 change: 1 addition & 0 deletions apps/android/docs/qa-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ Replaces the prior WebSocket + base64-PCM audio pump with a platform-native WebR
| Known non-blockers | Per-frame input/output meter animation no longer drives — requires `RTCRtpReceiver.stats` polling to restore (follow-up) | Same flat meter behavior; speaker toggle currently stubbed to a boolean — follow-up to honor runtime routing |
| Regression: custom AEC path | Retired — `codex-ios-audio` crate + `AecBridge.swift` / `VoiceSessionAudioCodec.swift` were deleted; libwebrtc AEC3 handles echo cancellation natively | Retired — `AecBridge.kt` deleted; `JavaAudioDeviceModule` enables the hardware AEC + NS |
| Regression: SSH-tunneled codex server | RPC still flows through SSH; WebRTC peer goes direct to OpenAI edge from device. If client runs in fully air-gapped network, realtime voice will not establish | Same |
| Theme hex colors preserve CSS alpha | `litterHexRGBA` parses #RGB/#RGBA/#RRGGBB/#RRGGBBAA with alpha last; swatch/index colors keep the alpha byte; theme tokens strip alpha at decode (`ThemeDefinition.sanitizeHex`) so app colors stay opaque (`HexColorTests`) | `colorFromHex` moves the alpha byte to the ARGB front instead of dropping it; theme tokens strip alpha via `tokenColorFromHex` to match iOS and the generated Material schemes (`ThemeColorTest`) |

## Message link menus (iOS PR #322)

Expand Down
4 changes: 4 additions & 0 deletions apps/ios/Litter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
56F93B9C3848A3D983526A2F /* CodexVoiceCallAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB2B7BC833F0EB556D8401A /* CodexVoiceCallAttributes.swift */; };
5784473E9D446D638DE2EEB2 /* CodexVoiceCallAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB2B7BC833F0EB556D8401A /* CodexVoiceCallAttributes.swift */; };
57F6703288282A1CC4A0B216 /* SavedThreadsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F1E4447C779CA8B66B950F7 /* SavedThreadsStore.swift */; };
58547E503761BE6E1C2086C9 /* HexColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A6C1D1C48E7406BAD30F901 /* HexColorTests.swift */; };
5895E5C25C200FC3FF747049 /* slack-dark.json in Resources */ = {isa = PBXBuildFile; fileRef = 5DF2C84DD77E7A8282AE9831 /* slack-dark.json */; };
5935F15B5E258E3222780528 /* github-light-high-contrast.json in Resources */ = {isa = PBXBuildFile; fileRef = 70AB8E0BCCF3384EE9B4A0BF /* github-light-high-contrast.json */; };
595604C3346A5A3C0B4DBBEC /* SessionLaunchSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2EAAADB209274FFFB94CC0 /* SessionLaunchSupport.swift */; };
Expand Down Expand Up @@ -959,6 +960,7 @@
88B886829FDF299DFFCC1A86 /* everforest-light.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "everforest-light.json"; sourceTree = "<group>"; };
89172C83E4194291FF873A88 /* houston.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = houston.json; sourceTree = "<group>"; };
892D583790A9BD90CFD89701 /* LockScreenCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenCardView.swift; sourceTree = "<group>"; };
8A6C1D1C48E7406BAD30F901 /* HexColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HexColorTests.swift; sourceTree = "<group>"; };
8BE99A02238FD8184F77DD49 /* WallpaperAdjustView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WallpaperAdjustView.swift; sourceTree = "<group>"; };
8D9B7168247D5201C4795A69 /* AlleycatPairingModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlleycatPairingModeTests.swift; sourceTree = "<group>"; };
8F98D2E0622F1A4DADC1E888 /* cat_transmission_01.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = cat_transmission_01.png; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1231,6 +1233,7 @@
77969543FA36FEA8B621A3B9 /* ConversationDisplayPreferenceTests.swift */,
B0C1A0569B4424123A37B0DA /* ConversationPlanSemanticsTests.swift */,
B66383694E80CDCBCF582C67 /* ConversationScreenModelTests.swift */,
8A6C1D1C48E7406BAD30F901 /* HexColorTests.swift */,
018779F7529B6CAAC3A2B896 /* HomeDashboardSupportTests.swift */,
F5F26ED2D3C223D3FD12ACB9 /* InteractionTimingTests.swift */,
73F04FAE655DA80B0585F866 /* LitterAppearanceModeTests.swift */,
Expand Down Expand Up @@ -2258,6 +2261,7 @@
2F28DA245F84E87318A4D7E4 /* ConversationDisplayPreferenceTests.swift in Sources */,
9C44ABDBA2399DD2C0B1E9E2 /* ConversationPlanSemanticsTests.swift in Sources */,
F1535DE46C8E5A530A8C131E /* ConversationScreenModelTests.swift in Sources */,
58547E503761BE6E1C2086C9 /* HexColorTests.swift in Sources */,
6EF7AFD103A68CDEA1A94612 /* HomeDashboardSupportTests.swift in Sources */,
7881749EF05190C237EFF36B /* InteractionTimingTests.swift in Sources */,
4BEFD1E7129A6B33D965029D /* LitterAppearanceModeTests.swift in Sources */,
Expand Down
24 changes: 10 additions & 14 deletions apps/ios/Sources/Litter/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,21 @@ import Observation

extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let r = Double((int >> 16) & 0xFF) / 255
let g = Double((int >> 8) & 0xFF) / 255
let b = Double(int & 0xFF) / 255
self.init(red: r, green: g, blue: b)
if let rgba = litterHexRGBA(hex) {
self.init(red: rgba.red, green: rgba.green, blue: rgba.blue, opacity: rgba.alpha)
} else {
self.init(red: 0, green: 0, blue: 0)
}
}
}

extension UIColor {
convenience init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let r = CGFloat((int >> 16) & 0xFF) / 255
let g = CGFloat((int >> 8) & 0xFF) / 255
let b = CGFloat(int & 0xFF) / 255
self.init(red: r, green: g, blue: b, alpha: 1)
if let rgba = litterHexRGBA(hex) {
self.init(red: rgba.red, green: rgba.green, blue: rgba.blue, alpha: rgba.alpha)
} else {
self.init(red: 0, green: 0, blue: 0, alpha: 1)
}
}
}

Expand Down
39 changes: 32 additions & 7 deletions apps/ios/Sources/Litter/Models/LitterPalette.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import SwiftUI

/// Parses CSS hex with alpha last (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`)
/// into normalized RGBA components, or nil for anything else.
///
/// Lives in this file because both the main app target and the
/// `LitterLiveActivity` extension compile `LitterPalette.swift`.
func litterHexRGBA(_ hex: String) -> (red: Double, green: Double, blue: Double, alpha: Double)? {
var value = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("#") { value.removeFirst() }
var digits = Array(value.lowercased())
// A leading sign is not a hex digit; UInt64(_:radix:) would still
// accept one, so require hex digits before parsing.
guard digits.allSatisfy(\.isHexDigit) else { return nil }
if digits.count == 3 || digits.count == 4 {
digits = digits.flatMap { [$0, $0] }
}
guard digits.count == 6 || digits.count == 8,
let int = UInt64(String(digits), radix: 16)
else { return nil }
let alpha = digits.count == 8 ? Double(int & 0xFF) / 255 : 1
let rgb = digits.count == 8 ? int >> 8 : int
return (
Double((rgb >> 16) & 0xFF) / 255,
Double((rgb >> 8) & 0xFF) / 255,
Double(rgb & 0xFF) / 255,
alpha
)
}

/// Shared color palette used by both the main app (LitterTheme) and the
/// Live Activity widget extension. Reads from the shared App Group
/// UserDefaults (written by ThemeManager) with hardcoded fallbacks.
Expand Down Expand Up @@ -63,12 +91,9 @@ extension LitterPalette.Pair {
}

static func colorFromHex(_ hex: String) -> Color {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let r = Double((int >> 16) & 0xFF) / 255
let g = Double((int >> 8) & 0xFF) / 255
let b = Double(int & 0xFF) / 255
return Color(red: r, green: g, blue: b)
if let rgba = litterHexRGBA(hex) {
return Color(red: rgba.red, green: rgba.green, blue: rgba.blue, opacity: rgba.alpha)
}
return Color(red: 0, green: 0, blue: 0)
}
}
27 changes: 16 additions & 11 deletions apps/ios/Sources/Litter/Models/ThemeDefinition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,19 @@ struct ThemeDefinition: Codable {
}
}

// VS Code allows #RRGGBBAA. Downstream color helpers assume 6-digit
// RGB, so strip the trailing alpha pair at the decode boundary.
// VS Code allows #RRGGBBAA and #RGBA. Downstream color helpers assume
// 6-digit RGB and theme tokens must stay opaque (Android strips alpha
// the same way), so drop the trailing pair at the decode boundary and
// expand 4-digit shorthand to solid #RRGGBB.
private static func sanitizeHex(_ raw: String) -> String {
guard raw.hasPrefix("#"), raw.count == 9 else { return raw }
return String(raw.prefix(7))
guard raw.hasPrefix("#") else { return raw }
let hex = String(raw.dropFirst())
if hex.count == 8 { return "#" + hex.prefix(6) }
if hex.count == 4 {
let digits = Array(hex.lowercased()).prefix(3)
return "#" + digits.map { "\($0)\($0)" }.joined()
}
return raw
}

// tokenColors are ignored — syntax highlighting is handled by Hairball
Expand Down Expand Up @@ -162,13 +170,10 @@ struct ResolvedTheme {
}

static func hexToRGB(_ hex: String) -> (Double, Double, Double) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: cleaned).scanHexInt64(&int)
let r = Double((int >> 16) & 0xFF) / 255
let g = Double((int >> 8) & 0xFF) / 255
let b = Double(int & 0xFF) / 255
return (r, g, b)
// Theme hex is CSS with alpha last; the dimming math uses the RGB
// channels only.
guard let rgba = litterHexRGBA(hex) else { return (0, 0, 0) }
return (rgba.red, rgba.green, rgba.blue)
}

static func rgbToHex(_ r: Double, _ g: Double, _ b: Double) -> String {
Expand Down
37 changes: 30 additions & 7 deletions apps/ios/Sources/LitterWatch/Models/WatchThemeStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,35 @@ final class WatchThemeStore: ObservableObject {

extension Color {
init(themeHex string: String) {
let cleaned = string.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var v: UInt64 = 0
Scanner(string: cleaned).scanHexInt64(&v)
let r = Double((v >> 16) & 0xFF) / 255
let g = Double((v >> 8) & 0xFF) / 255
let b = Double(v & 0xFF) / 255
self.init(.sRGB, red: r, green: g, blue: b, opacity: 1)
// Theme hex is CSS with alpha last: #RGB/#RGBA/#RRGGBB/#RRGGBBAA.
// The watch target doesn't compile the app's litterHexRGBA helper,
// so keep this parse in step with it.
var value = string.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("#") { value.removeFirst() }
var digits = Array(value.lowercased())
// A leading sign is not a hex digit; UInt64(_:radix:) would still
// accept one, so require hex digits before parsing.
guard digits.allSatisfy(\.isHexDigit) else {
self.init(.sRGB, red: 0, green: 0, blue: 0, opacity: 1)
return
}
if digits.count == 3 || digits.count == 4 {
digits = digits.flatMap { [$0, $0] }
}
guard digits.count == 6 || digits.count == 8,
let v = UInt64(String(digits), radix: 16)
else {
self.init(.sRGB, red: 0, green: 0, blue: 0, opacity: 1)
return
}
let opacity = digits.count == 8 ? Double(v & 0xFF) / 255 : 1
let rgb = digits.count == 8 ? v >> 8 : v
self.init(
.sRGB,
red: Double((rgb >> 16) & 0xFF) / 255,
green: Double((rgb >> 8) & 0xFF) / 255,
blue: Double(rgb & 0xFF) / 255,
opacity: opacity
)
}
}
Loading
Loading