feat(web): add theme selection UI in Settings > Appearance #495

Merged
jason.woltje merged 1 commits from feat/ms18-theme-selection-ui into main 2026-02-23 14:18:17 +00:00
3 changed files with 376 additions and 27 deletions
Showing only changes of commit 194eaa4ad1 - Show all commits

View File

@@ -0,0 +1,324 @@
"use client";
import { useState, useCallback } from "react";
import type { ReactElement } from "react";
import Link from "next/link";
import { useTheme } from "@/providers/ThemeProvider";
import { getAllThemes, type ThemeDefinition } from "@/themes";
import { apiPatch } from "@/lib/api/client";
function ThemeCard({
theme,
isActive,
onSelect,
}: {
theme: ThemeDefinition;
isActive: boolean;
onSelect: () => void;
}): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onSelect}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
style={{
display: "flex",
flexDirection: "column",
gap: 12,
padding: 16,
borderRadius: "var(--r-lg)",
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
border: isActive
? "2px solid var(--primary)"
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
cursor: "pointer",
textAlign: "left",
transition: "all 0.15s ease",
position: "relative",
width: "100%",
}}
aria-label={`Select ${theme.name} theme`}
aria-pressed={isActive}
>
{/* Color preview swatches */}
<div
style={{
display: "flex",
gap: 0,
borderRadius: "var(--r)",
overflow: "hidden",
height: 48,
width: "100%",
border: "1px solid rgba(128, 128, 128, 0.15)",
}}
>
{theme.colorPreview.map((color, i) => (
<div
key={i}
style={{
flex: 1,
background: color,
}}
/>
))}
</div>
{/* Theme info */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
fontSize: "0.9rem",
fontWeight: 600,
color: "var(--text)",
}}
>
{theme.name}
</span>
{isActive && (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="13 4 6 12 3 9" />
</svg>
)}
<span
style={{
fontSize: "0.7rem",
padding: "2px 6px",
borderRadius: "var(--r-sm)",
background: theme.isDark ? "rgba(128,128,128,0.15)" : "rgba(245,158,11,0.12)",
color: theme.isDark ? "var(--muted)" : "var(--warn)",
fontWeight: 500,
marginLeft: "auto",
}}
>
{theme.isDark ? "Dark" : "Light"}
</span>
</div>
<p
style={{
fontSize: "0.78rem",
color: "var(--muted)",
margin: 0,
lineHeight: 1.4,
}}
>
{theme.description}
</p>
</button>
);
}
function SystemThemeCard({
isActive,
onSelect,
}: {
isActive: boolean;
onSelect: () => void;
}): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onSelect}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
style={{
display: "flex",
flexDirection: "column",
gap: 12,
padding: 16,
borderRadius: "var(--r-lg)",
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
border: isActive
? "2px solid var(--primary)"
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
cursor: "pointer",
textAlign: "left",
transition: "all 0.15s ease",
width: "100%",
}}
aria-label="Use system theme preference"
aria-pressed={isActive}
>
{/* Split preview (dark | light) */}
<div
style={{
display: "flex",
gap: 0,
borderRadius: "var(--r)",
overflow: "hidden",
height: 48,
width: "100%",
border: "1px solid rgba(128, 128, 128, 0.15)",
}}
>
<div style={{ flex: 1, background: "#0f141d" }} />
<div style={{ flex: 1, background: "#1b2331" }} />
<div style={{ flex: 1, background: "#f0f4fc" }} />
<div style={{ flex: 1, background: "#dde4f2" }} />
<div
style={{
flex: 1,
background: "linear-gradient(135deg, #2f80ff 50%, #8b5cf6 50%)",
}}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--text)" }}>System</span>
{isActive && (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="var(--primary)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<polyline points="13 4 6 12 3 9" />
</svg>
)}
<span
style={{
fontSize: "0.7rem",
padding: "2px 6px",
borderRadius: "var(--r-sm)",
background: "rgba(47, 128, 255, 0.12)",
color: "var(--primary-l)",
fontWeight: 500,
marginLeft: "auto",
}}
>
Auto
</span>
</div>
<p
style={{
fontSize: "0.78rem",
color: "var(--muted)",
margin: 0,
lineHeight: 1.4,
}}
>
Follows your operating system appearance preference
</p>
</button>
);
}
export default function AppearanceSettingsPage(): ReactElement {
const { theme: preference, setTheme: setLocalTheme } = useTheme();
const [saving, setSaving] = useState(false);
const allThemes = getAllThemes();
const handleThemeSelect = useCallback(
async (themeId: string) => {
setLocalTheme(themeId);
setSaving(true);
try {
await apiPatch("/users/me/preferences", { theme: themeId });
} catch {
// Theme is still applied locally even if API save fails
} finally {
setSaving(false);
}
},
[setLocalTheme]
);
return (
<div className="max-w-4xl mx-auto p-6">
{/* Breadcrumb */}
<div style={{ marginBottom: 8 }}>
<Link
href="/settings"
style={{
fontSize: "0.83rem",
color: "var(--muted)",
textDecoration: "none",
}}
>
Settings
</Link>
<span style={{ fontSize: "0.83rem", color: "var(--muted)", margin: "0 6px" }}>/</span>
<span style={{ fontSize: "0.83rem", color: "var(--text-2)" }}>Appearance</span>
</div>
{/* Page header */}
<div style={{ marginBottom: 32 }}>
<h1
style={{
fontSize: "1.875rem",
fontWeight: 700,
color: "var(--text)",
margin: 0,
}}
>
Appearance
</h1>
<p
style={{
fontSize: "0.9rem",
color: "var(--muted)",
margin: "8px 0 0 0",
}}
>
Choose a theme for the Mosaic interface
{saving && (
<span style={{ marginLeft: 12, color: "var(--primary-l)", fontStyle: "italic" }}>
Saving...
</span>
)}
</p>
</div>
{/* Theme grid */}
<div
className="grid gap-3"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
}}
>
{/* System option first */}
<SystemThemeCard
isActive={preference === "system"}
onSelect={() => void handleThemeSelect("system")}
/>
{/* All registered themes */}
{allThemes.map((t) => (
<ThemeCard
key={t.id}
theme={t}
isActive={preference === t.id}
onSelect={() => void handleThemeSelect(t.id)}
/>
))}
</div>
</div>
);
}

View File

@@ -95,6 +95,31 @@ function SettingsCategoryCard({ category }: SettingsCategoryCardProps): ReactEle
}
const categories: CategoryConfig[] = [
{
title: "Appearance",
description:
"Choose a theme for the interface. Switch between Dark, Light, Nord, Dracula, and more.",
href: "/settings/appearance",
accent: "var(--ms-pink-500)",
iconBg: "rgba(236, 72, 153, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="10" cy="10" r="7.5" />
<path d="M10 2.5v15" />
<path d="M10 2.5a7.5 7.5 0 0 1 0 15" fill="currentColor" opacity="0.15" />
</svg>
),
},
{
title: "Credentials",
description:

View File

@@ -2,33 +2,33 @@
> Single-writer: orchestrator only. Workers read but never modify.
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | ---------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------ |
| TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
| TW-THM-002 | in-progress | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | TBD | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | | 25K | — | |
| TW-THM-003 | not-started | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | TBD | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | | — | 25K | — | |
| TW-WDG-001 | not-started | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | TBD | TW-PLAN-001 | TW-WDG-002 | worker | — | — | 15K | — | |
| TW-WDG-002 | not-started | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | TBD | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | — | — | 40K | — | |
| TW-WDG-003 | not-started | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 25K | — | |
| TW-WDG-004 | not-started | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-WDG-005 | not-started | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 20K | — | |
| TW-EDT-001 | not-started | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | TBD | TW-PLAN-001 | TW-EDT-002 | worker | — | — | 35K | — | |
| TW-EDT-002 | not-started | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | TBD | TW-EDT-001 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-VER-001 | not-started | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | TBD | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | — | — | 25K | — | |
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| ----------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---- | -------------------------------- | ------------------------------------------------------ | ------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------ |
| TW-PLAN-001 | done | Plan MS18 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | TW-THM-001,TW-WDG-001,TW-EDT-001,TW-KBN-001 | orchestrator | 2026-02-23 | 2026-02-23 | 15K | ~12K | Planning complete, all artifacts committed |
| TW-THM-001 | done | Theme architecture — Create theme definition interface, theme registry, and 5 built-in themes (Dark, Light, Nord, Dracula, Solarized) as TS files | #487 | web | feat/ms18-theme-architecture | TW-PLAN-001 | TW-THM-002,TW-THM-003 | worker | 2026-02-23 | 2026-02-23 | 30K | ~15K | PR #493 merged |
| TW-THM-002 | done | ThemeProvider upgrade — Load themes dynamically from registry, apply CSS variables, support instant theme switching without page reload | #487 | web | feat/ms18-theme-provider-upgrade | TW-THM-001 | TW-THM-003,TW-VER-002 | worker | 2026-02-23 | 2026-02-23 | 25K | ~12K | PR #494 merged |
| TW-THM-003 | in-progress | Theme selection UI — Settings page section with theme browser, live preview swatches, persist selection to UserPreference.theme via API | #487 | web | feat/ms18-theme-selection-ui | TW-THM-001,TW-THM-002 | TW-VER-002 | worker | 2026-02-23 | — | 25K | — | |
| TW-WDG-001 | not-started | Widget definition seeding — Seed 7 existing widgets into widget_definitions table with correct sizing constraints and configSchema | #488 | api | TBD | TW-PLAN-001 | TW-WDG-002 | worker | — | — | 15K | — | |
| TW-WDG-002 | not-started | Dashboard → WidgetGrid migration — Replace hardcoded dashboard layout with WidgetGrid, load/save layout via UserLayout API, default layout on first visit | #488 | web | TBD | TW-WDG-001 | TW-WDG-003,TW-WDG-004,TW-WDG-005 | worker | — | — | 40K | — | |
| TW-WDG-003 | not-started | Widget picker UI — Drawer/dialog to browse available widgets from registry, preview size/description, add to dashboard | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 25K | — | |
| TW-WDG-004 | not-started | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-WDG-005 | not-started | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | TBD | TW-WDG-002 | TW-VER-001 | worker | — | — | 20K | — | |
| TW-EDT-001 | not-started | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | TBD | TW-PLAN-001 | TW-EDT-002 | worker | — | — | 35K | — | |
| TW-EDT-002 | not-started | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | TBD | TW-EDT-001 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |
| TW-VER-001 | not-started | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | TBD | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | — | — | 25K | — | |
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
## Summary
| Metric | Value |
| ------------- | --------------------- |
| Total tasks | 16 |
| Completed | 2 (PLAN-001, THM-001) |
| In Progress | 1 (THM-002) |
| Remaining | 13 |
| PRs merged | |
| Issues closed | — |
| Milestone | MS18-ThemeWidgets |
| Metric | Value |
| ------------- | ------------------------------ |
| Total tasks | 16 |
| Completed | 3 (PLAN-001, THM-001, THM-002) |
| In Progress | 1 (THM-003) |
| Remaining | 12 |
| PRs merged | #493, #494 |
| Issues closed | — |
| Milestone | MS18-ThemeWidgets |