From fe59b68c37ded7fc409ba369ec5234413cf8fb2c Mon Sep 17 00:00:00 2001
From: Gabriel Kaszewski
Date: Fri, 19 Jun 2026 03:26:18 +0200
Subject: [PATCH] theme config, layout preview, container alignment
Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence,
ThemeUpdate broadcast to ESP32 on save and initial connect.
Client: render engine uses theme colors, full-screen redraw on theme change.
SPA: theme page with color pickers + presets, layout preview with TS port
of layout engine, justify/align controls on containers.
DisplayHint refactored to struct (kind + h_align + v_align).
---
crates/adapters/config-memory/src/lib.rs | 23 +-
crates/adapters/config-sqlite/src/lib.rs | 9 +
.../config-sqlite/src/repository/mod.rs | 13 +-
.../config-sqlite/src/repository/theme.rs | 37 +++
.../config-sqlite/src/serialization/layout.rs | 35 ++-
.../config-sqlite/src/serialization/mod.rs | 1 +
.../config-sqlite/src/serialization/theme.rs | 40 +++
.../config-sqlite/tests/config_store_tests.rs | 4 +-
crates/adapters/http-api/src/routes/mod.rs | 6 +
crates/adapters/http-api/src/routes/theme.rs | 46 +++
crates/adapters/tcp-server/src/broadcaster.rs | 26 +-
crates/adapters/tcp-server/src/server.rs | 20 +-
crates/api-types/src/layout.rs | 43 ++-
crates/api-types/src/lib.rs | 2 +
crates/api-types/src/theme.rs | 52 ++++
crates/application/src/config_service.rs | 17 +-
.../application/tests/config_service_tests.rs | 4 +-
crates/application/tests/support/mod.rs | 13 +-
crates/bootstrap/src/event_handler.rs | 6 +
crates/client-application/src/client_app.rs | 2 +-
crates/client-desktop/src/main.rs | 10 +-
crates/client-domain/src/lib.rs | 6 +-
crates/client-domain/src/markup.rs | 10 +-
crates/client-domain/src/render_engine.rs | 69 ++---
crates/client-domain/src/text_layout.rs | 17 +-
.../tests/layout_engine_tests.rs | 49 +++-
crates/client-domain/tests/markup_tests.rs | 93 ++++--
.../tests/render_engine_tests.rs | 4 +-
.../client-domain/tests/render_tree_tests.rs | 4 +-
crates/client-esp32/src/tasks/render.rs | 5 +-
crates/domain/src/events/mod.rs | 3 +-
crates/domain/src/lib.rs | 4 +-
crates/domain/src/ports/broadcast.rs | 7 +-
crates/domain/src/ports/config_repository.rs | 8 +-
crates/domain/src/value_objects/mod.rs | 2 +
crates/domain/src/value_objects/theme.rs | 47 +++
crates/protocol/src/lib.rs | 2 +-
crates/protocol/tests/round_trip_tests.rs | 4 +-
spa/src/api/theme.ts | 22 ++
spa/src/api/types.ts | 18 ++
spa/src/components/app-shell.tsx | 2 +
spa/src/components/layout-preview.tsx | 103 +++++++
spa/src/lib/layout-engine.ts | 93 ++++++
spa/src/pages/layout-builder.tsx | 129 +++++++-
spa/src/pages/theme.tsx | 276 ++++++++++++++++++
spa/src/router.tsx | 8 +
46 files changed, 1276 insertions(+), 118 deletions(-)
create mode 100644 crates/adapters/config-sqlite/src/repository/theme.rs
create mode 100644 crates/adapters/config-sqlite/src/serialization/theme.rs
create mode 100644 crates/adapters/http-api/src/routes/theme.rs
create mode 100644 crates/api-types/src/theme.rs
create mode 100644 crates/domain/src/value_objects/theme.rs
create mode 100644 spa/src/api/theme.ts
create mode 100644 spa/src/components/layout-preview.tsx
create mode 100644 spa/src/lib/layout-engine.ts
create mode 100644 spa/src/pages/theme.tsx
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,
]),