From d2ac672f47cde655dd4eace0eba4bb6f1efc7b46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 30 Dec 2025 04:14:36 +0100 Subject: [PATCH] feat: add ui.seamColor accent --- CHANGELOG.md | 3 + .../steipete/clawdis/node/MainViewModel.kt | 1 + .../com/steipete/clawdis/node/NodeRuntime.kt | 32 ++++++++ .../steipete/clawdis/node/ui/RootScreen.kt | 22 +++++- apps/ios/Sources/Model/NodeAppModel.swift | 36 +++++++++ apps/ios/Sources/RootCanvas.swift | 27 ++++++- apps/macos/Sources/Clawdis/AppState.swift | 4 + .../Sources/Clawdis/ConnectionsStore.swift | 5 ++ .../Sources/Clawdis/TalkModeRuntime.swift | 5 ++ apps/macos/Sources/Clawdis/TalkOverlay.swift | 8 +- .../Sources/Clawdis/TalkOverlayView.swift | 76 +++++++++++++------ docs/configuration.md | 14 ++++ src/config/config.ts | 13 ++++ src/config/ui-seam-color.test.ts | 20 +++++ 14 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 src/config/ui-seam-color.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef7f5e85..33e51cee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,15 @@ ### Features - 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 - 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 Debug: hide “Restart Gateway” when the app won’t 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: avoid stuck playback when the audio player never starts (fail-fast + watchdog). +- macOS Talk Mode: increase overlay window size so wave rings don’t 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). - 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. diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt index 69b394a63..e93bd432a 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt @@ -24,6 +24,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val serverName: StateFlow = runtime.serverName val remoteAddress: StateFlow = runtime.remoteAddress val isForeground: StateFlow = runtime.isForeground + val seamColorArgb: StateFlow = runtime.seamColorArgb val cameraHud: StateFlow = runtime.cameraHud val cameraFlashToken: StateFlow = runtime.cameraFlashToken diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 50fbd3251..aa77bd94d 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -120,6 +120,9 @@ class NodeRuntime(context: Context) { private val _remoteAddress = MutableStateFlow(null) val remoteAddress: StateFlow = _remoteAddress.asStateFlow() + private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB) + val seamColorArgb: StateFlow = _seamColorArgb.asStateFlow() + private val _isForeground = MutableStateFlow(true) val isForeground: StateFlow = _isForeground.asStateFlow() @@ -133,6 +136,8 @@ class NodeRuntime(context: Context) { _serverName.value = name _remoteAddress.value = remote _isConnected.value = true + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB + scope.launch { refreshBrandingFromGateway() } scope.launch { refreshWakeWordsFromGateway() } maybeNavigateToA2uiOnConnect() }, @@ -155,6 +160,7 @@ class NodeRuntime(context: Context) { _serverName.value = null _remoteAddress.value = null _isConnected.value = false + _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB chat.onDisconnected(message) 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 { if ( command.startsWith(ClawdisCanvasCommand.NamespacePrefix) || @@ -901,6 +922,8 @@ class NodeRuntime(context: Context) { private data class Quad(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 = """ (() => { @@ -955,3 +978,12 @@ private fun JsonElement?.asStringOrNull(): String? = is JsonPrimitive -> content 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 +} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt index f7681eb49..485014b1b 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt @@ -57,6 +57,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -81,6 +83,8 @@ fun RootScreen(viewModel: MainViewModel) { val isForeground by viewModel.isForeground.collectAsState() val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState() val talkEnabled by viewModel.talkEnabled.collectAsState() + val seamColorArgb by viewModel.seamColorArgb.collectAsState() + val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) } val audioPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> 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. + 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( onClick = { val next = !talkEnabled @@ -238,12 +250,12 @@ fun RootScreen(viewModel: MainViewModel) { viewModel.setTalkEnabled(false) } }, + containerColor = talkContainer, + contentColor = talkContent, icon = { - val tint = if (talkEnabled) MaterialTheme.colorScheme.primary else LocalContentColor.current Icon( Icons.Default.RecordVoiceOver, contentDescription = "Talk Mode", - tint = tint, ) }, ) @@ -278,14 +290,16 @@ private enum class Sheet { private fun OverlayIconButton( onClick: () -> Unit, icon: @Composable () -> Unit, + containerColor: ComposeColor? = null, + contentColor: ComposeColor? = null, ) { FilledTonalIconButton( onClick = onClick, modifier = Modifier.size(44.dp), colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = overlayContainerColor(), - contentColor = overlayIconColor(), + containerColor = containerColor ?: overlayContainerColor(), + contentColor = contentColor ?: overlayIconColor(), ), ) { icon() diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 554441d1f..02cf7c9af 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -22,6 +22,7 @@ final class NodeAppModel { var bridgeServerName: String? var bridgeRemoteAddress: String? var connectedBridgeID: String? + var seamColorHex: String? private let bridge = BridgeSession() private var bridgeTask: Task? @@ -225,6 +226,7 @@ final class NodeAppModel { self.bridgeRemoteAddress = addr } } + await self.refreshBrandingFromGateway() await self.startVoiceWakeSync() await self.showA2UIOnConnectIfNeeded() }, @@ -264,6 +266,7 @@ final class NodeAppModel { self.bridgeServerName = nil self.bridgeRemoteAddress = nil self.connectedBridgeID = nil + self.seamColorHex = nil self.showLocalCanvasOnDisconnect() } } @@ -279,9 +282,42 @@ final class NodeAppModel { self.bridgeServerName = nil self.bridgeRemoteAddress = nil self.connectedBridgeID = nil + self.seamColorHex = nil 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 { let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words) diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 910d96a3d..ab67996a7 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -147,7 +147,9 @@ private struct CanvasContent: View { // Talk mode lives on a side bubble so it doesn't get buried in settings. OverlayButton( 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 self.talkEnabled = next @@ -251,13 +253,15 @@ private struct CanvasContent: View { private struct OverlayButton: View { let systemImage: String let brighten: Bool + var tint: Color? = nil + var isActive: Bool = false let action: () -> Void var body: some View { Button(action: self.action) { Image(systemName: self.systemImage) .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.primary) + .foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary) .padding(10) .background { RoundedRectangle(cornerRadius: 12, style: .continuous) @@ -275,9 +279,26 @@ private struct OverlayButton: View { endPoint: .bottomTrailing)) .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 { 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) } diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index c73383241..65ddaaf90 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -130,6 +130,9 @@ final class AppState { } } + /// Gateway-provided UI accent color (hex). Optional; clients provide a default. + var seamColorHex: String? + var iconOverride: IconOverrideSelection { didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } } } @@ -226,6 +229,7 @@ final class AppState { self.voicePushToTalkEnabled = UserDefaults.standard .object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey) + self.seamColorHex = nil if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool { self.heartbeatsEnabled = storedHeartbeats } else { diff --git a/apps/macos/Sources/Clawdis/ConnectionsStore.swift b/apps/macos/Sources/Clawdis/ConnectionsStore.swift index d89468a2b..0ff9f0bb5 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsStore.swift @@ -294,6 +294,11 @@ final class ConnectionsStore { : nil self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:] 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 self.telegramToken = telegram?["botToken"]?.stringValue ?? "" self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true diff --git a/apps/macos/Sources/Clawdis/TalkModeRuntime.swift b/apps/macos/Sources/Clawdis/TalkModeRuntime.swift index 54804337e..73c26a9e5 100644 --- a/apps/macos/Sources/Clawdis/TalkModeRuntime.swift +++ b/apps/macos/Sources/Clawdis/TalkModeRuntime.swift @@ -537,6 +537,11 @@ actor TalkModeRuntime { params: nil, timeoutMs: 8000) 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 model = talk?["modelId"]?.stringValue let outputFormat = talk?["outputFormat"]?.stringValue diff --git a/apps/macos/Sources/Clawdis/TalkOverlay.swift b/apps/macos/Sources/Clawdis/TalkOverlay.swift index e41d758f7..f36745df0 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlay.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlay.swift @@ -7,6 +7,7 @@ import SwiftUI @Observable final class TalkOverlayController { static let shared = TalkOverlayController() + static let overlaySize: CGFloat = 220 private let logger = Logger(subsystem: "com.steipete.clawdis", category: "talk.overlay") @@ -19,9 +20,6 @@ final class TalkOverlayController { var model = Model() private var window: NSPanel? private var hostingView: NSHostingView? - - private let width: CGFloat = 160 - private let height: CGFloat = 160 private let padding: CGFloat = 8 func present() { @@ -84,7 +82,7 @@ final class TalkOverlayController { private func ensureWindow() { if self.window != nil { return } 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], backing: .buffered, defer: false) @@ -109,7 +107,7 @@ final class TalkOverlayController { private func targetFrame() -> NSRect { 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 origin = CGPoint( x: visible.maxX - size.width - self.padding, diff --git a/apps/macos/Sources/Clawdis/TalkOverlayView.swift b/apps/macos/Sources/Clawdis/TalkOverlayView.swift index d7b400ed3..d502e21ad 100644 --- a/apps/macos/Sources/Clawdis/TalkOverlayView.swift +++ b/apps/macos/Sources/Clawdis/TalkOverlayView.swift @@ -2,40 +2,66 @@ import SwiftUI struct TalkOverlayView: View { var controller: TalkOverlayController - @State private var hovering = false + @State private var appState = AppStateStore.shared + @State private var hoveringWindow = false var body: some View { - ZStack(alignment: .topLeading) { - TalkOrbView(phase: self.controller.model.phase, level: self.controller.model.level) + ZStack { + TalkOrbView( + phase: self.controller.model.phase, + level: self.controller.model.level, + accent: self.seamColor) .frame(width: 96, height: 96) - .contentShape(Rectangle()) + .contentShape(Circle()) .onTapGesture { TalkModeController.shared.stopSpeaking(reason: .userTap) } - .padding(26) - - Button { - TalkModeController.shared.exitTalkMode() - } label: { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Color.white.opacity(self.hovering ? 0.95 : 0.7)) - .frame(width: 18, height: 18) - .background(Color.black.opacity(self.hovering ? 0.45 : 0.3)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .contentShape(Circle()) - .padding(4) - .onHover { self.hovering = $0 } + .overlay(alignment: .topLeading) { + Button { + TalkModeController.shared.exitTalkMode() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(Color.white.opacity(0.95)) + .frame(width: 18, height: 18) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .contentShape(Circle()) + .offset(x: -10, y: -10) + .opacity(self.hoveringWindow ? 1 : 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 { let phase: TalkModePhase let level: Double + let accent: Color var body: some View { 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) .scaleEffect(pulse * listenScale) - TalkWaveRings(phase: phase, level: level, time: t) + TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent) if phase == .thinking { TalkOrbitArcs(time: t) @@ -61,7 +87,7 @@ private struct TalkOrbView: View { private var orbGradient: RadialGradient { RadialGradient( - colors: [Color.white, Color(red: 0.62, green: 0.88, blue: 1.0)], + colors: [Color.white, self.accent], center: .topLeading, startRadius: 4, endRadius: 52) @@ -72,7 +98,7 @@ private struct TalkWaveRings: View { let phase: TalkModePhase let level: Double let time: TimeInterval - private let ringColor = Color(red: 0.82, green: 0.94, blue: 1.0) + let accent: Color var body: some View { ZStack { @@ -83,7 +109,7 @@ private struct TalkWaveRings: View { 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 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) .opacity(alpha - progress * 0.6) } diff --git a/docs/configuration.md b/docs/configuration.md index a49e916f8..ac4b3ccf4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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) Use `gateway.mode` to explicitly declare whether this machine should run the Gateway. diff --git a/src/config/config.ts b/src/config/config.ts index 870ba9621..9d3b23442 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -346,6 +346,10 @@ export type ClawdisConfig = { }; logging?: LoggingConfig; browser?: BrowserConfig; + ui?: { + /** Accent color for Clawdis UI chrome (hex). */ + seamColor?: string; + }; skillsLoad?: SkillsLoadConfig; skillsInstall?: SkillsInstallConfig; models?: ModelsConfig; @@ -494,6 +498,10 @@ const TranscribeAudioSchema = z }) .optional(); +const HexColorSchema = z + .string() + .regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); + const SessionSchema = z .object({ scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), @@ -672,6 +680,11 @@ const ClawdisSchema = z.object({ attachOnly: z.boolean().optional(), }) .optional(), + ui: z + .object({ + seamColor: HexColorSchema.optional(), + }) + .optional(), models: ModelsConfigSchema, agent: z .object({ diff --git a/src/config/ui-seam-color.test.ts b/src/config/ui-seam-color.test.ts new file mode 100644 index 000000000..9865e0e93 --- /dev/null +++ b/src/config/ui-seam-color.test.ts @@ -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); + }); +});