diff --git a/buildSrc/src/main/kotlin/convention.compose.gradle.kts b/buildSrc/src/main/kotlin/convention.compose.gradle.kts
index 19b84202..bad48b00 100644
--- a/buildSrc/src/main/kotlin/convention.compose.gradle.kts
+++ b/buildSrc/src/main/kotlin/convention.compose.gradle.kts
@@ -7,6 +7,7 @@ plugins {
dependencies {
"implementation"(androidx.compose.foundation)
"implementation"(androidx.compose.material)
+ "implementation"(androidx.compose.material3)
"implementation"(androidx.compose.ui)
"implementation"(androidx.compose.runtime)
"implementation"(androidx.lifecycle.viewmodel.compose)
diff --git a/buildSrc/src/main/kotlin/convention.debug.panel.plugin.gradle.kts b/buildSrc/src/main/kotlin/convention.debug.panel.plugin.gradle.kts
index 785292aa..167109f4 100644
--- a/buildSrc/src/main/kotlin/convention.debug.panel.plugin.gradle.kts
+++ b/buildSrc/src/main/kotlin/convention.debug.panel.plugin.gradle.kts
@@ -34,4 +34,5 @@ kotlin {
dependencies {
implementation(project(":panel-core"))
+ implementation(project(":panel-ui-kit"))
}
diff --git a/panel-core/build.gradle.kts b/panel-core/build.gradle.kts
index 67851357..8bbb4de4 100644
--- a/panel-core/build.gradle.kts
+++ b/panel-core/build.gradle.kts
@@ -43,6 +43,8 @@ kotlin {
}
dependencies {
+ implementation(project(":panel-ui-kit"))
+
implementation(androidx.activity.compose)
implementation(androidx.navigation.compose)
implementation(androidx.core)
diff --git a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt
index 197d24bb..66e35ffa 100644
--- a/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt
+++ b/panel-core/src/main/kotlin/com/redmadrobot/debug/core/ui/debugpanel/DebugPanelActivity.kt
@@ -3,14 +3,17 @@ package com.redmadrobot.debug.core.ui.debugpanel
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
-import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.remember
import com.redmadrobot.debug.core.inapp.compose.DebugPanelScreen
+import com.redmadrobot.debug.uikit.theme.DebugPanelTheme
+import com.redmadrobot.debug.uikit.theme.ThemeState
internal class DebugPanelActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
- MaterialTheme {
+ val themeState = remember { ThemeState() }
+ DebugPanelTheme(themeState = themeState) {
DebugPanelScreen(onClose = { finish() })
}
}
diff --git a/panel-ui-kit/build.gradle.kts b/panel-ui-kit/build.gradle.kts
new file mode 100644
index 00000000..25c65ef8
--- /dev/null
+++ b/panel-ui-kit/build.gradle.kts
@@ -0,0 +1,45 @@
+plugins {
+ id("com.android.library")
+ id("convention.compose")
+ id("convention-publish")
+ id("convention.detekt")
+}
+
+description = "Debug panel UI kit: theme, design tokens, shared components"
+
+android {
+ compileSdk = Project.COMPILE_SDK
+ lint.targetSdk = Project.TARGET_SDK
+
+ defaultConfig {
+ minSdk = Project.MIN_SDK
+
+ consumerProguardFile("consumer-rules.pro")
+ }
+
+ buildTypes {
+ getByName(Project.BuildTypes.release) {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile(Project.Proguard.androidOptimizedRules),
+ Project.Proguard.projectRules
+ )
+ }
+ }
+
+ kotlin {
+ jvmToolchain(17)
+ }
+
+ namespace = "com.redmadrobot.debug.uikit"
+}
+
+kotlin {
+ explicitApi()
+}
+
+dependencies {
+ implementation(androidx.compose.material3)
+ implementation(androidx.compose.ui.tooling)
+ implementation(androidx.compose.ui.tooling.preview)
+}
diff --git a/panel-ui-kit/consumer-rules.pro b/panel-ui-kit/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/panel-ui-kit/proguard-rules.pro b/panel-ui-kit/proguard-rules.pro
new file mode 100644
index 00000000..fb164d66
--- /dev/null
+++ b/panel-ui-kit/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/panel-ui-kit/src/main/AndroidManifest.xml b/panel-ui-kit/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8072ee00
--- /dev/null
+++ b/panel-ui-kit/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/AnimatedColors.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/AnimatedColors.kt
new file mode 100644
index 00000000..1a1ad24f
--- /dev/null
+++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/AnimatedColors.kt
@@ -0,0 +1,81 @@
+package com.redmadrobot.debug.uikit.theme
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.Color
+
+private val ColorAnimSpec = spring(stiffness = 600f)
+
+@Composable
+internal fun DebugPanelColors.animated(): DebugPanelColors = DebugPanelColors(
+ background = background.animated(),
+ button = button.animated(),
+ content = content.animated(),
+ stroke = stroke.animated(),
+ surface = surface.animated(),
+ source = source.animated(),
+)
+
+@Composable
+private fun animateColor(target: Color): Color {
+ val animated by animateColorAsState(
+ targetValue = target,
+ animationSpec = ColorAnimSpec,
+ label = "",
+ )
+ return animated
+}
+
+@Composable
+private fun BackgroundColors.animated(): BackgroundColors = BackgroundColors(
+ primary = animateColor(primary),
+ secondary = animateColor(secondary),
+ tertiary = animateColor(tertiary),
+)
+
+@Composable
+private fun ButtonColors.animated(): ButtonColors = ButtonColors(
+ primary = animateColor(primary),
+ onPrimary = animateColor(onPrimary),
+ secondary = animateColor(secondary),
+ onSecondary = animateColor(onSecondary),
+ error = animateColor(error),
+ onError = animateColor(onError),
+)
+
+@Composable
+private fun ContentColors.animated(): ContentColors = ContentColors(
+ primary = animateColor(primary),
+ secondary = animateColor(secondary),
+ tertiary = animateColor(tertiary),
+ accent = animateColor(accent),
+ error = animateColor(error),
+ teal = animateColor(teal),
+)
+
+@Composable
+private fun StrokeColors.animated(): StrokeColors = StrokeColors(
+ primary = animateColor(primary),
+ secondary = animateColor(secondary),
+)
+
+@Composable
+private fun SurfaceColors.animated(): SurfaceColors = SurfaceColors(
+ primary = animateColor(primary),
+ secondary = animateColor(secondary),
+ tertiary = animateColor(tertiary),
+ dialog = animateColor(dialog),
+ selected = animateColor(selected),
+)
+
+@Composable
+private fun SourceColors.animated(): SourceColors = SourceColors(
+ defaultText = animateColor(defaultText),
+ defaultBackground = animateColor(defaultBackground),
+ debugText = animateColor(debugText),
+ debugBackground = animateColor(debugBackground),
+ remoteText = animateColor(remoteText),
+ remoteBackground = animateColor(remoteBackground),
+)
diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/BaseColors.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/BaseColors.kt
new file mode 100644
index 00000000..3baf438b
--- /dev/null
+++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/BaseColors.kt
@@ -0,0 +1,187 @@
+@file:Suppress("MagicNumber")
+
+package com.redmadrobot.debug.uikit.theme
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+internal object BaseColors {
+ // Purple
+ val Purple10 = Color(0xFF21005D)
+ val Purple20 = Color(0xFF381E72)
+ val Purple30 = Color(0xFF4A4458)
+ val Purple40 = Color(0xFF6750A4)
+ val Purple80 = Color(0xFFD0BCFF)
+ val Purple90 = Color(0xFFE8DEF8)
+ val Purple95 = Color(0xFFF3EDF7)
+ val Purple99 = Color(0xFFFEF7FF)
+
+ // Neutral
+ val Neutral10 = Color(0xFF1C1B1F)
+ val Neutral20 = Color(0xFF313033)
+ val Neutral30 = Color(0xFF49454F)
+ val Neutral40 = Color(0xFF605D66)
+ val Neutral50 = Color(0xFF79747E)
+ val Neutral60 = Color(0xFF938F99)
+ val Neutral80 = Color(0xFFCAC4D0)
+ val Neutral87 = Color(0xFFE6E1E5)
+ val Neutral90 = Color(0xFFE6E0E9)
+ val Neutral92 = Color(0xFFECE6F0)
+ val Neutral94 = Color(0xFFF4EFF4)
+ val Neutral95 = Color(0xFFE7E0EC)
+ val Neutral99 = Color(0xFFFFFBFE)
+
+ // Neutral variant (dark surfaces)
+ val NeutralVariant20 = Color(0xFF2B2930)
+ val NeutralVariant30 = Color(0xFF36343B)
+ val NeutralVariant40 = Color(0xFF414046)
+
+ // Secondary
+ val Secondary40 = Color(0xFF625B71)
+ val Secondary80 = Color(0xFFCCC2DC)
+ val Secondary90 = Color(0xFFE8DEF8)
+
+ // Tertiary
+ val Tertiary20 = Color(0xFF492532)
+ val Tertiary30 = Color(0xFF633B48)
+ val Tertiary40 = Color(0xFF7D5260)
+ val Tertiary80 = Color(0xFFEFB8C8)
+ val Tertiary90 = Color(0xFFFFD8E4)
+
+ // Error
+ val Error20 = Color(0xFF601410)
+ val Error30 = Color(0xFF8C1D18)
+ val Error40 = Color(0xFFB3261E)
+ val Error80 = Color(0xFFF2B8B5)
+ val Error90 = Color(0xFFF9DEDC)
+
+ // Status / accent
+ val Teal = Color(0xFF03DAC6)
+ val Green = Color(0xFF1B6E2D)
+ val GreenLight = Color(0xFFD6F5E0)
+ val GreenDark = Color(0xFF1B3D2A)
+ val GreenDarkText = Color(0xFF7DD99E)
+ val Orange = Color(0xFFE65100)
+ val OrangeLight = Color(0xFFFFF3E0)
+ val OrangeDark = Color(0xFF3D2E10)
+ val OrangeDarkText = Color(0xFFFFB74D)
+
+ val White = Color(0xFFFFFFFF)
+ val Black = Color(0xFF000000)
+}
+
+@Suppress("LongMethod")
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+@Preview(showBackground = true)
+private fun Preview() {
+ DebugPanelTheme {
+ FlowRow(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(state = rememberScrollState())
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(space = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(space = 4.dp),
+ ) {
+ // Purple
+ ColorElement(BaseColors.Purple10, "Purple10")
+ ColorElement(BaseColors.Purple20, "Purple20")
+ ColorElement(BaseColors.Purple30, "Purple30")
+ ColorElement(BaseColors.Purple40, "Purple40")
+ ColorElement(BaseColors.Purple80, "Purple80")
+ ColorElement(BaseColors.Purple90, "Purple90")
+ ColorElement(BaseColors.Purple95, "Purple95")
+ ColorElement(BaseColors.Purple99, "Purple99")
+
+ // Neutral
+ ColorElement(BaseColors.Neutral10, "Neutral10")
+ ColorElement(BaseColors.Neutral20, "Neutral20")
+ ColorElement(BaseColors.Neutral30, "Neutral30")
+ ColorElement(BaseColors.Neutral40, "Neutral40")
+ ColorElement(BaseColors.Neutral50, "Neutral50")
+ ColorElement(BaseColors.Neutral60, "Neutral60")
+ ColorElement(BaseColors.Neutral80, "Neutral80")
+ ColorElement(BaseColors.Neutral87, "Neutral87")
+ ColorElement(BaseColors.Neutral90, "Neutral90")
+ ColorElement(BaseColors.Neutral92, "Neutral92")
+ ColorElement(BaseColors.Neutral94, "Neutral94")
+ ColorElement(BaseColors.Neutral95, "Neutral95")
+ ColorElement(BaseColors.Neutral99, "Neutral99")
+
+ // Neutral variant
+ ColorElement(BaseColors.NeutralVariant20, "NV20")
+ ColorElement(BaseColors.NeutralVariant30, "NV30")
+ ColorElement(BaseColors.NeutralVariant40, "NV40")
+
+ // Secondary
+ ColorElement(BaseColors.Secondary40, "Secondary40")
+ ColorElement(BaseColors.Secondary80, "Secondary80")
+ ColorElement(BaseColors.Secondary90, "Secondary90")
+
+ // Tertiary
+ ColorElement(BaseColors.Tertiary20, "Tertiary20")
+ ColorElement(BaseColors.Tertiary30, "Tertiary30")
+ ColorElement(BaseColors.Tertiary40, "Tertiary40")
+ ColorElement(BaseColors.Tertiary80, "Tertiary80")
+ ColorElement(BaseColors.Tertiary90, "Tertiary90")
+
+ // Error
+ ColorElement(BaseColors.Error20, "Error20")
+ ColorElement(BaseColors.Error30, "Error30")
+ ColorElement(BaseColors.Error40, "Error40")
+ ColorElement(BaseColors.Error80, "Error80")
+ ColorElement(BaseColors.Error90, "Error90")
+
+ // Status / accent
+ ColorElement(BaseColors.Teal, "Teal")
+ ColorElement(BaseColors.Green, "Green")
+ ColorElement(BaseColors.GreenLight, "GreenLight")
+ ColorElement(BaseColors.GreenDark, "GreenDark")
+ ColorElement(BaseColors.GreenDarkText, "GreenDkTxt")
+ ColorElement(BaseColors.Orange, "Orange")
+ ColorElement(BaseColors.OrangeLight, "OrangeLight")
+ ColorElement(BaseColors.OrangeDark, "OrangeDark")
+ ColorElement(BaseColors.OrangeDarkText, "OrangeDkTxt")
+
+ // Black & White
+ ColorElement(BaseColors.White, "White")
+ ColorElement(BaseColors.Black, "Black")
+ }
+ }
+}
+
+@Composable
+private fun ColorElement(color: Color, label: String, modifier: Modifier = Modifier) {
+ val textColor = if (color.luminance() > 0.5f) Color.Black else Color.White
+
+ Box(
+ modifier = modifier
+ .size(size = 100.dp)
+ .background(color = color),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ color = textColor,
+ fontSize = 10.sp,
+ )
+ }
+}
diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColorPresets.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColorPresets.kt
new file mode 100644
index 00000000..c3d10e98
--- /dev/null
+++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColorPresets.kt
@@ -0,0 +1,46 @@
+package com.redmadrobot.debug.uikit.theme
+
+internal val LightDebugPanelColors = DebugPanelColors()
+
+internal val DarkDebugPanelColors = DebugPanelColors(
+ background = BackgroundColors(
+ primary = BaseColors.Neutral10,
+ secondary = BaseColors.NeutralVariant20,
+ tertiary = BaseColors.Purple30,
+ ),
+ button = ButtonColors(
+ primary = BaseColors.Purple80,
+ onPrimary = BaseColors.Purple20,
+ secondary = BaseColors.Purple30,
+ onSecondary = BaseColors.Purple80,
+ error = BaseColors.Error80,
+ onError = BaseColors.Error20,
+ ),
+ content = ContentColors(
+ primary = BaseColors.Neutral87,
+ secondary = BaseColors.Neutral80,
+ tertiary = BaseColors.Neutral50,
+ accent = BaseColors.Purple80,
+ error = BaseColors.Error80,
+ teal = BaseColors.Teal,
+ ),
+ stroke = StrokeColors(
+ primary = BaseColors.Neutral30,
+ secondary = BaseColors.Neutral40,
+ ),
+ surface = SurfaceColors(
+ primary = BaseColors.Neutral10,
+ secondary = BaseColors.NeutralVariant20,
+ tertiary = BaseColors.Purple30,
+ dialog = BaseColors.NeutralVariant20,
+ selected = BaseColors.Purple30,
+ ),
+ source = SourceColors(
+ defaultText = BaseColors.Neutral80,
+ defaultBackground = BaseColors.Neutral30,
+ debugText = BaseColors.GreenDarkText,
+ debugBackground = BaseColors.GreenDark,
+ remoteText = BaseColors.OrangeDarkText,
+ remoteBackground = BaseColors.OrangeDark,
+ ),
+)
diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColors.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColors.kt
new file mode 100644
index 00000000..bfd21cc7
--- /dev/null
+++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/DebugPanelColors.kt
@@ -0,0 +1,66 @@
+package com.redmadrobot.debug.uikit.theme
+
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.graphics.Color
+
+@Stable
+public data class BackgroundColors(
+ val primary: Color = BaseColors.Purple99,
+ val secondary: Color = BaseColors.Purple95,
+ val tertiary: Color = BaseColors.Purple90,
+)
+
+@Stable
+public data class ButtonColors(
+ val primary: Color = BaseColors.Purple40,
+ val onPrimary: Color = BaseColors.White,
+ val secondary: Color = BaseColors.Purple90,
+ val onSecondary: Color = BaseColors.Purple40,
+ val error: Color = BaseColors.Error40,
+ val onError: Color = BaseColors.White,
+)
+
+@Stable
+public data class ContentColors(
+ val primary: Color = BaseColors.Neutral10,
+ val secondary: Color = BaseColors.Neutral30,
+ val tertiary: Color = BaseColors.Neutral50,
+ val accent: Color = BaseColors.Purple40,
+ val error: Color = BaseColors.Error40,
+ val teal: Color = BaseColors.Teal,
+)
+
+@Stable
+public data class StrokeColors(
+ val primary: Color = BaseColors.Neutral95,
+ val secondary: Color = BaseColors.Neutral80,
+)
+
+@Stable
+public data class SurfaceColors(
+ val primary: Color = BaseColors.Purple99,
+ val secondary: Color = BaseColors.Purple95,
+ val tertiary: Color = BaseColors.Purple90,
+ val dialog: Color = BaseColors.Neutral99,
+ val selected: Color = BaseColors.Purple90,
+)
+
+@Stable
+public data class SourceColors(
+ val defaultText: Color = BaseColors.Neutral50,
+ val defaultBackground: Color = BaseColors.Neutral95,
+ val debugText: Color = BaseColors.Green,
+ val debugBackground: Color = BaseColors.GreenLight,
+ val remoteText: Color = BaseColors.Orange,
+ val remoteBackground: Color = BaseColors.OrangeLight,
+)
+
+@Stable
+public data class DebugPanelColors(
+ val background: BackgroundColors = BackgroundColors(),
+ val button: ButtonColors = ButtonColors(),
+ val content: ContentColors = ContentColors(),
+ val stroke: StrokeColors = StrokeColors(),
+ val surface: SurfaceColors = SurfaceColors(),
+ val source: SourceColors = SourceColors(),
+)
diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Theme.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Theme.kt
new file mode 100644
index 00000000..47cde525
--- /dev/null
+++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Theme.kt
@@ -0,0 +1,154 @@
+package com.redmadrobot.debug.uikit.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+
+/**
+ * Theme mode selectable at runtime from debug panel settings.
+ */
+public enum class ThemeMode {
+ /** Follow system dark/light setting. */
+ System,
+
+ /** Always light. */
+ Light,
+
+ /** Always dark. */
+ Dark,
+}
+
+/**
+ * Holds the currently selected [ThemeMode].
+ *
+ * Mutate [themeMode] from settings UI — the composable tree will recompose automatically.
+ */
+@Stable
+public class ThemeState(initialMode: ThemeMode = ThemeMode.System) {
+ public var themeMode: ThemeMode by mutableStateOf(initialMode)
+}
+
+internal val LocalColors = compositionLocalOf { LightDebugPanelColors }
+internal val LocalTypography = compositionLocalOf { DebugPanelTypographyTokens() }
+internal val LocalThemeState = compositionLocalOf { ThemeState() }
+
+/**
+ * Entry point for accessing Debug Panel design tokens.
+ *
+ * Usage:
+ * ```
+ * color = DebugPanelTheme.colors.background.primary
+ * style = DebugPanelTheme.typography.bodySmall
+ * mode = DebugPanelTheme.themeState.themeMode
+ * ```
+ */
+public object DebugPanelTheme {
+ public val colors: DebugPanelColors
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalColors.current
+
+ public val typography: DebugPanelTypographyTokens
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalTypography.current
+
+ public val themeState: ThemeState
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalThemeState.current
+}
+
+/**
+ * Debug Panel theme.
+ *
+ * Provides [DebugPanelColors] and [DebugPanelTypographyTokens] via [CompositionLocal],
+ * and configures Material 3 [ColorScheme] for standard M3 components.
+ *
+ * When [themeState]`.themeMode` changes at runtime (e.g. from settings),
+ * the entire UI recomposes with animated color transitions.
+ *
+ * @param themeState holds the runtime-mutable [ThemeMode]. Shared across the panel so that
+ * settings can write to it and all screens react.
+ * @param dynamicColor whether to use Material You dynamic colors on supported devices.
+ * @param content the composable content to be themed.
+ */
+@Composable
+public fun DebugPanelTheme(
+ themeState: ThemeState = ThemeState(),
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ val systemDark = isSystemInDarkTheme()
+ val darkTheme = when (themeState.themeMode) {
+ ThemeMode.System -> systemDark
+ ThemeMode.Light -> false
+ ThemeMode.Dark -> true
+ }
+
+ val targetColors = if (darkTheme) DarkDebugPanelColors else LightDebugPanelColors
+ val panelColors = targetColors.animated()
+ val panelTypography = if (darkTheme) DarkDebugPanelTypographyTokens else DebugPanelTypographyTokens()
+
+ val materialColorScheme = if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ } else {
+ panelColors.toMaterialColorScheme()
+ }
+
+ CompositionLocalProvider(
+ LocalColors provides panelColors,
+ LocalTypography provides panelTypography,
+ LocalThemeState provides themeState,
+ ) {
+ MaterialTheme(
+ colorScheme = materialColorScheme,
+ typography = panelTypography.toMaterialTypography(),
+ content = content,
+ )
+ }
+}
+
+internal fun DebugPanelColors.toMaterialColorScheme(): ColorScheme = lightColorScheme(
+ primary = button.primary,
+ onPrimary = button.onPrimary,
+ primaryContainer = surface.tertiary,
+ onPrimaryContainer = content.primary,
+ secondary = content.secondary,
+ onSecondary = button.onPrimary,
+ secondaryContainer = button.secondary,
+ onSecondaryContainer = content.primary,
+ tertiary = content.tertiary,
+ onTertiary = button.onPrimary,
+ error = content.error,
+ onError = button.onError,
+ errorContainer = BaseColors.Error90,
+ onErrorContainer = BaseColors.Error20,
+ background = background.primary,
+ onBackground = content.primary,
+ surface = surface.primary,
+ onSurface = content.primary,
+ surfaceVariant = stroke.primary,
+ onSurfaceVariant = content.secondary,
+ outline = content.tertiary,
+ outlineVariant = stroke.secondary,
+ surfaceContainer = surface.secondary,
+ surfaceContainerHigh = surface.tertiary,
+ inverseSurface = content.primary,
+ inverseOnSurface = background.primary,
+ inversePrimary = content.accent,
+)
diff --git a/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Typography.kt b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Typography.kt
new file mode 100644
index 00000000..2b574250
--- /dev/null
+++ b/panel-ui-kit/src/main/kotlin/com/redmadrobot/debug/uikit/theme/Typography.kt
@@ -0,0 +1,102 @@
+package com.redmadrobot.debug.uikit.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+/** Monospace font family for technical values (URLs, config keys, code). */
+public val MonoFontFamily: FontFamily = FontFamily.Monospace
+
+@Stable
+public data class DebugPanelTypographyTokens(
+ /** TopAppBar title. */
+ val titleLarge: TextStyle = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.sp,
+ ),
+ /** Section headers, group names. */
+ val titleMedium: TextStyle = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ /** Card titles, server names. */
+ val titleSmall: TextStyle = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ /** Primary body text. */
+ val bodyLarge: TextStyle = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp,
+ ),
+ /** Secondary body text, config values. */
+ val bodyMedium: TextStyle = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 13.sp,
+ lineHeight = 18.sp,
+ letterSpacing = 0.25.sp,
+ ),
+ /** Technical values (URLs, keys) — compact monospace. */
+ val bodySmall: TextStyle = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ fontFamily = MonoFontFamily,
+ ),
+ /** Tab labels, chip text. */
+ val labelLarge: TextStyle = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ /** Badges, source indicators. */
+ val labelMedium: TextStyle = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp,
+ ),
+ /** Section headers (uppercase), overline text. */
+ val labelSmall: TextStyle = TextStyle(
+ fontWeight = FontWeight.Medium,
+ fontSize = 10.sp,
+ lineHeight = 14.sp,
+ letterSpacing = 1.sp,
+ ),
+)
+
+internal val DarkDebugPanelTypographyTokens = DebugPanelTypographyTokens(
+ bodySmall = TextStyle(
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp,
+ fontFamily = MonoFontFamily,
+ color = BaseColors.Neutral80,
+ ),
+)
+
+internal fun DebugPanelTypographyTokens.toMaterialTypography(): Typography = Typography(
+ titleLarge = titleLarge,
+ titleMedium = titleMedium,
+ titleSmall = titleSmall,
+ bodyLarge = bodyLarge,
+ bodyMedium = bodyMedium,
+ bodySmall = bodySmall,
+ labelLarge = labelLarge,
+ labelMedium = labelMedium,
+ labelSmall = labelSmall,
+)
diff --git a/settings.gradle.kts b/settings.gradle.kts
index e6cd6d55..25282f3c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -47,7 +47,8 @@ dependencyResolutionManagement {
// Base modules
include(
":panel-no-op",
- ":panel-core"
+ ":panel-core",
+ ":panel-ui-kit"
)
// Plugins