diff --git a/crates/adapters/config-memory/src/lib.rs b/crates/adapters/config-memory/src/lib.rs
index 53735bb..b014d67 100644
--- a/crates/adapters/config-memory/src/lib.rs
+++ b/crates/adapters/config-memory/src/lib.rs
@@ -1,6 +1,6 @@
use domain::{
- ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
- WidgetConfig, WidgetId,
+ ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
+ User, WidgetConfig, WidgetId,
};
use std::collections::HashMap;
use std::sync::RwLock;
@@ -15,6 +15,7 @@ pub struct MemoryConfigStore {
widgets: RwLock>,
data_sources: RwLock>,
layout: RwLock
-
+
+
+
+
@@ -328,6 +349,65 @@ export function LayoutBuilderPage() {
+
+ {showPreview && root && theme && (
+
+
+ Layout Preview
+
+
+
+
+
+ setScreenWidth(Number(e.target.value))}
+ className="w-20"
+ min={1}
+ />
+
+
x
+
+
+ setScreenHeight(Number(e.target.value))}
+ className="w-20"
+ min={1}
+ />
+
+
+ {([
+ [320, 240],
+ [240, 135],
+ [128, 64],
+ ] as const).map(([w, h]) => (
+
+ ))}
+
+
+
+
+
+ )}
)
}
@@ -420,7 +500,7 @@ function ContainerProps({
}: {
node: LayoutNode
path: Path
- onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction", value: number | string) => void
+ onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
onAddWidget: (path: Path, widgetId: number) => void
onAddContainer: (path: Path, direction: Direction) => void
onRemove: () => void
@@ -469,6 +549,43 @@ function ContainerProps({
/>
+
+
+
+
+
+
+
+
+
+
{!isRoot && }
diff --git a/spa/src/pages/theme.tsx b/spa/src/pages/theme.tsx
new file mode 100644
index 0000000..c666d6b
--- /dev/null
+++ b/spa/src/pages/theme.tsx
@@ -0,0 +1,276 @@
+import { useState, useEffect } from "react"
+import { useTheme, useUpdateTheme } from "@/api/theme"
+import type { ThemeColor, ThemeConfig } from "@/api/types"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Badge } from "@/components/ui/badge"
+import { Save } from "lucide-react"
+import { toast } from "sonner"
+
+function colorToHex(c: ThemeColor): string {
+ const r = Math.min(255, Math.max(0, c.r)).toString(16).padStart(2, "0")
+ const g = Math.min(255, Math.max(0, c.g)).toString(16).padStart(2, "0")
+ const b = Math.min(255, Math.max(0, c.b)).toString(16).padStart(2, "0")
+ return `#${r}${g}${b}`
+}
+
+function hexToColor(hex: string): ThemeColor | null {
+ const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
+ if (!m) return null
+ return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) }
+}
+
+const PRESETS: Record
= {
+ "Default Dark": {
+ primary: { r: 0, g: 122, b: 204 },
+ secondary: { r: 136, g: 136, b: 136 },
+ accent: { r: 233, g: 69, b: 96 },
+ text: { r: 255, g: 255, b: 255 },
+ background: { r: 0, g: 0, b: 0 },
+ },
+ Light: {
+ primary: { r: 0, g: 102, b: 170 },
+ secondary: { r: 102, g: 102, b: 102 },
+ accent: { r: 214, g: 59, b: 80 },
+ text: { r: 26, g: 26, b: 26 },
+ background: { r: 245, g: 245, b: 245 },
+ },
+ "Solarized Dark": {
+ primary: { r: 38, g: 139, b: 210 },
+ secondary: { r: 131, g: 148, b: 150 },
+ accent: { r: 203, g: 75, b: 22 },
+ text: { r: 147, g: 161, b: 161 },
+ background: { r: 0, g: 43, b: 54 },
+ },
+ Nord: {
+ primary: { r: 136, g: 192, b: 208 },
+ secondary: { r: 129, g: 161, b: 193 },
+ accent: { r: 191, g: 97, b: 106 },
+ text: { r: 236, g: 239, b: 244 },
+ background: { r: 46, g: 52, b: 64 },
+ },
+}
+
+const COLOR_FIELDS: (keyof ThemeConfig)[] = [
+ "primary",
+ "secondary",
+ "accent",
+ "text",
+ "background",
+]
+
+function ColorPicker({
+ label,
+ color,
+ onChange,
+}: {
+ label: string
+ color: ThemeColor
+ onChange: (c: ThemeColor) => void
+}) {
+ const hex = colorToHex(color)
+ return (
+
+ )
+}
+
+export function ThemePage() {
+ const { data: serverTheme, isLoading } = useTheme()
+ const updateTheme = useUpdateTheme()
+ const [theme, setTheme] = useState(null)
+ const [initialized, setInitialized] = useState(false)
+
+ useEffect(() => {
+ if (!initialized && serverTheme) {
+ setTheme(structuredClone(serverTheme))
+ setInitialized(true)
+ }
+ }, [serverTheme, initialized])
+
+ function setColor(field: keyof ThemeConfig, c: ThemeColor) {
+ setTheme((t) => (t ? { ...t, [field]: c } : t))
+ }
+
+ function applyPreset(name: string) {
+ setTheme(structuredClone(PRESETS[name]))
+ }
+
+ async function save() {
+ if (!theme) return
+ try {
+ await updateTheme.mutateAsync(theme)
+ toast.success("Theme saved & pushed to clients")
+ } catch (e) {
+ toast.error(String(e))
+ }
+ }
+
+ if (isLoading) return Loading...
+
+ if (!theme) {
+ return (
+
+
+
Theme
+
Configure display colors
+
+
+
+ No theme configured. Pick a preset to start:
+
+ {Object.keys(PRESETS).map((name) => (
+
+ ))}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Theme
+
Configure display colors
+
+
+
+
+
+ {/* Color pickers */}
+
+
+ Colors
+
+
+ {COLOR_FIELDS.map((field) => (
+ setColor(field, c)}
+ />
+ ))}
+
+
+
+ {/* Right column: presets + preview */}
+
+
+
+ Presets
+
+
+ {Object.keys(PRESETS).map((name) => (
+
+ ))}
+
+
+
+
+
+ Preview
+
+
+ {/* Color swatches */}
+
+ {COLOR_FIELDS.map((field) => (
+
+ ))}
+
+
+ {/* Sample display */}
+
+
+ Primary Heading
+
+
+ Sample text content displayed on the device.
+
+
+
+ Accent
+
+
+ Secondary
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/spa/src/router.tsx b/spa/src/router.tsx
index d21c1cb..201b72f 100644
--- a/spa/src/router.tsx
+++ b/spa/src/router.tsx
@@ -10,6 +10,7 @@ import { DashboardPage } from "@/pages/dashboard"
import { DataSourcesPage } from "@/pages/data-sources"
import { WidgetsPage } from "@/pages/widgets"
import { LayoutBuilderPage } from "@/pages/layout-builder"
+import { ThemePage } from "@/pages/theme"
import { PresetsPage } from "@/pages/presets"
import { GuidePage } from "@/pages/guide"
import { LoginPage } from "@/pages/login"
@@ -72,6 +73,12 @@ const layoutRoute = createRoute({
component: LayoutBuilderPage,
})
+const themeRoute = createRoute({
+ getParentRoute: () => authenticatedRoute,
+ path: "/theme",
+ component: ThemePage,
+})
+
const presetsRoute = createRoute({
getParentRoute: () => authenticatedRoute,
path: "/presets",
@@ -91,6 +98,7 @@ const routeTree = rootRoute.addChildren([
dataSourcesRoute,
widgetsRoute,
layoutRoute,
+ themeRoute,
presetsRoute,
guideRoute,
]),