feat: add ui.seamColor accent

This commit is contained in:
Peter Steinberger
2025-12-30 04:14:36 +01:00
parent e3d8d5f300
commit d2ac672f47
14 changed files with 229 additions and 37 deletions

View File

@@ -4,12 +4,15 @@
### Features ### Features
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech. - Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
### Fixes ### Fixes
- macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background. - macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.
- macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries. - macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.
- macOS Debug: hide “Restart Gateway” when the app wont start a local gateway (remote mode / attach-only). - macOS Debug: hide “Restart Gateway” when the app wont start a local gateway (remote mode / attach-only).
- macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured. - macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.
- macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).
- macOS Talk Mode: increase overlay window size so wave rings dont clip; close button is hover-only and closer to the orb.
- Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android). - Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).
- Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand. - Gateway config: inject `talk.apiKey` from `ELEVENLABS_API_KEY`/shell profile so nodes can fetch it on demand.
- Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast. - Canvas A2UI: tag requests with `platform=android|ios|macos` and boost Android canvas background contrast.

View File

@@ -24,6 +24,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val serverName: StateFlow<String?> = runtime.serverName val serverName: StateFlow<String?> = runtime.serverName
val remoteAddress: StateFlow<String?> = runtime.remoteAddress val remoteAddress: StateFlow<String?> = runtime.remoteAddress
val isForeground: StateFlow<Boolean> = runtime.isForeground val isForeground: StateFlow<Boolean> = runtime.isForeground
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken

View File

@@ -120,6 +120,9 @@ class NodeRuntime(context: Context) {
private val _remoteAddress = MutableStateFlow<String?>(null) private val _remoteAddress = MutableStateFlow<String?>(null)
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow() val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
private val _isForeground = MutableStateFlow(true) private val _isForeground = MutableStateFlow(true)
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow() val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
@@ -133,6 +136,8 @@ class NodeRuntime(context: Context) {
_serverName.value = name _serverName.value = name
_remoteAddress.value = remote _remoteAddress.value = remote
_isConnected.value = true _isConnected.value = true
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
scope.launch { refreshBrandingFromGateway() }
scope.launch { refreshWakeWordsFromGateway() } scope.launch { refreshWakeWordsFromGateway() }
maybeNavigateToA2uiOnConnect() maybeNavigateToA2uiOnConnect()
}, },
@@ -155,6 +160,7 @@ class NodeRuntime(context: Context) {
_serverName.value = null _serverName.value = null
_remoteAddress.value = null _remoteAddress.value = null
_isConnected.value = false _isConnected.value = false
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
chat.onDisconnected(message) chat.onDisconnected(message)
showLocalCanvasOnDisconnect() showLocalCanvasOnDisconnect()
} }
@@ -618,6 +624,21 @@ class NodeRuntime(context: Context) {
} }
} }
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {
val res = session.request("config.get", "{}")
val root = json.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val parsed = parseHexColorArgb(raw) ?: return
_seamColorArgb.value = parsed
} catch (_: Throwable) {
// ignore
}
}
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult { private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
if ( if (
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) || command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
@@ -901,6 +922,8 @@ class NodeRuntime(context: Context) {
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D) private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF9EE0FF
private const val a2uiReadyCheckJS: String = private const val a2uiReadyCheckJS: String =
""" """
(() => { (() => {
@@ -955,3 +978,12 @@ private fun JsonElement?.asStringOrNull(): String? =
is JsonPrimitive -> content is JsonPrimitive -> content
else -> null else -> null
} }
private fun parseHexColorArgb(raw: String?): Long? {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
if (hex.length != 6) return null
val rgb = hex.toLongOrNull(16) ?: return null
return 0xFF000000L or rgb
}

View File

@@ -57,6 +57,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -81,6 +83,8 @@ fun RootScreen(viewModel: MainViewModel) {
val isForeground by viewModel.isForeground.collectAsState() val isForeground by viewModel.isForeground.collectAsState()
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
val talkEnabled by viewModel.talkEnabled.collectAsState() val talkEnabled by viewModel.talkEnabled.collectAsState()
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
val audioPermissionLauncher = val audioPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) viewModel.setTalkEnabled(true) if (granted) viewModel.setTalkEnabled(true)
@@ -225,6 +229,14 @@ fun RootScreen(viewModel: MainViewModel) {
) )
// Talk mode gets a dedicated side bubble instead of burying it in settings. // Talk mode gets a dedicated side bubble instead of burying it in settings.
val baseOverlay = overlayContainerColor()
val talkContainer =
lerp(
baseOverlay,
seamColor.copy(alpha = baseOverlay.alpha),
if (talkEnabled) 0.35f else 0.22f,
)
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
OverlayIconButton( OverlayIconButton(
onClick = { onClick = {
val next = !talkEnabled val next = !talkEnabled
@@ -238,12 +250,12 @@ fun RootScreen(viewModel: MainViewModel) {
viewModel.setTalkEnabled(false) viewModel.setTalkEnabled(false)
} }
}, },
containerColor = talkContainer,
contentColor = talkContent,
icon = { icon = {
val tint = if (talkEnabled) MaterialTheme.colorScheme.primary else LocalContentColor.current
Icon( Icon(
Icons.Default.RecordVoiceOver, Icons.Default.RecordVoiceOver,
contentDescription = "Talk Mode", contentDescription = "Talk Mode",
tint = tint,
) )
}, },
) )
@@ -278,14 +290,16 @@ private enum class Sheet {
private fun OverlayIconButton( private fun OverlayIconButton(
onClick: () -> Unit, onClick: () -> Unit,
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
containerColor: ComposeColor? = null,
contentColor: ComposeColor? = null,
) { ) {
FilledTonalIconButton( FilledTonalIconButton(
onClick = onClick, onClick = onClick,
modifier = Modifier.size(44.dp), modifier = Modifier.size(44.dp),
colors = colors =
IconButtonDefaults.filledTonalIconButtonColors( IconButtonDefaults.filledTonalIconButtonColors(
containerColor = overlayContainerColor(), containerColor = containerColor ?: overlayContainerColor(),
contentColor = overlayIconColor(), contentColor = contentColor ?: overlayIconColor(),
), ),
) { ) {
icon() icon()

View File

@@ -22,6 +22,7 @@ final class NodeAppModel {
var bridgeServerName: String? var bridgeServerName: String?
var bridgeRemoteAddress: String? var bridgeRemoteAddress: String?
var connectedBridgeID: String? var connectedBridgeID: String?
var seamColorHex: String?
private let bridge = BridgeSession() private let bridge = BridgeSession()
private var bridgeTask: Task<Void, Never>? private var bridgeTask: Task<Void, Never>?
@@ -225,6 +226,7 @@ final class NodeAppModel {
self.bridgeRemoteAddress = addr self.bridgeRemoteAddress = addr
} }
} }
await self.refreshBrandingFromGateway()
await self.startVoiceWakeSync() await self.startVoiceWakeSync()
await self.showA2UIOnConnectIfNeeded() await self.showA2UIOnConnectIfNeeded()
}, },
@@ -264,6 +266,7 @@ final class NodeAppModel {
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil self.connectedBridgeID = nil
self.seamColorHex = nil
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
} }
@@ -279,9 +282,42 @@ final class NodeAppModel {
self.bridgeServerName = nil self.bridgeServerName = nil
self.bridgeRemoteAddress = nil self.bridgeRemoteAddress = nil
self.connectedBridgeID = nil self.connectedBridgeID = nil
self.seamColorHex = nil
self.showLocalCanvasOnDisconnect() self.showLocalCanvasOnDisconnect()
} }
var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0)
private static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
private func refreshBrandingFromGateway() async {
do {
let res = try await self.bridge.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
}
} catch {
// ignore
}
}
func setGlobalWakeWords(_ words: [String]) async { func setGlobalWakeWords(_ words: [String]) async {
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words) let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)

View File

@@ -147,7 +147,9 @@ private struct CanvasContent: View {
// Talk mode lives on a side bubble so it doesn't get buried in settings. // Talk mode lives on a side bubble so it doesn't get buried in settings.
OverlayButton( OverlayButton(
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle", systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle",
brighten: self.brightenButtons) brighten: self.brightenButtons,
tint: self.appModel.seamColor,
isActive: self.appModel.talkMode.isEnabled)
{ {
let next = !self.appModel.talkMode.isEnabled let next = !self.appModel.talkMode.isEnabled
self.talkEnabled = next self.talkEnabled = next
@@ -251,13 +253,15 @@ private struct CanvasContent: View {
private struct OverlayButton: View { private struct OverlayButton: View {
let systemImage: String let systemImage: String
let brighten: Bool let brighten: Bool
var tint: Color? = nil
var isActive: Bool = false
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
Button(action: self.action) { Button(action: self.action) {
Image(systemName: self.systemImage) Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
.padding(10) .padding(10)
.background { .background {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -275,9 +279,26 @@ private struct OverlayButton: View {
endPoint: .bottomTrailing)) endPoint: .bottomTrailing))
.blendMode(.overlay) .blendMode(.overlay)
} }
.overlay {
if let tint {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
tint.opacity(self.isActive ? 0.22 : 0.14),
tint.opacity(self.isActive ? 0.10 : 0.06),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
}
.overlay { .overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5) .strokeBorder(
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.isActive ? 0.7 : 0.5)
} }
.shadow(color: .black.opacity(0.35), radius: 12, y: 6) .shadow(color: .black.opacity(0.35), radius: 12, y: 6)
} }

View File

@@ -130,6 +130,9 @@ final class AppState {
} }
} }
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
var seamColorHex: String?
var iconOverride: IconOverrideSelection { var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
} }
@@ -226,6 +229,7 @@ final class AppState {
self.voicePushToTalkEnabled = UserDefaults.standard self.voicePushToTalkEnabled = UserDefaults.standard
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false .object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey) self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
self.seamColorHex = nil
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
self.heartbeatsEnabled = storedHeartbeats self.heartbeatsEnabled = storedHeartbeats
} else { } else {

View File

@@ -294,6 +294,11 @@ final class ConnectionsStore {
: nil : nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configLoaded = true self.configLoaded = true
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
let telegram = snap.config?["telegram"]?.dictionaryValue let telegram = snap.config?["telegram"]?.dictionaryValue
self.telegramToken = telegram?["botToken"]?.stringValue ?? "" self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true

View File

@@ -537,6 +537,11 @@ actor TalkModeRuntime {
params: nil, params: nil,
timeoutMs: 8000) timeoutMs: 8000)
let talk = snap.config?["talk"]?.dictionaryValue let talk = snap.config?["talk"]?.dictionaryValue
let ui = snap.config?["ui"]?.dictionaryValue
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
await MainActor.run {
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
let voice = talk?["voiceId"]?.stringValue let voice = talk?["voiceId"]?.stringValue
let model = talk?["modelId"]?.stringValue let model = talk?["modelId"]?.stringValue
let outputFormat = talk?["outputFormat"]?.stringValue let outputFormat = talk?["outputFormat"]?.stringValue

View File

@@ -7,6 +7,7 @@ import SwiftUI
@Observable @Observable
final class TalkOverlayController { final class TalkOverlayController {
static let shared = TalkOverlayController() static let shared = TalkOverlayController()
static let overlaySize: CGFloat = 220
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay") private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay")
@@ -19,9 +20,6 @@ final class TalkOverlayController {
var model = Model() var model = Model()
private var window: NSPanel? private var window: NSPanel?
private var hostingView: NSHostingView<TalkOverlayView>? private var hostingView: NSHostingView<TalkOverlayView>?
private let width: CGFloat = 160
private let height: CGFloat = 160
private let padding: CGFloat = 8 private let padding: CGFloat = 8
func present() { func present() {
@@ -84,7 +82,7 @@ final class TalkOverlayController {
private func ensureWindow() { private func ensureWindow() {
if self.window != nil { return } if self.window != nil { return }
let panel = NSPanel( let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height), contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize),
styleMask: [.nonactivatingPanel, .borderless], styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered, backing: .buffered,
defer: false) defer: false)
@@ -109,7 +107,7 @@ final class TalkOverlayController {
private func targetFrame() -> NSRect { private func targetFrame() -> NSRect {
guard let screen = NSScreen.main else { return .zero } guard let screen = NSScreen.main else { return .zero }
let size = NSSize(width: self.width, height: self.height) let size = NSSize(width: Self.overlaySize, height: Self.overlaySize)
let visible = screen.visibleFrame let visible = screen.visibleFrame
let origin = CGPoint( let origin = CGPoint(
x: visible.maxX - size.width - self.padding, x: visible.maxX - size.width - self.padding,

View File

@@ -2,40 +2,66 @@ import SwiftUI
struct TalkOverlayView: View { struct TalkOverlayView: View {
var controller: TalkOverlayController var controller: TalkOverlayController
@State private var hovering = false @State private var appState = AppStateStore.shared
@State private var hoveringWindow = false
var body: some View { var body: some View {
ZStack(alignment: .topLeading) { ZStack {
TalkOrbView(phase: self.controller.model.phase, level: self.controller.model.level) TalkOrbView(
phase: self.controller.model.phase,
level: self.controller.model.level,
accent: self.seamColor)
.frame(width: 96, height: 96) .frame(width: 96, height: 96)
.contentShape(Rectangle()) .contentShape(Circle())
.onTapGesture { .onTapGesture {
TalkModeController.shared.stopSpeaking(reason: .userTap) TalkModeController.shared.stopSpeaking(reason: .userTap)
} }
.padding(26) .overlay(alignment: .topLeading) {
Button {
Button { TalkModeController.shared.exitTalkMode()
TalkModeController.shared.exitTalkMode() } label: {
} label: { Image(systemName: "xmark")
Image(systemName: "xmark") .font(.system(size: 10, weight: .bold))
.font(.system(size: 10, weight: .bold)) .foregroundStyle(Color.white.opacity(0.95))
.foregroundStyle(Color.white.opacity(self.hovering ? 0.95 : 0.7)) .frame(width: 18, height: 18)
.frame(width: 18, height: 18) .background(Color.black.opacity(0.4))
.background(Color.black.opacity(self.hovering ? 0.45 : 0.3)) .clipShape(Circle())
.clipShape(Circle()) }
} .buttonStyle(.plain)
.buttonStyle(.plain) .contentShape(Circle())
.contentShape(Circle()) .offset(x: -10, y: -10)
.padding(4) .opacity(self.hoveringWindow ? 1 : 0)
.onHover { self.hovering = $0 } .animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
.allowsHitTesting(self.hoveringWindow)
}
} }
.frame(width: 160, height: 160, alignment: .center) .frame(width: TalkOverlayController.overlaySize, height: TalkOverlayController.overlaySize, alignment: .center)
.contentShape(Rectangle())
.onHover { self.hoveringWindow = $0 }
}
private static let defaultSeamColor = Color(red: 0.62, green: 0.88, blue: 1.0)
private var seamColor: Color {
Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor
}
private static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
} }
} }
private struct TalkOrbView: View { private struct TalkOrbView: View {
let phase: TalkModePhase let phase: TalkModePhase
let level: Double let level: Double
let accent: Color
var body: some View { var body: some View {
TimelineView(.animation) { context in TimelineView(.animation) { context in
@@ -50,7 +76,7 @@ private struct TalkOrbView: View {
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
.scaleEffect(pulse * listenScale) .scaleEffect(pulse * listenScale)
TalkWaveRings(phase: phase, level: level, time: t) TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
if phase == .thinking { if phase == .thinking {
TalkOrbitArcs(time: t) TalkOrbitArcs(time: t)
@@ -61,7 +87,7 @@ private struct TalkOrbView: View {
private var orbGradient: RadialGradient { private var orbGradient: RadialGradient {
RadialGradient( RadialGradient(
colors: [Color.white, Color(red: 0.62, green: 0.88, blue: 1.0)], colors: [Color.white, self.accent],
center: .topLeading, center: .topLeading,
startRadius: 4, startRadius: 4,
endRadius: 52) endRadius: 52)
@@ -72,7 +98,7 @@ private struct TalkWaveRings: View {
let phase: TalkModePhase let phase: TalkModePhase
let level: Double let level: Double
let time: TimeInterval let time: TimeInterval
private let ringColor = Color(red: 0.82, green: 0.94, blue: 1.0) let accent: Color
var body: some View { var body: some View {
ZStack { ZStack {
@@ -83,7 +109,7 @@ private struct TalkWaveRings: View {
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0) let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0)
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4 let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4
Circle() Circle()
.stroke(self.ringColor.opacity(alpha - progress * 0.3), lineWidth: 1.6) .stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
.scaleEffect(scale) .scaleEffect(scale)
.opacity(alpha - progress * 0.6) .opacity(alpha - progress * 0.6)
} }

View File

@@ -462,6 +462,20 @@ Defaults:
} }
``` ```
### `ui` (Appearance)
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
If unset, clients fall back to a muted light-blue.
```json5
{
ui: {
seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB)
}
}
```
### `gateway` (Gateway server mode + bind) ### `gateway` (Gateway server mode + bind)
Use `gateway.mode` to explicitly declare whether this machine should run the Gateway. Use `gateway.mode` to explicitly declare whether this machine should run the Gateway.

View File

@@ -346,6 +346,10 @@ export type ClawdisConfig = {
}; };
logging?: LoggingConfig; logging?: LoggingConfig;
browser?: BrowserConfig; browser?: BrowserConfig;
ui?: {
/** Accent color for Clawdis UI chrome (hex). */
seamColor?: string;
};
skillsLoad?: SkillsLoadConfig; skillsLoad?: SkillsLoadConfig;
skillsInstall?: SkillsInstallConfig; skillsInstall?: SkillsInstallConfig;
models?: ModelsConfig; models?: ModelsConfig;
@@ -494,6 +498,10 @@ const TranscribeAudioSchema = z
}) })
.optional(); .optional();
const HexColorSchema = z
.string()
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
const SessionSchema = z const SessionSchema = z
.object({ .object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -672,6 +680,11 @@ const ClawdisSchema = z.object({
attachOnly: z.boolean().optional(), attachOnly: z.boolean().optional(),
}) })
.optional(), .optional(),
ui: z
.object({
seamColor: HexColorSchema.optional(),
})
.optional(),
models: ModelsConfigSchema, models: ModelsConfigSchema,
agent: z agent: z
.object({ .object({

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("ui.seamColor", () => {
it("accepts hex colors", () => {
const res = validateConfigObject({ ui: { seamColor: "#FF4500" } });
expect(res.ok).toBe(true);
});
it("rejects non-hex colors", () => {
const res = validateConfigObject({ ui: { seamColor: "lobster" } });
expect(res.ok).toBe(false);
});
it("rejects invalid hex length", () => {
const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } });
expect(res.ok).toBe(false);
});
});