You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue ships two small, file-backed features on top of the CSS-token system that already exists in packages/ui/src/app.css:
Branding override — product name, logo, and a bounded brand color set, user-owned and runtime-applied.
Configurable chat avatar (2D) — one shipped default animated avatar, swappable by config.
Explicitly OUT of scope (split to other tracks):
3D VRM / Three.js / Rhubarb lip-sync talking avatar → belongs on its own issue in the voice/avatar track, behind an opt-in lazy-loaded flag, with its own bundle + security review. Do not smuggle it in here.
Lottie avatars, remote/CDN avatar URLs, user-uploaded SVG, multiple named themes / theme marketplace, per-component theme overrides, and the assistant OpenCode TUI theme (config/assistant/themes/openpalm.json — unrelated, leave it alone).
Net new runtime dependencies: zero.
Problem / Motivation
The operator console is hardcoded to OpenPalm branding (logo, accent color, "OpenPalm" strings) in Navbar.svelte, AuthGate.svelte, and setup/+page.svelte. An operator running their own install cannot change the name, logo, or accent color, and the chat UI has no avatar (roles render as plain text labels in ChatMessage.svelte). The existing CSS custom-property system in app.css already cascades a full palette — the only missing piece is a runtime override mechanism for a handful of brand anchors plus a couple of branding strings/assets.
Design
Config file (user-owned, file-assembly)
One whole-file JSON config, seeded-if-missing and non-destructive like the rest of config/:
saveBrandingAsset(...) — validate mime/size + atomic write into config/ui/assets/ (lib owns the write path; UI route is a thin handler).
buildThemeStyleBlock(branding): string — pure, table-driven serializer that emits only the changed tokens as :root{…} + :root[data-theme='dark']{…}. The only "rendering" and it's confined to CSS custom-property assignment.
Add paths to paths.ts; export from the lib barrel. CLI gets it for free; UI imports it. No control-plane logic duplicated in the UI.
Runtime injection (FOUC-safe — load-bearing)
app.html already runs a blocking pre-paint script for dark/light. Custom palette is worse if applied after hydration: the page would flash default OpenPalm orange (#ff9d00) before snapping to the operator's brand color — unacceptable for white-label.
New root packages/ui/src/routes/+layout.server.ts resolves readBranding() server-side → data.branding (available app-wide via PageData).
Inject the override <style> into <head> via hooks.server.tstransformPageChunk (placeholder %op.themeStyle% in app.html), before paint. Not{@html} in the layout body; not post-hydration JS.
color-mix refactor (so one override cascades)
--color-primary-subtle and --color-primary-border are currently rgba literals (4 lines: light + dark) and don't track --color-primary. Refactor them to color-mix(in srgb, var(--color-primary) N%, transparent) so overriding a single --color-primary cascades fully. color-mix is baseline-supported. Visually verify the default theme is unchanged.
Assets
The UI ships as a zero-runtime-dep npm package (@openpalm/ui, files:["build"]); static/ is build-time immutable and may be read-only at runtime. Custom assets must therefore stream from OP_HOME/config/ui/assets/:
Logo <img src> becomes /branding/<file>; resolves light/dark + falls back to bundled /logo-128.png.
Admin endpoint + tab
New packages/ui/src/routes/admin/ui-config/+server.ts (GET current + PUT json) plus a POST multipart upload, mirroring /admin/voice exactly (requireAdmin, getState, jsonResponse). One new admin tab in admin/+page.svelte. Upload validation: size cap (≤ ~1MB), mime allowlist PNG/GIF/WebP only + magic-byte sniff. No SVG upload, no remote URLs.
Chat avatar (2D)
New Avatar.svelte, used for assistant messages in ChatMessage.svelte.
Ship one default animated SVG (packages/ui/static/avatar-default.svg, CSS-keyframe animated, ~20–50KB) — "animated" with zero runtime dep and no CSP change.
Swap = name a .png/.gif/.webp/.svg (default only) in branding.json; rendered via <img src="/branding/<file>"> (native GIF/PNG/WebP support).
Render one avatar instance (header or latest assistant message), not one per bubble. Honor prefers-reduced-motion.
Centralize branding reads
Expose branding once from +layout.server.ts → page.data.branding; a small <Logo>/<Brand> pair reads it. Swap the ~5 hardcoded sites: Navbar.svelte (logo + name), AuthGate.svelte (logo + "OpenPalm Console"), setup/+page.svelte ("OP" mark). Setup wizard runs before branding.json may exist — its load must tolerate an absent file and render OpenPalm defaults (must not throw).
Security
CSS injection (the one real risk):buildThemeStyleBlock validates every color with strict ^#[0-9a-fA-F]{6}$ (hex only — no var(), no functions, no semicolons) before any value reaches the inline <style> sink. Validation lives in lib; treat its tests like guardian tests (assert red;}body{... is rejected).
Assets: filename allowlist from config; traversal guard (realpath under config/ui/assets/); mime allowlist + magic-byte sniff; size cap. No user-controlled path reaches fs raw.
No SVG uploads (XSS vector); shipped default SVG only, rendered via <img> (never {@html}).
CSP unchanged. Hash-mode CSP (script-src 'self', style-src 'unsafe-inline', img-src 'self' data:) already covers same-origin <img> and the inline override block. Never relax script-src. Remote/CDN avatars are blocked by connect-src 'self' and are disallowed by design.
No new ingress. Admin-authenticated UI surface only; guardian/assistant, assistant-isolation, and LAN-first invariants untouched.
name is rendered as text — Svelte auto-escapes.
Phases (each independently shippable; absent config = identical to today)
Docs — document config/ui/branding.json schema + asset drop-in; note the assistant TUI theme is separate.
Acceptance criteria
With no branding.json, the UI (and setup wizard) renders byte-identical to today.
Setting name changes Navbar/AuthGate/setup strings; setting palette.light.primary recolors the whole UI with no flash of the default orange on load (verified server-injected in <head>).
Overriding only primary cascades to subtle/border/accent/focus via color-mix.
Dropping a .png/.gif/.webp into config/ui/assets/ and naming it as logo/avatar serves it same-origin; bad/missing files fall back to defaults.
Chat shows the default animated avatar for assistant messages; swap-by-config works; prefers-reduced-motion disables motion.
Invalid color values (functions, semicolons, non-hex) are rejected by lib and never reach the style block (unit-tested).
Enable UI branding override + customizable chat avatar (2D)
Scope (read first)
This issue ships two small, file-backed features on top of the CSS-token system that already exists in
packages/ui/src/app.css:Explicitly OUT of scope (split to other tracks):
config/assistant/themes/openpalm.json— unrelated, leave it alone).Net new runtime dependencies: zero.
Problem / Motivation
The operator console is hardcoded to OpenPalm branding (logo, accent color, "OpenPalm" strings) in
Navbar.svelte,AuthGate.svelte, andsetup/+page.svelte. An operator running their own install cannot change the name, logo, or accent color, and the chat UI has no avatar (roles render as plain text labels inChatMessage.svelte). The existing CSS custom-property system inapp.cssalready cascades a full palette — the only missing piece is a runtime override mechanism for a handful of brand anchors plus a couple of branding strings/assets.Design
Config file (user-owned, file-assembly)
One whole-file JSON config, seeded-if-missing and non-destructive like the rest of
config/:OP_HOME/config/ui/branding.json{ "version": 1, "name": "OpenPalm", // replaces hardcoded "OpenPalm" strings; null → default "logo": { "light": "logo.png", "dark": "logo-dark.png" }, // filenames under config/ui/assets/; null → bundled default "palette": { // BOUNDED anchor set only — NOT all tokens "light": { "primary": "#ff9d00", "primaryHover": "#e68a00" }, "dark": { "primary": "#ff9d00", "primaryHover": "#ffb84d" } }, "avatar": "avatar.svg" // filename under config/ui/assets/; null → bundled default animated SVG }User-supplied logo/avatar files live in
OP_HOME/config/ui/assets/. They are user data — never overwritten by lifecycle.Control-plane logic in
@openpalm/libNew
packages/lib/src/control-plane/branding.ts, mirroringstack-spec.ts:readBranding(configDir): Branding— reads file, returns defaults if absent.writeBranding(configDir, partial)— whole-file write,0o644.saveBrandingAsset(...)— validate mime/size + atomic write intoconfig/ui/assets/(lib owns the write path; UI route is a thin handler).buildThemeStyleBlock(branding): string— pure, table-driven serializer that emits only the changed tokens as:root{…}+:root[data-theme='dark']{…}. The only "rendering" and it's confined to CSS custom-property assignment.Add paths to
paths.ts; export from the lib barrel. CLI gets it for free; UI imports it. No control-plane logic duplicated in the UI.Runtime injection (FOUC-safe — load-bearing)
app.htmlalready runs a blocking pre-paint script for dark/light. Custom palette is worse if applied after hydration: the page would flash default OpenPalm orange (#ff9d00) before snapping to the operator's brand color — unacceptable for white-label.packages/ui/src/routes/+layout.server.tsresolvesreadBranding()server-side →data.branding(available app-wide viaPageData).<style>into<head>viahooks.server.tstransformPageChunk(placeholder%op.themeStyle%inapp.html), before paint. Not{@html}in the layout body; not post-hydration JS.color-mix refactor (so one override cascades)
--color-primary-subtleand--color-primary-borderare currently rgba literals (4 lines: light + dark) and don't track--color-primary. Refactor them tocolor-mix(in srgb, var(--color-primary) N%, transparent)so overriding a single--color-primarycascades fully.color-mixis baseline-supported. Visually verify the default theme is unchanged.Assets
The UI ships as a zero-runtime-dep npm package (
@openpalm/ui,files:["build"]);static/is build-time immutable and may be read-only at runtime. Custom assets must therefore stream fromOP_HOME/config/ui/assets/:packages/ui/src/routes/branding/[asset]/+server.ts(GET) —path.join+ realpath assertion underconfig/ui/assets/(reject.., absolute,/),Bun.file()stream, correctContent-Type, conservative cache headers, 404 → bundled default.<img src>becomes/branding/<file>; resolves light/dark + falls back to bundled/logo-128.png.Admin endpoint + tab
New
packages/ui/src/routes/admin/ui-config/+server.ts(GET current + PUT json) plus aPOSTmultipart upload, mirroring/admin/voiceexactly (requireAdmin,getState,jsonResponse). One new admin tab inadmin/+page.svelte. Upload validation: size cap (≤ ~1MB), mime allowlist PNG/GIF/WebP only + magic-byte sniff. No SVG upload, no remote URLs.Chat avatar (2D)
Avatar.svelte, used for assistant messages inChatMessage.svelte.packages/ui/static/avatar-default.svg, CSS-keyframe animated, ~20–50KB) — "animated" with zero runtime dep and no CSP change..png/.gif/.webp/.svg(default only) inbranding.json; rendered via<img src="/branding/<file>">(native GIF/PNG/WebP support).prefers-reduced-motion.Centralize branding reads
Expose branding once from
+layout.server.ts→page.data.branding; a small<Logo>/<Brand>pair reads it. Swap the ~5 hardcoded sites:Navbar.svelte(logo + name),AuthGate.svelte(logo + "OpenPalm Console"),setup/+page.svelte("OP" mark). Setup wizard runs beforebranding.jsonmay exist — its load must tolerate an absent file and render OpenPalm defaults (must not throw).Security
buildThemeStyleBlockvalidates every color with strict^#[0-9a-fA-F]{6}$(hex only — novar(), no functions, no semicolons) before any value reaches the inline<style>sink. Validation lives in lib; treat its tests like guardian tests (assertred;}body{...is rejected).config/ui/assets/); mime allowlist + magic-byte sniff; size cap. No user-controlled path reachesfsraw.<img>(never{@html}).script-src 'self',style-src 'unsafe-inline',img-src 'self' data:) already covers same-origin<img>and the inline override block. Never relaxscript-src. Remote/CDN avatars are blocked byconnect-src 'self'and are disallowed by design.nameis rendered as text — Svelte auto-escapes.Phases (each independently shippable; absent config = identical to today)
branding.ts(read/write/validate/serialize) +paths.ts+ barrel export + unit tests (hex validation/CSS-injection rejection, traversal rejection, "only changed tokens emitted", defaults).--color-primary-subtle/-border→color-mix; verify default theme unchanged.+layout.server.ts,app.htmlplaceholder,hooks.server.tshead injection,branding/[asset]route, swap hardcodedname/logoin Navbar/AuthGate/setup. Prove no-op when config absent./admin/ui-configGET/PUT + multipart upload + admin tab (mirror/admin/voice).Avatar.svelte, default animated SVG, wire intoChatMessage.svelte, reduced-motion gate.config/ui/branding.jsonschema + asset drop-in; note the assistant TUI theme is separate.Acceptance criteria
branding.json, the UI (and setup wizard) renders byte-identical to today.namechanges Navbar/AuthGate/setup strings; settingpalette.light.primaryrecolors the whole UI with no flash of the default orange on load (verified server-injected in<head>).primarycascades to subtle/border/accent/focus viacolor-mix..png/.gif/.webpintoconfig/ui/assets/and naming it aslogo/avatarserves it same-origin; bad/missing files fall back to defaults.prefers-reduced-motiondisables motion.../absolute/non-allowlisted filenames (unit-tested).securitypolicyviolationcheck);svelte.config.jsCSP unchanged.cd packages/ui && npm run checkpasses; newbranding.vitest.tsand/admin/ui-configserver.vitest.tspass.Optional / stretch (do NOT build now)
themes:{},active:) — additive to the same file later.lottie-webSVG renderer, same-origin upload only) — only if demand materializes and after its own security review.Key files
packages/lib/src/control-plane/branding.ts; paths inpackages/lib/src/control-plane/paths.ts; barrel export inpackages/lib/src/index.ts.packages/ui/src/routes/+layout.server.ts,packages/ui/src/routes/branding/[asset]/+server.ts,packages/ui/src/routes/admin/ui-config/+server.ts,packages/ui/src/lib/components/Avatar.svelte,packages/ui/static/avatar-default.svg.packages/ui/src/hooks.server.ts(transformPageChunk),packages/ui/src/app.html(%op.themeStyle%),packages/ui/src/app.css(color-mix refactor),+layout.svelte,Navbar.svelte,AuthGate.svelte,routes/setup/+page.svelte,ChatMessage.svelte,routes/admin/+page.svelte.OP_HOME/config/ui/branding.json,OP_HOME/config/ui/assets/.🤝 Refined by a multi-agent review team (explore · architect · skeptic · domain-expert → consensus). Verdict: reduce-scope. Effort: M