Skip to content

Enable UI branding override + customizable chat avatar (2D) #426

Description

@itlackey

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:

  1. Branding override — product name, logo, and a bounded brand color set, user-owned and runtime-applied.
  2. 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.
  • Assistant persona editing → tracked in Configurable personas per channel (over OpenCode agents) #22. Not part of branding.
  • 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/:

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/lib

New packages/lib/src/control-plane/branding.ts, mirroring stack-spec.ts:

  • readBranding(configDir): Branding — reads file, returns defaults if absent.
  • writeBranding(configDir, partial) — whole-file write, 0o644.
  • saveBrandingAsset(...) — validate mime/size + atomic write into config/ui/assets/ (lib owns the write path; UI route is a thin handler).
  • buildThemeStyleBlock(branding): stringpure, 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.ts transformPageChunk (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/:

  • New packages/ui/src/routes/branding/[asset]/+server.ts (GET) — path.join + realpath assertion under config/ui/assets/ (reject .., absolute, /), Bun.file() stream, correct Content-Type, conservative cache headers, 404 → bundled default.
  • 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.tspage.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)

  1. Lib corebranding.ts (read/write/validate/serialize) + paths.ts + barrel export + unit tests (hex validation/CSS-injection rejection, traversal rejection, "only changed tokens emitted", defaults).
  2. CSS refactor--color-primary-subtle/-bordercolor-mix; verify default theme unchanged.
  3. Injection + branding swap+layout.server.ts, app.html placeholder, hooks.server.ts head injection, branding/[asset] route, swap hardcoded name/logo in Navbar/AuthGate/setup. Prove no-op when config absent.
  4. Admin/admin/ui-config GET/PUT + multipart upload + admin tab (mirror /admin/voice).
  5. AvatarAvatar.svelte, default animated SVG, wire into ChatMessage.svelte, reduced-motion gate.
  6. 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).
  • Asset route rejects ../absolute/non-allowlisted filenames (unit-tested).
  • Upload rejects non-PNG/GIF/WebP and oversize files.
  • No CSP violations on default render or after an uploaded-GIF avatar (Playwright securitypolicyviolation check); svelte.config.js CSP unchanged.
  • cd packages/ui && npm run check passes; new branding.vitest.ts and /admin/ui-config server.vitest.ts pass.

Optional / stretch (do NOT build now)

  • Multiple named themes (themes:{}, active:) — additive to the same file later.
  • Opt-in Lottie avatar (lazy-imported lottie-web SVG renderer, same-origin upload only) — only if demand materializes and after its own security review.
  • 3D VRM lip-sync avatar — separate voice/avatar issue, opt-in + lazy-loaded.

Key files

  • New: packages/lib/src/control-plane/branding.ts; paths in packages/lib/src/control-plane/paths.ts; barrel export in packages/lib/src/index.ts.
  • New UI: 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.
  • Edit: 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.
  • Runtime (user-owned, not in repo): 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions