diff --git a/AI.md b/AI.md
index 2667f56..f4192fc 100644
--- a/AI.md
+++ b/AI.md
@@ -227,6 +227,7 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
- settings view is now a shell plus focused panels: `SettingsView.tsx` owns grouped tab navigation/tabpanel semantics only, while `GeneralSettings`, `AppearanceSettings`, `StorageSettings`, `ViewsSettings`, `MembersSettings`, `WorkflowSettings`, `LabelsMilestonesSettings`, `CustomFieldsSettings`, `PluginsSettings`, `AutomationSettings`, `ImportExportSettings`, and `BridgeSettings` own their own command wiring
- settings workflow, automation, and bridge sections should stay out of the shell; add future settings surfaces as focused files under `packages/ui/src/views/settings/` and register them through the shell tab list
- settings sections expose keyboard-accessible tab semantics with Arrow/Home/End navigation; inactive settings panels stay mounted with `hidden` instead of being unmounted so unsaved local form drafts survive section switches
+- the narrow Settings navigation recomposes into a single horizontally scrollable tab strip; keep `flex-direction: row` in the responsive rule because the base tab-list contract is column-oriented and otherwise pushes the active panel below the fold
- settings edit icons come from `lucide-react`, and import failures render as inline alerts instead of browser-native `alert()`
- `BridgeSettings.tsx` is truth-in-UI for future AI/MCP integration: it documents real core command coverage and explicitly says no installable bridge/server binary/client config is shipped yet, so do not add setup commands until a bridge runtime exists
- settings registry tables now follow a consistent edit flow for members/statuses/priorities/types:
@@ -286,6 +287,29 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
- docs use shared confirmation for document delete and lucide document icons instead of emoji UI markers
- `/item/:id` now renders through the shared modal shell instead of the legacy drawer route shell, with Archive/Trash/Duplicate/Done actions passed through `Modal`'s pinned footer slot
+## Visual system and appearance architecture
+
+- The color system is a finite semantic contract, not an open CSS editor. `packages/ui/src/theme/theme-contract.ts` owns every customizable `--color-*` role, its public types, grouped editor metadata, and the appearance/theme schema versions. Components should consume semantic roles; do not add route-local hex, rgb, named colors, or arbitrary user-authored CSS.
+- `packages/ui/src/theme/built-in-themes.ts` owns the immutable Grillo Adaptive, Graphite, Warm Sand, and High Contrast definitions. Every definition carries complete light and dark token records. `themeFromSeed()` clones a complete source palette and derives accent, hover, pressed, soft, focus, status-active, and readable foreground values for both modes.
+- `packages/ui/src/theme/color-utils.ts` is the dependency-free color boundary. It accepts only 3/4/6/8-digit hex, normalizes values, mixes colors, computes WCAG relative luminance/contrast, chooses a readable accent foreground, applies shared project accents, and strengthens muted text/borders for the explicit or system-derived high-contrast preference.
+- `packages/ui/src/theme/theme-storage.ts` owns device-local persistence and validation:
+ - `gph.appearance.v2` stores mode, selected theme, personal per-project theme bindings, contrast, and motion
+ - `gph.custom-themes.v1` stores sanitized custom theme definitions
+ - legacy `gph.theme` light/dark/system values migrate on read and are removed on the next save
+ - import/export is versioned JSON and requires every known semantic role in both modes; unknown objects cannot inject CSS
+- `ThemeProvider` combines built-ins plus custom themes, resolves system mode/contrast/motion media queries, applies the active complete token map to the root element, synchronizes preference/custom-theme changes across tabs through the `storage` event, updates `color-scheme` plus the browser theme-color meta value, and keeps a non-persisted preview layer for the editor. Its compatibility `theme`, `setTheme`, and `toggle` API remains available to existing shell controls.
+- Theme ownership is intentionally split:
+ - custom themes, default selection, and per-project theme binding are personal/device-local
+ - `ProjectMeta.accentColor` is shared project identity data
+ - `project.updateSettings.patch.accentColor` validates a six-digit hex value, writes it to `bundle.project`, and deliberately removes it from the `projectSettings` spread
+ - the provider overlays a shared project accent on the active personal palette, so collaborators can keep different themes while retaining the same project identity
+- Built-ins are immutable in the UI. Users clone a built-in or active theme before editing. Draft edits can preview across the running app, persist only on Save, and may be discarded; deletion uses inline confirmation. Contrast warnings inform but do not hard-block custom themes.
+- `tokens.css` is the first-paint fallback and shared non-color token layer. It declares matching Grillo light/dark values, rem-based type/spacing, motion timing, `color-scheme`, reduced-motion behavior, and forced-color focus handling. Runtime themes override only the semantic color variables.
+- `apps/web/index.html` and `apps/desktop/index.html` resolve the persisted light/dark/system choice before React starts to prevent the old light-mode flash. Full custom token application follows when `ThemeProvider` mounts.
+- `global.css` no longer authors component hex/rgb colors. Label colors, sidebar separators, shortcut/code surfaces, shadows, overlays, focus, feedback, and workflow colors all resolve through the theme contract. The CSS contract test fails on new component hex/rgb literals or unresolved custom-property references.
+- Accessibility guardrails include visible input focus rings, 4.5:1 text and 3:1 essential-boundary editor checks, readable generated accent foregrounds, system/explicit higher contrast, reduced motion, reduced transparency, and forced-colors handling. Low-contrast custom values remain possible by design and produce warnings rather than a lockout.
+- Responsive polish tied to this system includes a one-row horizontally scrollable Board toolbar, viewport-sized snap-aligned mobile board lanes, 44px narrow-screen button targets, a compact mobile Settings strip, consistent Overview metric hierarchy, and bordered work-item detail sections.
+
## Testing strategy
- TDD for the shared core: domain rules, command handlers, storage contract, and export/import
@@ -354,10 +378,11 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
- deep JSON import reference validation
- whole-card board-card link activation
- desktop storage adapter command wiring and shared adapter installation
+ - legacy theme-mode migration, malformed preference repair, safe custom-theme round trips, complete import validation, hex-only color parsing, WCAG contrast calculations, generated accent states, preset application, seed-theme creation, shared project accent validation, and semantic CSS completeness
- the Settings view test now always seeds a fresh project-store bundle per run instead of reusing any stale Zustand singleton state from prior tests
- UI test setup now installs a memory-backed `localStorage` shim when jsdom's storage implementation is unavailable or misconfigured, which keeps persistence-oriented tests deterministic
- `apps/desktop` now has a Vitest/jsdom test harness for the desktop storage adapter
-- Playwright e2e for hybrid parity, theme toggle, command palette, project creation, item creation with `C` shortcut, JSON export download, search, calendar day-cell work creation, docs create/save/edit-mode behavior, and mobile navigation
+- Playwright e2e for hybrid parity, theme toggle and Appearance preset persistence, command palette, project creation, item creation with `C` shortcut, JSON export download, search, calendar day-cell work creation, docs create/save/edit-mode behavior, mobile navigation, and a first-viewport check for the active mobile Settings panel
- `npm test` runs core, UI, and desktop adapter tests; `npm run test:e2e` runs `apps/web/scripts/run-e2e.mjs`, which starts Vite as an owned child process, waits for readiness, runs Playwright, and shuts Vite down before returning; `npm run typecheck` covers all packages and apps; `npm run lint` currently aliases typecheck until a dedicated lint stack is added
- `apps/web/scripts/run-e2e.mjs` owns the e2e base URL and passes it through `PLAYWRIGHT_BASE_URL`; `apps/web/playwright.config.ts` reads that environment value with a fixed local fallback so runner and config cannot silently drift.
@@ -368,7 +393,7 @@ The core domain in `packages/core/src/domain/` covers the entities the plan call
- implemented the validated command dispatcher with envelope + dispatch table + per-command handlers and event logging
- implemented starter templates: `simple-kanban`, `software-project`, `bug-tracker`, `release-planner`
- implemented search, export (JSON / Markdown / CSV), and import (JSON + CSV) in the shared core
-- implemented the React AppShell, theme provider (light/dark/system), and command palette with navigation, item creation, view switching, search hits, and theme toggle commands
+- implemented the React AppShell, versioned semantic theme runtime and Appearance studio, and command palette with navigation, item creation, view switching, search hits, and theme toggle commands
- implemented board, backlog, table, docs (with sanitized Markdown and backlinks), roadmap (drag/resize), calendar, bug triage, my work, search, settings, and the work item drawer
- configured the Tauri desktop shell with narrow Rust commands and a folder-backed storage adapter
- added the PWA manifest, service worker registration, favicon, and auto-save bridge for the web app
diff --git a/Readme.md b/Readme.md
index 609c1c4..bc2e98c 100644
--- a/Readme.md
+++ b/Readme.md
@@ -56,10 +56,24 @@ Grillo is past the skeleton stage. The current app includes:
- Bug triage with readable lanes, severity/priority/source/context fields, intake gates, accept/decline/snooze/assign actions, and a searchable duplicate-link picker.
- Automation rules with command-backed create/update/delete/enable/disable, dry-run previews, item-event triggers, and audited action failures.
- JSON, Markdown, and CSV import/export, plus clean print preview.
-- Light/dark/system themes, a remembered desktop sidebar that toggles between full navigation and an icon rail, context-aware no-project navigation, responsive mobile navigation and project tabs, offline status, and local/self-hosted PWA install support when the browser exposes it.
+- A full Appearance studio with light/dark/system modes, Grillo Adaptive, Graphite, Warm Sand, and High Contrast presets, seed-based custom theme creation, every authored UI color exposed as a semantic role, live preview, contrast checks, safe JSON import/export, per-project personal overrides, and a shared project accent.
+- A remembered desktop sidebar that toggles between full navigation and an icon rail, context-aware no-project navigation, responsive mobile navigation and project tabs, offline status, and local/self-hosted PWA install support when the browser exposes it.
- Planning-surface UX polish including aligned Backlog rows, progressive Table controls, a keyboard-operable Roadmap resize grip, a complete seven-day Calendar beside its agenda, and item-title link names for assistive technology.
- A realistic, non-persistent demo workspace with active and completed work, bug intake, comments, members, dates, milestones, and linked docs.
+## Appearance and themes
+
+Open **Settings -> Appearance** to choose a preset or build your own. Grillo stores light and dark values together, so a custom theme remains coherent when the operating system changes modes.
+
+- Color mode, contrast, motion, selected theme, custom themes, and per-project theme bindings stay local to the current device and browser profile.
+- The optional project accent is different: it is stored in the project bundle and shared with collaborators. Grillo derives its hover, pressed, soft, focus, and readable foreground states automatically.
+- Built-in themes are immutable. Clone one before changing individual semantic roles.
+- Theme imports accept only the versioned Grillo theme structure and recognized hex color values. Imported files cannot inject arbitrary CSS.
+- The editor reports key WCAG text and control-boundary contrast pairs without preventing intentional low-contrast experimentation.
+- System contrast and reduced-motion preferences are respected, with explicit user overrides available.
+
+All authored component colors now route through the semantic contract in `packages/ui/src/theme/theme-contract.ts`. The default palette remains recognizably Grillo, with stronger text, focus, border, and accent contrast than the original token set.
+
## Storage model
The durable project format is a `.pms.json` bundle under `.pm-suite/` when using folder-backed storage. Browser-local projects use local browser storage as a compatibility layer.
diff --git a/apps/desktop/index.html b/apps/desktop/index.html
index 15d4293..a2b8f6e 100644
--- a/apps/desktop/index.html
+++ b/apps/desktop/index.html
@@ -3,6 +3,21 @@
+
+
Grillo Project Hub
diff --git a/apps/web/index.html b/apps/web/index.html
index b3e5348..e849a55 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -5,6 +5,20 @@
+
Grillo Project Hub
diff --git a/docs/plans/2026-07-18-visual-system-personalization.md b/docs/plans/2026-07-18-visual-system-personalization.md
new file mode 100644
index 0000000..a8cef46
--- /dev/null
+++ b/docs/plans/2026-07-18-visual-system-personalization.md
@@ -0,0 +1,96 @@
+# Visual System and Personalization Update
+
+Status: implemented on `codex/visual-system-personalization`
+
+## Goal
+
+Raise Grillo's visual polish without replacing its calm, nature-led identity, then give users a safe path from quick presets to complete color control. The same system must work in the browser and Tauri shell, survive older saved preferences, respect system accessibility settings, and remain understandable to future contributors.
+
+## Research signals
+
+The implementation follows the patterns that consistently appeared across mature design systems and accessibility specifications:
+
+- [WCAG 2.2 contrast guidance](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) establishes 4.5:1 for ordinary text, with 3:1 used for large text and essential non-text boundaries.
+- [Media Queries Level 5](https://www.w3.org/TR/mediaqueries-5/) defines preferences such as reduced motion, reduced transparency, and increased contrast.
+- [CSS Color Adjustment](https://www.w3.org/TR/css-color-adjust-1/) defines `color-scheme` and forced-color behavior.
+- [Atlassian design tokens](https://atlassian.design/foundations/tokens/), [Fluent 2 tokens](https://fluent2.microsoft.design/design-tokens), and [Carbon themes](https://carbondesignsystem.com/elements/themes/overview/) converge on primitives feeding semantic aliases, with themes expressed as complete value maps rather than component-specific overrides.
+- The [Design Tokens Community Group format](https://www.designtokens.org/TR/drafts/format/) informed the versioned, data-only import/export boundary. Grillo keeps a narrower application schema for now so imports cannot become arbitrary CSS.
+
+## Product model
+
+Three levels serve different users without exposing complexity too early:
+
+1. Quick selection: system/light/dark, contrast and motion preferences, plus Grillo Adaptive, Graphite, Warm Sand, and High Contrast presets.
+2. Theme creation: name a theme, choose a seed, and derive accessible light/dark accent states from the current palette.
+3. Advanced editing: clone a theme and edit every semantic color role in both modes, with live app preview and a contrast report.
+
+Theme selection, custom themes, and personal per-project overrides are device-local. A project accent is shared in the project bundle; it overlays identity roles without forcing collaborators to use the same theme.
+
+## Technical plan and delivered mapping
+
+### 1. Establish the semantic contract
+
+- Define one typed list for canvas/surfaces, text/icons, controls/focus, feedback/workflow, labels, and visual effects.
+- Move every authored component color, including label chips, shadow tint, sidebar separators, and code/shortcut surfaces, behind those roles.
+- Keep layout, type, spacing, radii, and motion as shared non-theme tokens.
+
+Delivered in `theme-contract.ts`, `tokens.css`, `global.css`, and `WorkItemCard.tsx`.
+
+### 2. Add a resilient runtime
+
+- Version preferences and custom themes separately.
+- Migrate the former `gph.theme` value.
+- Resolve system mode, contrast, and motion; apply complete token maps at the root; synchronize tabs.
+- Bootstrap mode before React to avoid a first-paint flash.
+- Preserve the old provider API used by the header and command system.
+
+Delivered in `theme-storage.ts`, `theme-provider.tsx`, and both app `index.html` files.
+
+### 3. Build the Appearance studio
+
+- Show real miniature previews for built-ins and customs.
+- Support default-device and personal per-project selection.
+- Create from seed, clone, edit both modes, preview, save/discard, inline-confirm delete, reset, and safe import/export.
+- Warn on key text/control contrast pairs while allowing intentional experimentation.
+
+Delivered in `AppearanceSettings.tsx` and its responsive styling.
+
+### 4. Separate personal appearance from project identity
+
+- Keep custom themes and selection out of project data.
+- Add a validated shared six-digit project accent.
+- Derive hover, pressed, soft, focus, active-status, and foreground roles at runtime.
+
+Delivered through `project.updateSettings.patch.accentColor`, `ProjectMeta.accentColor`, and the theme provider overlay.
+
+### 5. Apply the highest-impact polish
+
+- Strengthen default foreground and boundary contrast while retaining the Grillo palette.
+- Restore visible form focus, add motion/transparency/forced-colors behavior, and standardize transition timing.
+- Recompose narrow Settings navigation into a horizontal strip.
+- Keep the Board toolbar in one scrollable row and size mobile lanes to the viewport with snap behavior.
+- Give Overview metrics a consistent hierarchy and work-item sections clearer separation.
+- Use theme-aware label colors everywhere they appear.
+
+Delivered in `tokens.css`, `global.css`, `ViewToolbar.tsx`, Board, Table, Labels settings, Overview, and work-item styling.
+
+## Safety and acceptance criteria
+
+- No component-authored hex/rgb color literals outside theme definitions and color utilities.
+- Every CSS custom-property reference has a declaration.
+- Imported themes contain a complete light and dark semantic map and hex-only values.
+- Built-ins cannot be overwritten or deleted.
+- A broken preference or theme falls back to Grillo Adaptive.
+- Custom theme previews do not persist until Save.
+- Existing light/dark/system preferences migrate.
+- System mode avoids a light flash on startup.
+- Text/control contrast is visible to the user before sharing a theme.
+- Core, UI, web, and desktop type checks/builds pass; theme storage, generation, settings flows, CSS contracts, and project accent commands have regression coverage.
+- Desktop light/dark and 390px mobile Appearance/Board states receive rendered browser QA.
+
+## Follow-on opportunities
+
+- Add typography and density profiles as separate preference axes; do not overload the color theme schema.
+- Add a curated theme gallery only after provenance, compatibility, and trust UX are defined.
+- Consider exporting a parallel full DTCG document if other tools need interchange; keep application import validation narrower than the general standard.
+- Add automated browser-level contrast and forced-colors snapshots once the E2E environment has stable visual baselines.
diff --git a/packages/core/src/commands/dispatcher.test.ts b/packages/core/src/commands/dispatcher.test.ts
index 49d8ab8..e15babb 100644
--- a/packages/core/src/commands/dispatcher.test.ts
+++ b/packages/core/src/commands/dispatcher.test.ts
@@ -984,6 +984,29 @@ describe("command dispatcher", () => {
).toThrow(/Project mismatch/);
});
+ it("stores a validated shared project accent outside personal appearance settings", () => {
+ const bundle = createProjectBundle({ name: "Branded" });
+ const result = dispatchCommand(
+ bundle,
+ envelopeFor({
+ type: "project.updateSettings",
+ projectId: bundle.project.id,
+ patch: { accentColor: "#245ea8" }
+ }, "ui", null)
+ );
+
+ expect(result.bundle.project.accentColor).toBe("#245ea8");
+ expect(result.bundle.projectSettings).not.toHaveProperty("accentColor");
+ expect(() => dispatchCommand(
+ result.bundle,
+ envelopeFor({
+ type: "project.updateSettings",
+ projectId: bundle.project.id,
+ patch: { accentColor: "blue" }
+ }, "ui", null)
+ )).toThrow(/six-digit hex/);
+ });
+
it("stores edits previews disables and deletes automation rules", () => {
let bundle = buildProjectFromTemplate("software-project", "Automation");
const label = bundle.core.labels.find((entry) => entry.name === "frontend")!;
diff --git a/packages/core/src/commands/dispatcher.ts b/packages/core/src/commands/dispatcher.ts
index e87ee3e..d40b45c 100644
--- a/packages/core/src/commands/dispatcher.ts
+++ b/packages/core/src/commands/dispatcher.ts
@@ -368,6 +368,7 @@ function updateProjectSettings(
enabledModuleIds?: string[];
hiddenViewIds?: string[];
pluginTrustMode?: "first-party" | "curated" | "unrestricted";
+ accentColor?: string | null;
};
}
): DispatchResult {
@@ -375,11 +376,18 @@ function updateProjectSettings(
if (payload.patch.pluginTrustMode && !["first-party", "curated", "unrestricted"].includes(payload.patch.pluginTrustMode)) {
throw new Error(`Invalid plugin trust mode: ${payload.patch.pluginTrustMode}`);
}
+ if (payload.patch.accentColor !== undefined && payload.patch.accentColor !== null && !/^#[0-9a-f]{6}$/i.test(payload.patch.accentColor)) {
+ throw new Error("Project accent color must be a six-digit hex color");
+ }
+ const { accentColor, ...settingsPatch } = payload.patch;
const next = bumpRevision({
...bundle,
+ project: accentColor === undefined
+ ? bundle.project
+ : { ...bundle.project, accentColor },
projectSettings: {
...bundle.projectSettings,
- ...payload.patch,
+ ...settingsPatch,
enabledModuleIds: payload.patch.enabledModuleIds ? [...payload.patch.enabledModuleIds] : bundle.projectSettings.enabledModuleIds,
hiddenViewIds: payload.patch.hiddenViewIds ? [...payload.patch.hiddenViewIds] : (bundle.projectSettings.hiddenViewIds ?? [])
}
diff --git a/packages/core/src/commands/envelope.ts b/packages/core/src/commands/envelope.ts
index 48ec7af..c49a727 100644
--- a/packages/core/src/commands/envelope.ts
+++ b/packages/core/src/commands/envelope.ts
@@ -111,6 +111,8 @@ export type ProjectUpdateSettingsPayload = {
enabledModuleIds?: string[];
hiddenViewIds?: string[];
pluginTrustMode?: "first-party" | "curated" | "unrestricted";
+ /** Shared project identity color. Null follows the active personal theme. */
+ accentColor?: string | null;
};
};
diff --git a/packages/ui/src/components/layout/ViewToolbar.tsx b/packages/ui/src/components/layout/ViewToolbar.tsx
index 40c0fc6..cdd0762 100644
--- a/packages/ui/src/components/layout/ViewToolbar.tsx
+++ b/packages/ui/src/components/layout/ViewToolbar.tsx
@@ -1,5 +1,5 @@
import { type ReactNode } from "react";
-export function ViewToolbar({ children }: { children: ReactNode }) {
- return {children}
;
+export function ViewToolbar({ children, className = "" }: { children: ReactNode; className?: string }) {
+ return {children}
;
}
diff --git a/packages/ui/src/components/work/WorkItemCard.tsx b/packages/ui/src/components/work/WorkItemCard.tsx
index 1f1e72a..0e001a4 100644
--- a/packages/ui/src/components/work/WorkItemCard.tsx
+++ b/packages/ui/src/components/work/WorkItemCard.tsx
@@ -53,14 +53,14 @@ export function WorkItemCard({
);
}
-function colorForLabel(color: string): string {
+export function colorForLabel(color: string): string {
const palette: Record = {
- blue: "rgba(91, 144, 191, 0.18)",
- green: "rgba(79, 138, 85, 0.18)",
- orange: "rgba(210, 138, 58, 0.20)",
- purple: "rgba(125, 95, 168, 0.18)",
- red: "rgba(177, 58, 58, 0.20)",
- yellow: "rgba(210, 180, 58, 0.20)"
+ blue: "var(--color-label-blue)",
+ green: "var(--color-label-green)",
+ orange: "var(--color-label-orange)",
+ purple: "var(--color-label-purple)",
+ red: "var(--color-label-red)",
+ yellow: "var(--color-label-yellow)"
};
- return palette[color] ?? color;
+ return palette[color] ?? (/^#[0-9a-f]{3,8}$/i.test(color) ? color : "var(--color-accent-soft)");
}
diff --git a/packages/ui/src/theme/built-in-themes.ts b/packages/ui/src/theme/built-in-themes.ts
new file mode 100644
index 0000000..a03c7e0
--- /dev/null
+++ b/packages/ui/src/theme/built-in-themes.ts
@@ -0,0 +1,286 @@
+import { deriveAccentTokens } from "./color-utils";
+import {
+ THEME_SCHEMA_VERSION,
+ type ThemeColorTokens,
+ type ThemeDefinition
+} from "./theme-contract";
+
+export const GRILLO_THEME_ID = "grillo-adaptive";
+
+const grilloLight: ThemeColorTokens = {
+ "color-bg-canvas": "#f7f5f0",
+ "color-bg-surface": "#ffffff",
+ "color-bg-elevated": "#ffffff",
+ "color-bg-muted": "#efece5",
+ "color-bg-sidebar": "#2c3530",
+ "color-bg-sidebar-hover": "#38433c",
+ "color-bg-sidebar-active": "#435049",
+ "color-bg-row-hover": "#f0ede5",
+ "color-bg-row-selected": "#e3eee0",
+ "color-bg-status-ok": "#e3eee0",
+ "color-bg-status-warn": "#fbf2dc",
+ "color-bg-status-blocked": "#f4dada",
+ "color-bg-status-done": "#d8e6d0",
+ "color-bg-overlay": "#1c24205c",
+ "color-text-primary": "#1d2620",
+ "color-text-secondary": "#525a53",
+ "color-text-muted": "#687069",
+ "color-text-on-accent": "#ffffff",
+ "color-text-on-danger": "#ffffff",
+ "color-text-on-sidebar": "#f1f4ef",
+ "color-text-on-sidebar-muted": "#bac2b9",
+ "color-text-on-status": "#2a3530",
+ "color-border-subtle": "#d7d3c9",
+ "color-border-strong": "#999487",
+ "color-border-focus": "#3f7647",
+ "color-accent": "#3f7647",
+ "color-accent-hover": "#35643c",
+ "color-accent-pressed": "#2b5332",
+ "color-accent-soft": "#dce9d7",
+ "color-accent-contrast": "#ffffff",
+ "color-danger": "#a52f35",
+ "color-warn": "#9b6818",
+ "color-success": "#2e6a4d",
+ "color-info": "#356f9f",
+ "color-status-planned": "#59675a",
+ "color-status-active": "#3f7647",
+ "color-status-completed": "#2e6a4d",
+ "color-status-canceled": "#7a5c27",
+ "color-label-blue": "#dceaf7",
+ "color-label-green": "#dcebdc",
+ "color-label-orange": "#f7e4cf",
+ "color-label-purple": "#e9dff4",
+ "color-label-red": "#f4dada",
+ "color-label-yellow": "#f6edc7",
+ "color-shadow": "#1c2420",
+ "color-sidebar-divider": "#ffffff1f",
+ "color-code-bg": "#00000014",
+ "color-code-border": "#00000024",
+ "color-code-bg-inverse": "#ffffff1a",
+ "color-code-border-inverse": "#ffffff29"
+};
+
+const grilloDark: ThemeColorTokens = {
+ "color-bg-canvas": "#1c2420",
+ "color-bg-surface": "#232b27",
+ "color-bg-elevated": "#2a322d",
+ "color-bg-muted": "#1a201d",
+ "color-bg-sidebar": "#111713",
+ "color-bg-sidebar-hover": "#1f2622",
+ "color-bg-sidebar-active": "#2a312c",
+ "color-bg-row-hover": "#2a312c",
+ "color-bg-row-selected": "#2d3a32",
+ "color-bg-status-ok": "#2d3a32",
+ "color-bg-status-warn": "#3a3220",
+ "color-bg-status-blocked": "#3a2828",
+ "color-bg-status-done": "#233228",
+ "color-bg-overlay": "#0000008c",
+ "color-text-primary": "#f0f2ed",
+ "color-text-secondary": "#c2c8bf",
+ "color-text-muted": "#9da49a",
+ "color-text-on-accent": "#101713",
+ "color-text-on-danger": "#101713",
+ "color-text-on-sidebar": "#edf1e9",
+ "color-text-on-sidebar-muted": "#aab3a7",
+ "color-text-on-status": "#f0f2ed",
+ "color-border-subtle": "#3b443e",
+ "color-border-strong": "#6b766e",
+ "color-border-focus": "#79b982",
+ "color-accent": "#79b982",
+ "color-accent-hover": "#89c991",
+ "color-accent-pressed": "#98d7a0",
+ "color-accent-soft": "#304238",
+ "color-accent-contrast": "#101713",
+ "color-danger": "#ef7f83",
+ "color-warn": "#e2b766",
+ "color-success": "#79b982",
+ "color-info": "#83b8e2",
+ "color-status-planned": "#b4c0b1",
+ "color-status-active": "#79b982",
+ "color-status-completed": "#64aa78",
+ "color-status-canceled": "#d3b071",
+ "color-label-blue": "#2d4355",
+ "color-label-green": "#304638",
+ "color-label-orange": "#4b3929",
+ "color-label-purple": "#40364e",
+ "color-label-red": "#4c3032",
+ "color-label-yellow": "#494328",
+ "color-shadow": "#000000",
+ "color-sidebar-divider": "#ffffff1f",
+ "color-code-bg": "#ffffff1a",
+ "color-code-border": "#ffffff29",
+ "color-code-bg-inverse": "#00000024",
+ "color-code-border-inverse": "#00000038"
+};
+
+function override(base: ThemeColorTokens, patch: Partial): ThemeColorTokens {
+ return { ...base, ...patch };
+}
+
+export const GRILLO_THEME: ThemeDefinition = {
+ schemaVersion: THEME_SCHEMA_VERSION,
+ id: GRILLO_THEME_ID,
+ name: "Grillo Adaptive",
+ description: "The familiar warm, nature-led Grillo palette with stronger contrast.",
+ builtIn: true,
+ seedColor: "#3f7647",
+ modes: { light: grilloLight, dark: grilloDark }
+};
+
+const graphiteLight = deriveAccentTokens(override(grilloLight, {
+ "color-bg-canvas": "#f4f5f6",
+ "color-bg-muted": "#e9ebed",
+ "color-bg-sidebar": "#24282d",
+ "color-bg-sidebar-hover": "#343a41",
+ "color-bg-sidebar-active": "#434b54",
+ "color-bg-row-hover": "#eceff1",
+ "color-bg-row-selected": "#e2eaf5",
+ "color-text-primary": "#202429",
+ "color-text-secondary": "#515861",
+ "color-text-muted": "#666f79",
+ "color-border-subtle": "#d2d6da",
+ "color-border-strong": "#8d959e",
+ "color-shadow": "#202429"
+}), "#315f9c", "light");
+
+const graphiteDark = deriveAccentTokens(override(grilloDark, {
+ "color-bg-canvas": "#181b1f",
+ "color-bg-surface": "#22262b",
+ "color-bg-elevated": "#292e34",
+ "color-bg-muted": "#15181b",
+ "color-bg-sidebar": "#111316",
+ "color-bg-sidebar-hover": "#252a30",
+ "color-bg-sidebar-active": "#30363d",
+ "color-bg-row-hover": "#292e34",
+ "color-bg-row-selected": "#293748",
+ "color-text-primary": "#f1f3f5",
+ "color-text-secondary": "#c4c9cf",
+ "color-text-muted": "#9ba3ac",
+ "color-border-subtle": "#3a4149",
+ "color-border-strong": "#6b7784"
+}), "#7eace8", "dark");
+
+const warmLight = deriveAccentTokens(override(grilloLight, {
+ "color-bg-canvas": "#fbf5e9",
+ "color-bg-surface": "#fffdf8",
+ "color-bg-elevated": "#ffffff",
+ "color-bg-muted": "#f2e7d3",
+ "color-bg-sidebar": "#4a372d",
+ "color-bg-sidebar-hover": "#5a4437",
+ "color-bg-sidebar-active": "#6b5140",
+ "color-bg-row-hover": "#f6ead7",
+ "color-bg-row-selected": "#f5dfc7",
+ "color-text-primary": "#35251e",
+ "color-text-secondary": "#6c5549",
+ "color-text-muted": "#7f6a5f",
+ "color-border-subtle": "#e3d3bd",
+ "color-border-strong": "#9d846d",
+ "color-shadow": "#3d2b22"
+}), "#a4472f", "light");
+
+const warmDark = deriveAccentTokens(override(grilloDark, {
+ "color-bg-canvas": "#251d19",
+ "color-bg-surface": "#302520",
+ "color-bg-elevated": "#392c26",
+ "color-bg-muted": "#211916",
+ "color-bg-sidebar": "#19120f",
+ "color-bg-sidebar-hover": "#30241f",
+ "color-bg-sidebar-active": "#443128",
+ "color-bg-row-hover": "#392c26",
+ "color-bg-row-selected": "#493127",
+ "color-text-primary": "#faeee6",
+ "color-text-secondary": "#d9c3b7",
+ "color-text-muted": "#b59e92",
+ "color-border-subtle": "#4c3b33",
+ "color-border-strong": "#8c6e5e"
+}), "#e88a68", "dark");
+
+const highContrastLight = deriveAccentTokens(override(grilloLight, {
+ "color-bg-canvas": "#ffffff",
+ "color-bg-surface": "#ffffff",
+ "color-bg-elevated": "#ffffff",
+ "color-bg-muted": "#f1f1f1",
+ "color-bg-sidebar": "#000000",
+ "color-bg-sidebar-hover": "#242424",
+ "color-bg-sidebar-active": "#3b3b3b",
+ "color-text-primary": "#000000",
+ "color-text-secondary": "#222222",
+ "color-text-muted": "#444444",
+ "color-text-on-sidebar": "#ffffff",
+ "color-text-on-sidebar-muted": "#e0e0e0",
+ "color-border-subtle": "#6b6b6b",
+ "color-border-strong": "#222222",
+ "color-shadow": "#000000"
+}), "#004f8f", "light");
+
+const highContrastDark = deriveAccentTokens(override(grilloDark, {
+ "color-bg-canvas": "#000000",
+ "color-bg-surface": "#0d0d0d",
+ "color-bg-elevated": "#171717",
+ "color-bg-muted": "#000000",
+ "color-bg-sidebar": "#000000",
+ "color-bg-sidebar-hover": "#242424",
+ "color-bg-sidebar-active": "#383838",
+ "color-text-primary": "#ffffff",
+ "color-text-secondary": "#f0f0f0",
+ "color-text-muted": "#c9c9c9",
+ "color-border-subtle": "#808080",
+ "color-border-strong": "#d0d0d0",
+ "color-shadow": "#000000"
+}), "#86c5ff", "dark");
+
+export const BUILT_IN_THEMES: ThemeDefinition[] = [
+ GRILLO_THEME,
+ {
+ schemaVersion: THEME_SCHEMA_VERSION,
+ id: "graphite",
+ name: "Graphite",
+ description: "A cooler, neutral workspace with a focused blue accent.",
+ builtIn: true,
+ seedColor: "#315f9c",
+ modes: { light: graphiteLight, dark: graphiteDark }
+ },
+ {
+ schemaVersion: THEME_SCHEMA_VERSION,
+ id: "warm-sand",
+ name: "Warm Sand",
+ description: "A softer, editorial palette with terracotta accents.",
+ builtIn: true,
+ seedColor: "#a4472f",
+ modes: { light: warmLight, dark: warmDark }
+ },
+ {
+ schemaVersion: THEME_SCHEMA_VERSION,
+ id: "high-contrast",
+ name: "High Contrast",
+ description: "Maximum separation for text, controls, and workspace layers.",
+ builtIn: true,
+ seedColor: "#004f8f",
+ modes: { light: highContrastLight, dark: highContrastDark }
+ }
+];
+
+export function themeFromSeed(input: {
+ id: string;
+ name: string;
+ description?: string;
+ seedColor: string;
+ source?: ThemeDefinition;
+}): ThemeDefinition {
+ const source = input.source ?? GRILLO_THEME;
+ const now = new Date().toISOString();
+ return {
+ schemaVersion: THEME_SCHEMA_VERSION,
+ id: input.id,
+ name: input.name,
+ description: input.description ?? "A custom Grillo theme.",
+ builtIn: false,
+ seedColor: input.seedColor,
+ modes: {
+ light: deriveAccentTokens({ ...source.modes.light }, input.seedColor, "light"),
+ dark: deriveAccentTokens({ ...source.modes.dark }, input.seedColor, "dark")
+ },
+ createdAt: now,
+ updatedAt: now
+ };
+}
diff --git a/packages/ui/src/theme/color-utils.test.ts b/packages/ui/src/theme/color-utils.test.ts
new file mode 100644
index 0000000..d05cfd1
--- /dev/null
+++ b/packages/ui/src/theme/color-utils.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { BUILT_IN_THEMES, GRILLO_THEME } from "./built-in-themes";
+import {
+ contrastRatio,
+ deriveAccentTokens,
+ mixThemeColors,
+ normalizeThemeColor,
+ readableForeground
+} from "./color-utils";
+
+describe("theme color utilities", () => {
+ it("normalizes supported hex colors and rejects arbitrary CSS", () => {
+ expect(normalizeThemeColor(" #AbC ")).toBe("#aabbcc");
+ expect(normalizeThemeColor("#12345678")).toBe("#12345678");
+ expect(normalizeThemeColor("url(example.com)")).toBeNull();
+ expect(normalizeThemeColor("red")).toBeNull();
+ });
+
+ it("calculates WCAG contrast and readable foregrounds", () => {
+ expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 4);
+ expect(readableForeground("#f6d365")).toBe("#101713");
+ expect(readableForeground("#20352a")).toBe("#ffffff");
+ });
+
+ it("derives a complete, accessible accent state set", () => {
+ const next = deriveAccentTokens(GRILLO_THEME.modes.light, "#245ea8", "light");
+ expect(next["color-accent"]).toBe("#245ea8");
+ expect(next["color-accent-soft"]).toBe(mixThemeColors("#245ea8", "#ffffff", 0.82));
+ expect(contrastRatio(next["color-text-on-accent"], next["color-accent"])).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it("keeps every built-in text and strong-boundary pair above its target", () => {
+ for (const theme of BUILT_IN_THEMES) {
+ for (const mode of ["light", "dark"] as const) {
+ const tokens = theme.modes[mode];
+ expect(contrastRatio(tokens["color-text-primary"], tokens["color-bg-surface"]), `${theme.name} ${mode} primary`).toBeGreaterThanOrEqual(4.5);
+ expect(contrastRatio(tokens["color-text-secondary"], tokens["color-bg-surface"]), `${theme.name} ${mode} secondary`).toBeGreaterThanOrEqual(4.5);
+ expect(contrastRatio(tokens["color-text-on-accent"], tokens["color-accent"]), `${theme.name} ${mode} accent`).toBeGreaterThanOrEqual(4.5);
+ expect(contrastRatio(tokens["color-border-strong"], tokens["color-bg-surface"]), `${theme.name} ${mode} boundary`).toBeGreaterThanOrEqual(3);
+ }
+ }
+ });
+});
diff --git a/packages/ui/src/theme/color-utils.ts b/packages/ui/src/theme/color-utils.ts
new file mode 100644
index 0000000..89d3701
--- /dev/null
+++ b/packages/ui/src/theme/color-utils.ts
@@ -0,0 +1,95 @@
+import type { ResolvedThemeMode, ThemeColorTokens } from "./theme-contract";
+
+type Rgb = { r: number; g: number; b: number };
+
+const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
+
+export function isValidThemeColor(value: string): boolean {
+ return HEX_COLOR.test(value.trim());
+}
+
+export function normalizeThemeColor(value: string): string | null {
+ const trimmed = value.trim().toLowerCase();
+ if (!HEX_COLOR.test(trimmed)) return null;
+ if (trimmed.length === 4 || trimmed.length === 5) {
+ return "#" + trimmed.slice(1).split("").map((part) => part + part).join("");
+ }
+ return trimmed;
+}
+
+function hexToRgb(value: string): Rgb {
+ const normalized = normalizeThemeColor(value) ?? "#000000";
+ return {
+ r: Number.parseInt(normalized.slice(1, 3), 16),
+ g: Number.parseInt(normalized.slice(3, 5), 16),
+ b: Number.parseInt(normalized.slice(5, 7), 16)
+ };
+}
+
+function channelToHex(value: number): string {
+ return Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, "0");
+}
+
+export function mixThemeColors(first: string, second: string, secondWeight: number): string {
+ const a = hexToRgb(first);
+ const b = hexToRgb(second);
+ const weight = Math.max(0, Math.min(1, secondWeight));
+ return "#" + channelToHex(a.r * (1 - weight) + b.r * weight)
+ + channelToHex(a.g * (1 - weight) + b.g * weight)
+ + channelToHex(a.b * (1 - weight) + b.b * weight);
+}
+
+function linearChannel(value: number): number {
+ const channel = value / 255;
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
+}
+
+export function relativeLuminance(value: string): number {
+ const { r, g, b } = hexToRgb(value);
+ return 0.2126 * linearChannel(r) + 0.7152 * linearChannel(g) + 0.0722 * linearChannel(b);
+}
+
+export function contrastRatio(first: string, second: string): number {
+ const light = Math.max(relativeLuminance(first), relativeLuminance(second));
+ const dark = Math.min(relativeLuminance(first), relativeLuminance(second));
+ return (light + 0.05) / (dark + 0.05);
+}
+
+export function readableForeground(background: string): string {
+ const light = "#ffffff";
+ const dark = "#101713";
+ return contrastRatio(light, background) >= contrastRatio(dark, background) ? light : dark;
+}
+
+export function deriveAccentTokens(
+ tokens: ThemeColorTokens,
+ accentColor: string,
+ mode: ResolvedThemeMode
+): ThemeColorTokens {
+ const accent = normalizeThemeColor(accentColor);
+ if (!accent || accent.length !== 7) return tokens;
+ const surface = tokens["color-bg-surface"];
+ const hoverTarget = mode === "dark" ? "#ffffff" : "#000000";
+
+ return {
+ ...tokens,
+ "color-accent": accent,
+ "color-accent-hover": mixThemeColors(accent, hoverTarget, mode === "dark" ? 0.12 : 0.14),
+ "color-accent-pressed": mixThemeColors(accent, hoverTarget, mode === "dark" ? 0.2 : 0.24),
+ "color-accent-soft": mixThemeColors(accent, surface, mode === "dark" ? 0.72 : 0.82),
+ "color-accent-contrast": readableForeground(accent),
+ "color-text-on-accent": readableForeground(accent),
+ "color-border-focus": accent,
+ "color-status-active": accent
+ };
+}
+
+export function deriveHigherContrastTokens(tokens: ThemeColorTokens): ThemeColorTokens {
+ return {
+ ...tokens,
+ "color-text-muted": tokens["color-text-secondary"],
+ "color-border-subtle": tokens["color-border-strong"],
+ "color-border-focus": tokens["color-accent"],
+ "color-sidebar-divider": tokens["color-text-on-sidebar-muted"]
+ };
+}
diff --git a/packages/ui/src/theme/global-css.test.ts b/packages/ui/src/theme/global-css.test.ts
index c3baf0f..dca91f8 100644
--- a/packages/ui/src/theme/global-css.test.ts
+++ b/packages/ui/src/theme/global-css.test.ts
@@ -53,3 +53,24 @@ describe("responsive shell CSS", () => {
expect(cssRule(".shell-project-actions")).toContain("flex-wrap: nowrap");
});
});
+
+describe("theme contract CSS", () => {
+ it("routes authored component colors through semantic variables", () => {
+ expect(css).not.toMatch(/#[0-9a-f]{3,8}/i);
+ expect(css).not.toMatch(/rgba?\(/i);
+ });
+
+ it("declares every referenced custom property", () => {
+ const source = `${tokens}\n${css}`;
+ const declared = new Set(Array.from(source.matchAll(/(--[\w-]+)\s*:/g), (match) => match[1]));
+ const referenced = new Set(Array.from(source.matchAll(/var\((--[\w-]+)/g), (match) => match[1]));
+ expect(Array.from(referenced).filter((name) => !declared.has(name))).toEqual([]);
+ });
+
+ it("provides focus, reduced motion, and forced-color foundations", () => {
+ expect(tokens).toContain("color-scheme: light");
+ expect(tokens).toContain('@media (forced-colors: active)');
+ expect(tokens).toContain('[data-motion="reduce"]');
+ expect(cssRule(".input:focus-visible, .textarea:focus-visible, .select:focus-visible")).toContain("box-shadow");
+ });
+});
diff --git a/packages/ui/src/theme/global.css b/packages/ui/src/theme/global.css
index e30619f..a64c08d 100644
--- a/packages/ui/src/theme/global.css
+++ b/packages/ui/src/theme/global.css
@@ -17,7 +17,7 @@
font-size: var(--font-size-sm);
font-weight: 500;
line-height: 1;
- transition: background 0.12s, border-color 0.12s, color 0.12s;
+ transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
white-space: nowrap;
}
a.btn {
@@ -35,6 +35,10 @@ a.btn {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
+.btn-primary:active {
+ background: var(--color-accent-pressed);
+ border-color: var(--color-accent-pressed);
+}
.btn-ghost {
background: transparent;
border-color: transparent;
@@ -44,7 +48,7 @@ a.btn {
}
.btn-danger {
background: var(--color-danger);
- color: white;
+ color: var(--color-text-on-danger);
border-color: var(--color-danger);
}
.btn-sm {
@@ -104,11 +108,13 @@ a.btn {
color: var(--color-text-primary);
font-family: inherit;
font-size: var(--font-size-sm);
- transition: border-color 0.12s;
+ transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard);
}
-.input:focus, .textarea:focus, .select:focus {
- border-color: var(--color-accent);
- outline: none;
+.input:focus-visible, .textarea:focus-visible, .select:focus-visible {
+ border-color: var(--color-border-focus);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-border-focus) 22%, transparent);
+ outline: 2px solid transparent;
+ outline-offset: 1px;
}
.textarea {
min-height: 80px;
@@ -160,7 +166,7 @@ a.btn {
min-width: 0;
width: 100%;
overflow: hidden;
- transition: grid-template-columns 160ms ease;
+ transition: grid-template-columns var(--duration-standard) var(--ease-standard);
}
.app-shell[data-sidebar-state="collapsed"] {
--active-sidebar-width: var(--sidebar-rail-width);
@@ -228,7 +234,7 @@ a.btn {
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
- border-bottom: 1px solid rgba(255, 255, 255, 0.12);
+ border-bottom: 1px solid var(--color-sidebar-divider);
}
.mobile-nav-sheet .icon-btn {
color: var(--color-text-on-sidebar);
@@ -437,7 +443,7 @@ a.btn {
display: flex;
flex-direction: column;
z-index: 51;
- animation: slideIn 0.18s ease-out;
+ animation: slideIn var(--duration-standard) var(--ease-standard);
}
@keyframes slideIn {
from { transform: translate(-50%, 12px); opacity: 0.6; }
@@ -597,7 +603,7 @@ a.btn {
flex-direction: column;
gap: 6px;
cursor: grab;
- transition: box-shadow 0.12s, transform 0.12s, border-color 0.12s;
+ transition: box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
user-select: none;
}
.board-card:hover {
@@ -1265,10 +1271,16 @@ a.btn {
border-radius: var(--radius-md);
}
.overview-metric {
+ display: grid;
+ align-content: center;
+ gap: var(--space-1);
+ min-height: 88px;
padding: var(--space-3);
+ border-top: 3px solid var(--color-accent);
+ box-shadow: var(--shadow-1);
}
-.overview-metric[data-tone="warning"] { border-color: var(--color-warning); }
-.overview-metric[data-tone="danger"] { border-color: var(--color-danger); }
+.overview-metric[data-tone="warning"] { border-top-color: var(--color-warning); }
+.overview-metric[data-tone="danger"] { border-top-color: var(--color-danger); }
.overview-metric-value {
display: block;
font-size: var(--font-size-2xl);
@@ -2441,6 +2453,212 @@ a.btn {
padding: 10px 0;
border-top: 1px solid var(--color-border-subtle);
}
+.item-detail-section {
+ padding: var(--space-3);
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-surface);
+}
+
+/* ---- Appearance studio ---- */
+.appearance-settings {
+ max-width: 1180px;
+}
+.appearance-control-grid,
+.theme-create-grid,
+.theme-card-grid,
+.theme-token-grid,
+.contrast-check-grid {
+ display: grid;
+ gap: var(--space-3);
+}
+.appearance-control-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+.appearance-scope-row,
+.appearance-actions,
+.appearance-editor-toolbar,
+.appearance-mode-switch,
+.appearance-delete-confirm {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+.theme-card-grid {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+.theme-card {
+ position: relative;
+ display: grid;
+ gap: var(--space-2);
+ min-width: 0;
+ padding: var(--space-3);
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-surface);
+ color: var(--color-text-primary);
+ text-align: left;
+ transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard);
+}
+.theme-card:hover {
+ border-color: var(--color-border-strong);
+ box-shadow: var(--shadow-1);
+ transform: translateY(-1px);
+}
+.theme-card[data-selected="true"] {
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 2px var(--color-accent-soft);
+}
+.theme-card-preview {
+ display: grid;
+ grid-template-columns: 28% 1fr;
+ min-height: 84px;
+ overflow: hidden;
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-sm);
+}
+.theme-preview-sidebar {
+ min-width: 0;
+}
+.theme-preview-surface {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 7px;
+ margin: 10px;
+ padding: 10px;
+ border: 1px solid;
+ border-radius: 5px;
+}
+.theme-preview-surface span {
+ display: block;
+ width: 68%;
+ height: 5px;
+ border-radius: 99px;
+}
+.theme-preview-surface span:nth-child(2) {
+ width: 48%;
+}
+.theme-preview-surface span:nth-child(3) {
+ width: 36%;
+ height: 12px;
+ margin-top: auto;
+}
+.theme-card-copy {
+ display: grid;
+ gap: 2px;
+}
+.theme-card-copy small,
+.theme-card-kind {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-xs);
+ line-height: 1.4;
+}
+.theme-card-kind {
+ position: absolute;
+ top: var(--space-4);
+ right: var(--space-4);
+ padding: 2px 6px;
+ border-radius: 99px;
+ background: var(--color-bg-elevated);
+ box-shadow: var(--shadow-1);
+}
+.appearance-file-button {
+ position: relative;
+ overflow: hidden;
+}
+.appearance-file-button input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+}
+.theme-create-grid {
+ grid-template-columns: minmax(220px, 1fr) minmax(210px, 0.7fr) auto;
+ align-items: end;
+}
+.theme-color-inputs {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ gap: var(--space-2);
+ align-items: center;
+}
+.theme-color-inputs input[type="color"] {
+ width: 42px;
+ height: 34px;
+ padding: 2px;
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-surface);
+}
+.appearance-editor-toolbar {
+ justify-content: space-between;
+}
+.appearance-theme-name {
+ flex: 1 1 280px;
+}
+.theme-token-groups {
+ display: grid;
+ gap: var(--space-2);
+}
+.theme-token-group {
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-elevated);
+}
+.theme-token-group > summary {
+ display: grid;
+ gap: 2px;
+ padding: var(--space-3);
+ cursor: pointer;
+ font-weight: 700;
+}
+.theme-token-group > summary small {
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-xs);
+ font-weight: 400;
+}
+.theme-token-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ padding: 0 var(--space-3) var(--space-3);
+}
+.contrast-report {
+ display: grid;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-muted);
+}
+.contrast-check-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.contrast-check {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ padding: 8px 10px;
+ border-left: 3px solid var(--color-danger);
+ border-radius: var(--radius-xs);
+ background: var(--color-bg-surface);
+ font-size: var(--font-size-xs);
+}
+.contrast-check[data-pass="true"] {
+ border-left-color: var(--color-success);
+}
+.appearance-delete-confirm {
+ padding: var(--space-2);
+ border-radius: var(--radius-sm);
+ background: var(--color-bg-status-blocked);
+}
+.appearance-status {
+ min-height: 1.5em;
+ margin: 0;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
/* ---- Settings grids ---- */
.settings-grid {
@@ -2595,18 +2813,14 @@ a.btn {
padding: 1px 5px;
margin-left: var(--space-1);
border-radius: var(--radius-xs);
- background: rgba(0, 0, 0, 0.08);
- border: 1px solid rgba(0, 0, 0, 0.14);
+ background: var(--color-code-bg);
+ border: 1px solid var(--color-code-border);
font-size: var(--font-size-2xs);
font-family: var(--font-mono);
font-weight: 500;
color: var(--color-text-secondary);
letter-spacing: 0;
}
-[data-theme="dark"] .kbd {
- background: rgba(255, 255, 255, 0.1);
- border-color: rgba(255, 255, 255, 0.16);
-}
/* Icon-only button (theme toggle) */
.icon-btn {
@@ -2716,7 +2930,10 @@ a.btn {
.gph-help-tip-trigger:focus-visible {
color: var(--color-text-primary);
background: var(--color-bg-row-hover);
- outline: none;
+}
+.gph-help-tip-trigger:focus-visible {
+ outline: 2px solid var(--color-border-focus);
+ outline-offset: 2px;
}
.gph-help-tip-trigger svg {
width: 15px;
@@ -2920,10 +3137,52 @@ a.btn {
.settings-nav {
position: static;
max-height: none;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scroll-snap-type: x proximity;
}
.settings-tablist {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ display: flex;
+ flex-direction: row;
+ width: max-content;
+ min-width: 100%;
+ gap: var(--space-2);
+ }
+ .settings-nav-group {
+ display: contents;
+ }
+ .settings-nav-group-title {
+ display: none;
+ }
+ .settings-tab {
+ width: 152px;
+ min-height: 44px;
+ scroll-snap-align: start;
+ }
+ .settings-tab small {
+ display: none;
+ }
+ .appearance-control-grid,
+ .theme-create-grid {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+ .theme-token-grid,
+ .contrast-check-grid {
+ grid-template-columns: 1fr;
+ }
+ .board-toolbar {
+ flex-wrap: nowrap;
+ align-items: end;
+ overflow-x: auto;
+ scroll-snap-type: x proximity;
+ }
+ .board-toolbar > * {
+ flex: 0 0 auto;
+ scroll-snap-align: start;
+ }
+ .board-toolbar .gph-field {
+ width: 180px;
}
.settings-inline-form {
align-items: stretch;
@@ -2996,6 +3255,51 @@ a.btn {
flex-wrap: wrap;
}
.settings-tablist {
- grid-template-columns: minmax(0, 1fr);
+ display: flex;
+ flex-direction: row;
+ }
+ .settings-content-panel {
+ padding: var(--space-3);
+ }
+ .theme-card-grid {
+ grid-template-columns: 1fr;
+ }
+ .appearance-actions > .btn,
+ .appearance-actions > .appearance-file-button {
+ flex: 1 1 auto;
+ }
+ .board {
+ padding: var(--space-3);
+ scroll-snap-type: x mandatory;
+ }
+ .board-column {
+ width: calc(100vw - 32px);
+ scroll-snap-align: start;
+ }
+ .btn,
+ .icon-btn {
+ min-height: 44px;
+ }
+ .icon-btn {
+ width: 44px;
+ }
+}
+
+@media (prefers-reduced-transparency: reduce) {
+ .mobile-nav-backdrop,
+ .modal-backdrop,
+ .drawer-backdrop,
+ .cmdk-backdrop {
+ background: color-mix(in srgb, var(--color-bg-sidebar) 94%, var(--color-bg-canvas));
+ backdrop-filter: none;
+ }
+}
+
+@media (forced-colors: active) {
+ .theme-card[data-selected="true"],
+ .settings-tab-active,
+ .overview-metric,
+ .contrast-check {
+ border-color: Highlight;
}
}
diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts
index 59600e9..9efec2f 100644
--- a/packages/ui/src/theme/index.ts
+++ b/packages/ui/src/theme/index.ts
@@ -1 +1,5 @@
export * from "./theme-provider";
+export * from "./theme-contract";
+export * from "./built-in-themes";
+export * from "./color-utils";
+export * from "./theme-storage";
diff --git a/packages/ui/src/theme/theme-contract.ts b/packages/ui/src/theme/theme-contract.ts
new file mode 100644
index 0000000..71b51c1
--- /dev/null
+++ b/packages/ui/src/theme/theme-contract.ts
@@ -0,0 +1,191 @@
+export type ThemeMode = "light" | "dark" | "system";
+export type ResolvedThemeMode = Exclude;
+export type ThemeContrastPreference = "system" | "standard" | "more";
+export type ThemeMotionPreference = "system" | "reduce" | "full";
+
+export const THEME_SCHEMA_VERSION = 1 as const;
+export const APPEARANCE_SCHEMA_VERSION = 2 as const;
+
+/** Every authored UI color is represented by one semantic role. */
+export const THEME_COLOR_TOKENS = [
+ "color-bg-canvas",
+ "color-bg-surface",
+ "color-bg-elevated",
+ "color-bg-muted",
+ "color-bg-sidebar",
+ "color-bg-sidebar-hover",
+ "color-bg-sidebar-active",
+ "color-bg-row-hover",
+ "color-bg-row-selected",
+ "color-bg-status-ok",
+ "color-bg-status-warn",
+ "color-bg-status-blocked",
+ "color-bg-status-done",
+ "color-bg-overlay",
+ "color-text-primary",
+ "color-text-secondary",
+ "color-text-muted",
+ "color-text-on-accent",
+ "color-text-on-danger",
+ "color-text-on-sidebar",
+ "color-text-on-sidebar-muted",
+ "color-text-on-status",
+ "color-border-subtle",
+ "color-border-strong",
+ "color-border-focus",
+ "color-accent",
+ "color-accent-hover",
+ "color-accent-pressed",
+ "color-accent-soft",
+ "color-accent-contrast",
+ "color-danger",
+ "color-warn",
+ "color-success",
+ "color-info",
+ "color-status-planned",
+ "color-status-active",
+ "color-status-completed",
+ "color-status-canceled",
+ "color-label-blue",
+ "color-label-green",
+ "color-label-orange",
+ "color-label-purple",
+ "color-label-red",
+ "color-label-yellow",
+ "color-shadow",
+ "color-sidebar-divider",
+ "color-code-bg",
+ "color-code-border",
+ "color-code-bg-inverse",
+ "color-code-border-inverse"
+] as const;
+
+export type ThemeColorTokenName = (typeof THEME_COLOR_TOKENS)[number];
+export type ThemeColorTokens = Record;
+
+export type ThemeDefinition = {
+ schemaVersion: typeof THEME_SCHEMA_VERSION;
+ id: string;
+ name: string;
+ description: string;
+ builtIn: boolean;
+ seedColor: string | null;
+ modes: Record;
+ createdAt?: string;
+ updatedAt?: string;
+};
+
+export type AppearancePreferences = {
+ schemaVersion: typeof APPEARANCE_SCHEMA_VERSION;
+ mode: ThemeMode;
+ selectedThemeId: string;
+ projectThemeBindings: Record;
+ contrast: ThemeContrastPreference;
+ motion: ThemeMotionPreference;
+};
+
+export type ThemeTokenGroup = {
+ id: string;
+ label: string;
+ description: string;
+ tokens: Array<{ name: ThemeColorTokenName; label: string }>;
+};
+
+function tokenRows(rows: Array<[ThemeColorTokenName, string]>) {
+ return rows.map(([name, label]) => ({ name, label }));
+}
+
+export const THEME_TOKEN_GROUPS: ThemeTokenGroup[] = [
+ {
+ id: "surfaces",
+ label: "Canvas & surfaces",
+ description: "Workspace layers, navigation, hover states, and overlays.",
+ tokens: tokenRows([
+ ["color-bg-canvas", "App canvas"],
+ ["color-bg-surface", "Primary surface"],
+ ["color-bg-elevated", "Elevated surface"],
+ ["color-bg-muted", "Muted surface"],
+ ["color-bg-sidebar", "Sidebar"],
+ ["color-bg-sidebar-hover", "Sidebar hover"],
+ ["color-bg-sidebar-active", "Sidebar active"],
+ ["color-bg-row-hover", "Row hover"],
+ ["color-bg-row-selected", "Row selected"],
+ ["color-bg-status-ok", "Positive status surface"],
+ ["color-bg-status-warn", "Warning status surface"],
+ ["color-bg-status-blocked", "Blocked status surface"],
+ ["color-bg-status-done", "Completed status surface"],
+ ["color-bg-overlay", "Modal overlay"]
+ ])
+ },
+ {
+ id: "text",
+ label: "Text & icons",
+ description: "Primary hierarchy and text placed on colored surfaces.",
+ tokens: tokenRows([
+ ["color-text-primary", "Primary text"],
+ ["color-text-secondary", "Secondary text"],
+ ["color-text-muted", "Muted text"],
+ ["color-text-on-accent", "Text on accent"],
+ ["color-text-on-danger", "Text on danger"],
+ ["color-text-on-sidebar", "Sidebar text"],
+ ["color-text-on-sidebar-muted", "Muted sidebar text"],
+ ["color-text-on-status", "Status text"]
+ ])
+ },
+ {
+ id: "controls",
+ label: "Controls & focus",
+ description: "Borders, focus rings, links, and interactive accent states.",
+ tokens: tokenRows([
+ ["color-border-subtle", "Subtle border"],
+ ["color-border-strong", "Strong border"],
+ ["color-border-focus", "Focus ring"],
+ ["color-accent", "Accent"],
+ ["color-accent-hover", "Accent hover"],
+ ["color-accent-pressed", "Accent pressed"],
+ ["color-accent-soft", "Soft accent surface"],
+ ["color-accent-contrast", "Accent contrast"]
+ ])
+ },
+ {
+ id: "feedback",
+ label: "Feedback & workflow",
+ description: "Alerts, workflow categories, and state communication.",
+ tokens: tokenRows([
+ ["color-danger", "Danger"],
+ ["color-warn", "Warning"],
+ ["color-success", "Success"],
+ ["color-info", "Information"],
+ ["color-status-planned", "Planned status"],
+ ["color-status-active", "Active status"],
+ ["color-status-completed", "Completed status"],
+ ["color-status-canceled", "Canceled status"]
+ ])
+ },
+ {
+ id: "labels",
+ label: "Labels",
+ description: "Categorical colors used by project labels and work-item chips.",
+ tokens: tokenRows([
+ ["color-label-blue", "Blue label"],
+ ["color-label-green", "Green label"],
+ ["color-label-orange", "Orange label"],
+ ["color-label-purple", "Purple label"],
+ ["color-label-red", "Red label"],
+ ["color-label-yellow", "Yellow label"]
+ ])
+ },
+ {
+ id: "effects",
+ label: "Effects & embedded content",
+ description: "Shadow tint, separators, and code surfaces.",
+ tokens: tokenRows([
+ ["color-shadow", "Shadow tint"],
+ ["color-sidebar-divider", "Sidebar divider"],
+ ["color-code-bg", "Code background"],
+ ["color-code-border", "Code border"],
+ ["color-code-bg-inverse", "Inverse code background"],
+ ["color-code-border-inverse", "Inverse code border"]
+ ])
+ }
+];
diff --git a/packages/ui/src/theme/theme-provider.tsx b/packages/ui/src/theme/theme-provider.tsx
index 0ca63cd..67f2178 100644
--- a/packages/ui/src/theme/theme-provider.tsx
+++ b/packages/ui/src/theme/theme-provider.tsx
@@ -1,72 +1,249 @@
-import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode
+} from "react";
+import { useProjectStore } from "../store/project-store";
+import { BUILT_IN_THEMES, GRILLO_THEME, GRILLO_THEME_ID } from "./built-in-themes";
+import { deriveAccentTokens, deriveHigherContrastTokens } from "./color-utils";
+import {
+ APPEARANCE_STORAGE_KEY,
+ CUSTOM_THEMES_STORAGE_KEY,
+ DEFAULT_APPEARANCE_PREFERENCES,
+ readAppearancePreferences,
+ readCustomThemes,
+ saveAppearancePreferences,
+ saveCustomThemes
+} from "./theme-storage";
+import type {
+ AppearancePreferences,
+ ResolvedThemeMode,
+ ThemeContrastPreference,
+ ThemeDefinition,
+ ThemeMode,
+ ThemeMotionPreference
+} from "./theme-contract";
-export type ThemeMode = "light" | "dark" | "system";
-
-const STORAGE_KEY = "gph.theme";
+export type { ThemeMode } from "./theme-contract";
export type ThemeContextValue = {
theme: ThemeMode;
- resolved: "light" | "dark";
+ resolved: ResolvedThemeMode;
+ preferences: AppearancePreferences;
+ themes: ThemeDefinition[];
+ activeTheme: ThemeDefinition;
+ selectedTheme: ThemeDefinition;
+ projectThemeId: string | null;
+ previewTheme: ThemeDefinition | null;
setTheme: (theme: ThemeMode) => void;
+ setContrast: (contrast: ThemeContrastPreference) => void;
+ setMotion: (motion: ThemeMotionPreference) => void;
+ selectTheme: (themeId: string) => void;
+ setProjectTheme: (projectId: string, themeId: string | null) => void;
+ saveTheme: (theme: ThemeDefinition) => void;
+ deleteTheme: (themeId: string) => void;
+ setPreviewTheme: (theme: ThemeDefinition | null) => void;
+ resetAppearance: () => void;
toggle: () => void;
};
const ThemeContext = createContext(null);
-function resolveSystem(): "light" | "dark" {
- if (typeof window === "undefined") return "light";
- return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+function mediaMatches(query: string): boolean {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia?.(query).matches ?? false;
+}
+
+function resolveSystem(): ResolvedThemeMode {
+ return mediaMatches("(prefers-color-scheme: dark)") ? "dark" : "light";
}
-function readStored(): ThemeMode {
- if (typeof localStorage === "undefined") return "system";
- try {
- const v = localStorage.getItem(STORAGE_KEY);
- if (v === "light" || v === "dark" || v === "system") return v;
- } catch {}
- return "system";
+function subscribeToMedia(query: string, onChange: (matches: boolean) => void) {
+ if (typeof window === "undefined") return () => {};
+ const media = window.matchMedia?.(query);
+ if (!media) return () => {};
+ const handler = () => onChange(media.matches);
+ media.addEventListener?.("change", handler);
+ return () => media.removeEventListener?.("change", handler);
}
export function ThemeProvider({ children }: { children: ReactNode }) {
- const [theme, setThemeState] = useState(() => readStored());
- const [systemResolved, setSystemResolved] = useState<"light" | "dark">(() => resolveSystem());
+ const projectId = useProjectStore((state) => state.bundle?.project.id ?? null);
+ const projectAccent = useProjectStore((state) => state.bundle?.project.accentColor ?? null);
+ const [preferences, setPreferencesState] = useState(() => readAppearancePreferences());
+ const [customThemes, setCustomThemes] = useState(() => readCustomThemes());
+ const [previewTheme, setPreviewTheme] = useState(null);
+ const [systemResolved, setSystemResolved] = useState(() => resolveSystem());
+ const [systemContrastMore, setSystemContrastMore] = useState(() => mediaMatches("(prefers-contrast: more)"));
+ const [systemReducedMotion, setSystemReducedMotion] = useState(() => mediaMatches("(prefers-reduced-motion: reduce)"));
+
+ useEffect(() => subscribeToMedia("(prefers-color-scheme: dark)", (matches) => setSystemResolved(matches ? "dark" : "light")), []);
+ useEffect(() => subscribeToMedia("(prefers-contrast: more)", setSystemContrastMore), []);
+ useEffect(() => subscribeToMedia("(prefers-reduced-motion: reduce)", setSystemReducedMotion), []);
useEffect(() => {
if (typeof window === "undefined") return;
- const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
- if (!mql) return;
- const handler = () => setSystemResolved(mql.matches ? "dark" : "light");
- mql.addEventListener("change", handler);
- return () => mql.removeEventListener("change", handler);
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === APPEARANCE_STORAGE_KEY) setPreferencesState(readAppearancePreferences());
+ if (event.key === CUSTOM_THEMES_STORAGE_KEY) setCustomThemes(readCustomThemes());
+ };
+ window.addEventListener("storage", handleStorage);
+ return () => window.removeEventListener("storage", handleStorage);
}, []);
- const resolved: "light" | "dark" = theme === "system" ? systemResolved : theme;
+ const themes = useMemo(() => [...BUILT_IN_THEMES, ...customThemes], [customThemes]);
+ const selectedTheme = themes.find((entry) => entry.id === preferences.selectedThemeId) ?? GRILLO_THEME;
+ const projectThemeId = projectId ? preferences.projectThemeBindings[projectId] ?? null : null;
+ const projectTheme = projectThemeId ? themes.find((entry) => entry.id === projectThemeId) ?? null : null;
+ const activeTheme = previewTheme ?? projectTheme ?? selectedTheme;
+ const resolved: ResolvedThemeMode = preferences.mode === "system" ? systemResolved : preferences.mode;
+
+ const contrast = preferences.contrast === "system"
+ ? (systemContrastMore ? "more" : "standard")
+ : preferences.contrast;
+ const motion = preferences.motion === "system"
+ ? (systemReducedMotion ? "reduce" : "full")
+ : preferences.motion;
useEffect(() => {
if (typeof document === "undefined") return;
- document.documentElement.setAttribute("data-theme", resolved);
- }, [resolved]);
+ const root = document.documentElement;
+ const sourceTokens = activeTheme.modes[resolved];
+ const accentedTokens = projectAccent ? deriveAccentTokens(sourceTokens, projectAccent, resolved) : sourceTokens;
+ const tokens = contrast === "more" ? deriveHigherContrastTokens(accentedTokens) : accentedTokens;
+ root.setAttribute("data-theme", resolved);
+ root.setAttribute("data-theme-id", activeTheme.id);
+ root.setAttribute("data-contrast", contrast);
+ root.setAttribute("data-motion", motion);
+ root.style.colorScheme = resolved;
+ for (const [token, value] of Object.entries(tokens)) {
+ root.style.setProperty("--" + token, value);
+ }
+ const themeColor = document.querySelector('meta[name="theme-color"]');
+ themeColor?.setAttribute("content", tokens["color-accent"]);
+ }, [activeTheme, contrast, motion, projectAccent, resolved]);
- const setTheme = useCallback((next: ThemeMode) => {
- setThemeState(next);
- try { localStorage.setItem(STORAGE_KEY, next); } catch {}
+ const updatePreferences = useCallback((updater: (current: AppearancePreferences) => AppearancePreferences) => {
+ setPreferencesState((current) => {
+ const next = updater(current);
+ saveAppearancePreferences(next);
+ return next;
+ });
}, []);
- const toggle = useCallback(() => {
- setThemeState((current) => {
- const effective = current === "system" ? systemResolved : current;
- const next: ThemeMode = effective === "dark" ? "light" : "dark";
- try { localStorage.setItem(STORAGE_KEY, next); } catch {}
+ const setTheme = useCallback((mode: ThemeMode) => {
+ updatePreferences((current) => ({ ...current, mode }));
+ }, [updatePreferences]);
+
+ const setContrast = useCallback((next: ThemeContrastPreference) => {
+ updatePreferences((current) => ({ ...current, contrast: next }));
+ }, [updatePreferences]);
+
+ const setMotion = useCallback((next: ThemeMotionPreference) => {
+ updatePreferences((current) => ({ ...current, motion: next }));
+ }, [updatePreferences]);
+
+ const selectTheme = useCallback((themeId: string) => {
+ updatePreferences((current) => ({ ...current, selectedThemeId: themeId }));
+ setPreviewTheme(null);
+ }, [updatePreferences]);
+
+ const setProjectTheme = useCallback((targetProjectId: string, themeId: string | null) => {
+ updatePreferences((current) => {
+ const bindings = { ...current.projectThemeBindings };
+ if (themeId) bindings[targetProjectId] = themeId;
+ else delete bindings[targetProjectId];
+ return { ...current, projectThemeBindings: bindings };
+ });
+ setPreviewTheme(null);
+ }, [updatePreferences]);
+
+ const saveTheme = useCallback((theme: ThemeDefinition) => {
+ setCustomThemes((current) => {
+ const nextTheme = { ...theme, builtIn: false, updatedAt: new Date().toISOString() };
+ const next = current.some((entry) => entry.id === nextTheme.id)
+ ? current.map((entry) => entry.id === nextTheme.id ? nextTheme : entry)
+ : [...current, nextTheme];
+ saveCustomThemes(next);
+ return next;
+ });
+ }, []);
+
+ const deleteTheme = useCallback((themeId: string) => {
+ if (BUILT_IN_THEMES.some((entry) => entry.id === themeId)) return;
+ setCustomThemes((current) => {
+ const next = current.filter((entry) => entry.id !== themeId);
+ saveCustomThemes(next);
return next;
});
- }, [systemResolved]);
+ updatePreferences((current) => ({
+ ...current,
+ selectedThemeId: current.selectedThemeId === themeId ? GRILLO_THEME_ID : current.selectedThemeId,
+ projectThemeBindings: Object.fromEntries(
+ Object.entries(current.projectThemeBindings).filter(([, value]) => value !== themeId)
+ )
+ }));
+ setPreviewTheme((current) => current?.id === themeId ? null : current);
+ }, [updatePreferences]);
+
+ const resetAppearance = useCallback(() => {
+ const next = { ...DEFAULT_APPEARANCE_PREFERENCES, projectThemeBindings: {} };
+ setPreferencesState(next);
+ saveAppearancePreferences(next);
+ setPreviewTheme(null);
+ }, []);
+
+ const toggle = useCallback(() => {
+ const effective = preferences.mode === "system" ? systemResolved : preferences.mode;
+ setTheme(effective === "dark" ? "light" : "dark");
+ }, [preferences.mode, setTheme, systemResolved]);
+
+ const value = useMemo(() => ({
+ theme: preferences.mode,
+ resolved,
+ preferences,
+ themes,
+ activeTheme,
+ selectedTheme,
+ projectThemeId,
+ previewTheme,
+ setTheme,
+ setContrast,
+ setMotion,
+ selectTheme,
+ setProjectTheme,
+ saveTheme,
+ deleteTheme,
+ setPreviewTheme,
+ resetAppearance,
+ toggle
+ }), [
+ activeTheme,
+ deleteTheme,
+ preferences,
+ previewTheme,
+ projectThemeId,
+ resolved,
+ saveTheme,
+ selectTheme,
+ selectedTheme,
+ setContrast,
+ setMotion,
+ setProjectTheme,
+ setTheme,
+ themes,
+ toggle
+ ]);
- const value = useMemo(() => ({ theme, resolved, setTheme, toggle }), [theme, resolved, setTheme, toggle]);
return {children} ;
}
export function useTheme(): ThemeContextValue {
- const ctx = useContext(ThemeContext);
- if (!ctx) throw new Error("useTheme must be used inside ");
- return ctx;
+ const context = useContext(ThemeContext);
+ if (!context) throw new Error("useTheme must be used inside ");
+ return context;
}
diff --git a/packages/ui/src/theme/theme-storage.test.ts b/packages/ui/src/theme/theme-storage.test.ts
new file mode 100644
index 0000000..d9b919e
--- /dev/null
+++ b/packages/ui/src/theme/theme-storage.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import { GRILLO_THEME, themeFromSeed } from "./built-in-themes";
+import {
+ APPEARANCE_STORAGE_KEY,
+ CUSTOM_THEMES_STORAGE_KEY,
+ exportThemeJson,
+ parseImportedTheme,
+ readAppearancePreferences,
+ readCustomThemes,
+ saveCustomThemes
+} from "./theme-storage";
+
+function memoryStorage(initial: Record = {}): Storage {
+ const data = new Map(Object.entries(initial));
+ return {
+ get length() { return data.size; },
+ clear: () => data.clear(),
+ getItem: (key) => data.get(key) ?? null,
+ key: (index) => Array.from(data.keys())[index] ?? null,
+ removeItem: (key) => data.delete(key),
+ setItem: (key, value) => data.set(key, value)
+ };
+}
+
+describe("theme storage", () => {
+ it("migrates the legacy color mode into versioned appearance preferences", () => {
+ const storage = memoryStorage({ "gph.theme": "dark" });
+ expect(readAppearancePreferences(storage)).toMatchObject({ mode: "dark", selectedThemeId: "grillo-adaptive" });
+ });
+
+ it("repairs malformed preference fields without losing valid choices", () => {
+ const storage = memoryStorage({
+ [APPEARANCE_STORAGE_KEY]: JSON.stringify({ mode: "light", selectedThemeId: "graphite", contrast: "invalid" })
+ });
+ expect(readAppearancePreferences(storage)).toMatchObject({
+ mode: "light",
+ selectedThemeId: "graphite",
+ contrast: "system",
+ projectThemeBindings: {}
+ });
+ });
+
+ it("round-trips custom themes and strips built-in authority", () => {
+ const storage = memoryStorage();
+ const custom = themeFromSeed({ id: "custom-ocean", name: "Ocean", seedColor: "#245ea8" });
+ saveCustomThemes([custom, GRILLO_THEME], storage);
+ expect(JSON.parse(storage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toHaveLength(1);
+ expect(readCustomThemes(storage)[0]).toMatchObject({ id: "custom-ocean", builtIn: false });
+ });
+
+ it("accepts complete semantic theme JSON and rejects arbitrary or incomplete data", () => {
+ const exported = exportThemeJson(GRILLO_THEME);
+ expect(parseImportedTheme(exported)).toMatchObject({ name: "Grillo Adaptive", builtIn: false });
+ expect(() => parseImportedTheme(JSON.stringify({ id: "unsafe", name: "Unsafe", modes: { light: {}, dark: {} } }))).toThrow(/semantic color roles/);
+ expect(() => parseImportedTheme("not json")).toThrow(/valid JSON/);
+ });
+});
diff --git a/packages/ui/src/theme/theme-storage.ts b/packages/ui/src/theme/theme-storage.ts
new file mode 100644
index 0000000..efb351a
--- /dev/null
+++ b/packages/ui/src/theme/theme-storage.ts
@@ -0,0 +1,150 @@
+import { GRILLO_THEME_ID } from "./built-in-themes";
+import { normalizeThemeColor } from "./color-utils";
+import {
+ APPEARANCE_SCHEMA_VERSION,
+ THEME_COLOR_TOKENS,
+ THEME_SCHEMA_VERSION,
+ type AppearancePreferences,
+ type ThemeColorTokens,
+ type ThemeDefinition,
+ type ThemeMode
+} from "./theme-contract";
+
+export const APPEARANCE_STORAGE_KEY = "gph.appearance.v2";
+export const CUSTOM_THEMES_STORAGE_KEY = "gph.custom-themes.v1";
+export const LEGACY_THEME_STORAGE_KEY = "gph.theme";
+
+export const DEFAULT_APPEARANCE_PREFERENCES: AppearancePreferences = {
+ schemaVersion: APPEARANCE_SCHEMA_VERSION,
+ mode: "system",
+ selectedThemeId: GRILLO_THEME_ID,
+ projectThemeBindings: {},
+ contrast: "system",
+ motion: "system"
+};
+
+function browserStorage(): Storage | null {
+ if (typeof localStorage === "undefined") return null;
+ return localStorage;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isThemeMode(value: unknown): value is ThemeMode {
+ return value === "light" || value === "dark" || value === "system";
+}
+
+export function readAppearancePreferences(storage: Storage | null = browserStorage()): AppearancePreferences {
+ if (!storage) return { ...DEFAULT_APPEARANCE_PREFERENCES };
+ try {
+ const raw = storage.getItem(APPEARANCE_STORAGE_KEY);
+ if (raw) {
+ const parsed: unknown = JSON.parse(raw);
+ if (isRecord(parsed)) {
+ const bindings = isRecord(parsed.projectThemeBindings)
+ ? Object.fromEntries(Object.entries(parsed.projectThemeBindings).filter((entry): entry is [string, string] => typeof entry[1] === "string"))
+ : {};
+ return {
+ schemaVersion: APPEARANCE_SCHEMA_VERSION,
+ mode: isThemeMode(parsed.mode) ? parsed.mode : "system",
+ selectedThemeId: typeof parsed.selectedThemeId === "string" ? parsed.selectedThemeId : GRILLO_THEME_ID,
+ projectThemeBindings: bindings,
+ contrast: parsed.contrast === "more" || parsed.contrast === "standard" ? parsed.contrast : "system",
+ motion: parsed.motion === "reduce" || parsed.motion === "full" ? parsed.motion : "system"
+ };
+ }
+ }
+
+ const legacy = storage.getItem(LEGACY_THEME_STORAGE_KEY);
+ if (isThemeMode(legacy)) {
+ return { ...DEFAULT_APPEARANCE_PREFERENCES, mode: legacy };
+ }
+ } catch {}
+ return { ...DEFAULT_APPEARANCE_PREFERENCES };
+}
+
+export function saveAppearancePreferences(
+ preferences: AppearancePreferences,
+ storage: Storage | null = browserStorage()
+): void {
+ if (!storage) return;
+ try {
+ storage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(preferences));
+ storage.removeItem(LEGACY_THEME_STORAGE_KEY);
+ } catch {}
+}
+
+function sanitizeTokenMap(value: unknown): ThemeColorTokens | null {
+ if (!isRecord(value)) return null;
+ const tokens = {} as ThemeColorTokens;
+ for (const token of THEME_COLOR_TOKENS) {
+ const raw = value[token];
+ if (typeof raw !== "string") return null;
+ const normalized = normalizeThemeColor(raw);
+ if (!normalized) return null;
+ tokens[token] = normalized;
+ }
+ return tokens;
+}
+
+export function sanitizeThemeDefinition(value: unknown): ThemeDefinition | null {
+ if (!isRecord(value) || !isRecord(value.modes)) return null;
+ const light = sanitizeTokenMap(value.modes.light);
+ const dark = sanitizeTokenMap(value.modes.dark);
+ if (!light || !dark) return null;
+ const id = typeof value.id === "string" ? value.id.trim().slice(0, 100) : "";
+ const name = typeof value.name === "string" ? value.name.trim().slice(0, 80) : "";
+ if (!id || !name) return null;
+ const seedColor = typeof value.seedColor === "string" ? normalizeThemeColor(value.seedColor) : null;
+ return {
+ schemaVersion: THEME_SCHEMA_VERSION,
+ id,
+ name,
+ description: typeof value.description === "string" ? value.description.trim().slice(0, 240) : "A custom Grillo theme.",
+ builtIn: false,
+ seedColor,
+ modes: { light, dark },
+ createdAt: typeof value.createdAt === "string" ? value.createdAt : undefined,
+ updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : undefined
+ };
+}
+
+export function readCustomThemes(storage: Storage | null = browserStorage()): ThemeDefinition[] {
+ if (!storage) return [];
+ try {
+ const raw = storage.getItem(CUSTOM_THEMES_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.map(sanitizeThemeDefinition).filter((theme): theme is ThemeDefinition => theme !== null);
+ } catch {
+ return [];
+ }
+}
+
+export function saveCustomThemes(themes: ThemeDefinition[], storage: Storage | null = browserStorage()): void {
+ if (!storage) return;
+ try {
+ storage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(themes.filter((theme) => !theme.builtIn)));
+ } catch {}
+}
+
+export function parseImportedTheme(raw: string): ThemeDefinition {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new Error("Theme file is not valid JSON.");
+ }
+ const theme = sanitizeThemeDefinition(parsed);
+ if (!theme) {
+ throw new Error("Theme file is missing required semantic color roles or contains unsupported values.");
+ }
+ return theme;
+}
+
+export function exportThemeJson(theme: ThemeDefinition): string {
+ return JSON.stringify({ ...theme, builtIn: false }, null, 2);
+}
diff --git a/packages/ui/src/theme/tokens.css b/packages/ui/src/theme/tokens.css
index b3a3f9d..73312cc 100644
--- a/packages/ui/src/theme/tokens.css
+++ b/packages/ui/src/theme/tokens.css
@@ -1,11 +1,11 @@
/**
- * Design tokens for the Grillo Project Hub.
- * Polished + friendly visual character, balanced density, subtle nature accent.
- * Light and dark themes are first-class, built from shared semantic tokens.
+ * Grillo semantic design tokens.
+ * Runtime themes override only --color-* roles; layout, type, and motion stay shared.
*/
:root {
- /* Light theme (default) */
+ color-scheme: light;
+
--color-bg-canvas: #f7f5f0;
--color-bg-surface: #ffffff;
--color-bg-elevated: #ffffff;
@@ -14,71 +14,91 @@
--color-bg-sidebar-hover: #38433c;
--color-bg-sidebar-active: #435049;
--color-bg-row-hover: #f0ede5;
- --color-bg-row-selected: #e6efe1;
- --color-bg-status-ok: #e6efe1;
+ --color-bg-row-selected: #e3eee0;
+ --color-bg-status-ok: #e3eee0;
--color-bg-status-warn: #fbf2dc;
--color-bg-status-blocked: #f4dada;
--color-bg-status-done: #d8e6d0;
- --color-bg-overlay: rgba(28, 36, 32, 0.36);
+ --color-bg-overlay: #1c24205c;
--color-text-primary: #1d2620;
- --color-text-secondary: #59615a;
- --color-text-muted: #8a9089;
+ --color-text-secondary: #525a53;
+ --color-text-muted: #687069;
--color-text-on-accent: #ffffff;
- --color-text-on-sidebar: #e3e8e1;
- --color-text-on-sidebar-muted: #a4ada3;
+ --color-text-on-danger: #ffffff;
+ --color-text-on-sidebar: #f1f4ef;
+ --color-text-on-sidebar-muted: #bac2b9;
--color-text-on-status: #2a3530;
- --color-border-subtle: #e2dfd6;
- --color-border-strong: #c6c2b3;
- --color-border-focus: #4f8a55;
+ --color-border-subtle: #d7d3c9;
+ --color-border-strong: #999487;
+ --color-border-focus: #3f7647;
- --color-accent: #4f8a55;
- --color-accent-hover: #437549;
- --color-accent-soft: #dde9d4;
+ --color-accent: #3f7647;
+ --color-accent-hover: #35643c;
+ --color-accent-pressed: #2b5332;
+ --color-accent-soft: #dce9d7;
--color-accent-contrast: #ffffff;
- --color-danger: #b13a3a;
- --color-warn: #d29a3a;
+ --color-danger: #a52f35;
+ --color-warn: #9b6818;
+ --color-warning: var(--color-warn);
+ --color-success: #2e6a4d;
+ --color-info: #356f9f;
- --color-status-planned: #6b7a6a;
- --color-status-active: #4f8a55;
+ --color-status-planned: #59675a;
+ --color-status-active: #3f7647;
--color-status-completed: #2e6a4d;
- --color-status-canceled: #8a6e3a;
-
- --shadow-1: 0 1px 2px rgba(28, 36, 32, 0.06), 0 1px 3px rgba(28, 36, 32, 0.04);
- --shadow-2: 0 2px 6px rgba(28, 36, 32, 0.08), 0 4px 16px rgba(28, 36, 32, 0.06);
- --shadow-3: 0 4px 16px rgba(28, 36, 32, 0.12), 0 12px 32px rgba(28, 36, 32, 0.08);
-
- --radius-xs: 4px;
- --radius-sm: 6px;
- --radius-md: 10px;
- --radius-lg: 14px;
- --radius-xl: 20px;
+ --color-status-canceled: #7a5c27;
+
+ --color-label-blue: #dceaf7;
+ --color-label-green: #dcebdc;
+ --color-label-orange: #f7e4cf;
+ --color-label-purple: #e9dff4;
+ --color-label-red: #f4dada;
+ --color-label-yellow: #f6edc7;
+
+ --color-shadow: #1c2420;
+ --color-sidebar-divider: #ffffff1f;
+ --color-code-bg: #00000014;
+ --color-code-border: #00000024;
+ --color-code-bg-inverse: #ffffff1a;
+ --color-code-border-inverse: #ffffff29;
+
+ --shadow-1: 0 1px 2px color-mix(in srgb, var(--color-shadow) 6%, transparent), 0 1px 3px color-mix(in srgb, var(--color-shadow) 4%, transparent);
+ --shadow-2: 0 2px 6px color-mix(in srgb, var(--color-shadow) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-shadow) 6%, transparent);
+ --shadow-3: 0 4px 16px color-mix(in srgb, var(--color-shadow) 12%, transparent), 0 12px 32px color-mix(in srgb, var(--color-shadow) 8%, transparent);
+
+ --radius-xs: 0.25rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.625rem;
+ --radius-lg: 0.875rem;
+ --radius-xl: 1.25rem;
- /* Prefer native system fonts (ui-sans-serif first) for reliable offline/desktop rendering across platforms.
- "Inter" was listed previously but never actually loaded via @font-face or in the apps, so this is a no-op for rendering
- but clarifies the intent to avoid assuming a specific web font. */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI Variable Text", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- --font-size-2xs: 11px;
- --font-size-xs: 12px;
- --font-size-sm: 13px;
- --font-size-md: 14px;
- --font-size-lg: 16px;
- --font-size-xl: 20px;
- --font-size-2xl: 24px;
- --font-size-3xl: 30px;
-
- --space-1: 4px;
- --space-2: 8px;
- --space-3: 12px;
- --space-4: 16px;
- --space-5: 20px;
- --space-6: 24px;
- --space-7: 32px;
- --space-8: 40px;
+ --font-size-2xs: 0.6875rem;
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.8125rem;
+ --font-size-md: 0.875rem;
+ --font-size-lg: 1rem;
+ --font-size-xl: 1.25rem;
+ --font-size-2xl: 1.5rem;
+ --font-size-3xl: 1.875rem;
+
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-7: 2rem;
+ --space-8: 2.5rem;
+
+ --duration-fast: 120ms;
+ --duration-standard: 180ms;
+ --ease-standard: cubic-bezier(0.2, 0, 0, 1);
--sidebar-width: 232px;
--sidebar-rail-width: 56px;
@@ -91,11 +111,12 @@
}
[data-theme="dark"] {
+ color-scheme: dark;
--color-bg-canvas: #1c2420;
--color-bg-surface: #232b27;
--color-bg-elevated: #2a322d;
--color-bg-muted: #1a201d;
- --color-bg-sidebar: #161b18;
+ --color-bg-sidebar: #111713;
--color-bg-sidebar-hover: #1f2622;
--color-bg-sidebar-active: #2a312c;
--color-bg-row-hover: #2a312c;
@@ -104,36 +125,43 @@
--color-bg-status-warn: #3a3220;
--color-bg-status-blocked: #3a2828;
--color-bg-status-done: #233228;
- --color-bg-overlay: rgba(0, 0, 0, 0.55);
-
- --color-text-primary: #e6e8e3;
- --color-text-secondary: #b1b6ad;
- --color-text-muted: #80847c;
- --color-text-on-accent: #ffffff;
- --color-text-on-sidebar: #d5d9d0;
- --color-text-on-sidebar-muted: #8e948a;
- --color-text-on-status: #e6e8e3;
-
- --color-border-subtle: #2d3530;
- --color-border-strong: #3d4640;
- --color-border-focus: #6aa472;
-
- --color-accent: #6aa472;
- --color-accent-hover: #76b07e;
- --color-accent-soft: #2d3a32;
- --color-accent-contrast: #ffffff;
-
- --color-danger: #d05d5d;
- --color-warn: #d8ad60;
-
- --color-status-planned: #98a394;
- --color-status-active: #6aa472;
- --color-status-completed: #4f8a55;
- --color-status-canceled: #b39160;
-
- --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.25), 0 1px 3px rgba(0, 0, 0, 0.2);
- --shadow-2: 0 2px 6px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.25);
- --shadow-3: 0 4px 16px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.3);
+ --color-bg-overlay: #0000008c;
+ --color-text-primary: #f0f2ed;
+ --color-text-secondary: #c2c8bf;
+ --color-text-muted: #9da49a;
+ --color-text-on-accent: #101713;
+ --color-text-on-danger: #101713;
+ --color-text-on-sidebar: #edf1e9;
+ --color-text-on-sidebar-muted: #aab3a7;
+ --color-text-on-status: #f0f2ed;
+ --color-border-subtle: #3b443e;
+ --color-border-strong: #6b766e;
+ --color-border-focus: #79b982;
+ --color-accent: #79b982;
+ --color-accent-hover: #89c991;
+ --color-accent-pressed: #98d7a0;
+ --color-accent-soft: #304238;
+ --color-accent-contrast: #101713;
+ --color-danger: #ef7f83;
+ --color-warn: #e2b766;
+ --color-success: #79b982;
+ --color-info: #83b8e2;
+ --color-status-planned: #b4c0b1;
+ --color-status-active: #79b982;
+ --color-status-completed: #64aa78;
+ --color-status-canceled: #d3b071;
+ --color-label-blue: #2d4355;
+ --color-label-green: #304638;
+ --color-label-orange: #4b3929;
+ --color-label-purple: #40364e;
+ --color-label-red: #4c3032;
+ --color-label-yellow: #494328;
+ --color-shadow: #000000;
+ --color-sidebar-divider: #ffffff1f;
+ --color-code-bg: #ffffff1a;
+ --color-code-border: #ffffff29;
+ --color-code-bg-inverse: #00000024;
+ --color-code-border-inverse: #00000038;
}
* {
@@ -167,6 +195,7 @@ a {
color: var(--color-accent);
text-decoration: none;
}
+
a:hover {
text-decoration: underline;
}
@@ -177,10 +206,26 @@ a:hover {
border-radius: var(--radius-xs);
}
+[data-motion="reduce"] *,
+[data-motion="reduce"] *::before,
+[data-motion="reduce"] *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+}
+
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
+ scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+
+@media (forced-colors: active) {
+ :focus-visible {
+ outline: 2px solid Highlight;
+ }
+}
diff --git a/packages/ui/src/views/board/BoardView.tsx b/packages/ui/src/views/board/BoardView.tsx
index 0d63693..f2620d1 100644
--- a/packages/ui/src/views/board/BoardView.tsx
+++ b/packages/ui/src/views/board/BoardView.tsx
@@ -180,7 +180,7 @@ export function BoardView({ view }: BoardViewProps) {
return (
-
+
state.bundle);
+ const applyCommand = useProjectStore((state) => state.applyCommand);
+ const {
+ activeTheme,
+ deleteTheme,
+ preferences,
+ previewTheme,
+ projectThemeId,
+ resetAppearance,
+ resolved,
+ saveTheme,
+ selectedTheme,
+ selectTheme,
+ setContrast,
+ setMotion,
+ setPreviewTheme,
+ setProjectTheme,
+ setTheme,
+ themes
+ } = useTheme();
+ const [scope, setScope] = useState(() => projectThemeId ? "project" : "global");
+ const [newThemeName, setNewThemeName] = useState("My Grillo theme");
+ const [seedColor, setSeedColor] = useState(activeTheme.modes[resolved]["color-accent"]);
+ const [draft, setDraft] = useState(null);
+ const [editorMode, setEditorMode] = useState(resolved);
+ const [status, setStatus] = useState("");
+ const [deleteArmed, setDeleteArmed] = useState(false);
+ const [accentDraft, setAccentDraft] = useState(bundle?.project.accentColor ?? "");
+
+ const projectId = bundle?.project.id ?? null;
+ const scopedThemeId = scope === "project" && projectThemeId ? projectThemeId : selectedTheme.id;
+ const scopedTheme = themes.find((entry) => entry.id === scopedThemeId) ?? selectedTheme;
+ const editingTheme = draft ?? (!scopedTheme.builtIn ? scopedTheme : null);
+ const contrastChecks = useMemo(() => editingTheme ? auditThemeContrast(editingTheme) : [], [editingTheme]);
+ const contrastWarnings = contrastChecks.filter((entry) => !entry.pass);
+
+ useEffect(() => {
+ setAccentDraft(bundle?.project.accentColor ?? "");
+ }, [bundle?.project.accentColor]);
+
+ useEffect(() => () => setPreviewTheme(null), [setPreviewTheme]);
+
+ useEffect(() => {
+ if (previewTheme && draft) setPreviewTheme(draft);
+ }, [draft, previewTheme, setPreviewTheme]);
+
+ if (!bundle) return null;
+
+ const applyTheme = (themeId: string) => {
+ if (scope === "project" && projectId) setProjectTheme(projectId, themeId);
+ else selectTheme(themeId);
+ setDraft(null);
+ setDeleteArmed(false);
+ setStatus(scope === "project" ? "Theme applied to this project on this device." : "Default theme updated on this device.");
+ };
+
+ const createTheme = () => {
+ const normalizedSeed = normalizeThemeColor(seedColor);
+ if (!normalizedSeed || normalizedSeed.length !== 7 || !newThemeName.trim()) {
+ setStatus("Enter a name and a six-digit seed color.");
+ return;
+ }
+ const next = themeFromSeed({
+ id: uniqueThemeId(newThemeName, themes),
+ name: newThemeName.trim(),
+ seedColor: normalizedSeed,
+ source: activeTheme
+ });
+ saveTheme(next);
+ applyTheme(next.id);
+ setDraft(next);
+ setStatus("Custom theme created. Fine-tune any semantic role below.");
+ };
+
+ const cloneTheme = () => {
+ const now = new Date().toISOString();
+ const next: ThemeDefinition = {
+ ...activeTheme,
+ id: uniqueThemeId(activeTheme.name + " copy", themes),
+ name: activeTheme.name + " Copy",
+ description: "A fully editable copy of " + activeTheme.name + ".",
+ builtIn: false,
+ modes: { light: { ...activeTheme.modes.light }, dark: { ...activeTheme.modes.dark } },
+ createdAt: now,
+ updatedAt: now
+ };
+ saveTheme(next);
+ applyTheme(next.id);
+ setDraft(next);
+ setStatus("Editable copy created.");
+ };
+
+ const saveDraft = () => {
+ if (!draft) return;
+ const safe = sanitizeThemeDefinition(draft);
+ if (!safe) {
+ setStatus("Fix invalid colors before saving. Use hex values such as #3f7647 or #1c24205c.");
+ return;
+ }
+ saveTheme(safe);
+ applyTheme(safe.id);
+ setDraft(null);
+ setPreviewTheme(null);
+ setStatus(contrastWarnings.length > 0
+ ? "Theme saved with contrast warnings. Review the accessibility report before sharing it."
+ : "Theme saved. All tested text and control pairs meet their target contrast.");
+ };
+
+ const importTheme = async (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ if (!file) return;
+ try {
+ const imported = parseImportedTheme(await file.text());
+ const next = {
+ ...imported,
+ id: themes.some((entry) => entry.id === imported.id) ? uniqueThemeId(imported.name, themes) : imported.id,
+ builtIn: false
+ };
+ saveTheme(next);
+ applyTheme(next.id);
+ setDraft(next);
+ setStatus("Theme imported safely. Only recognized semantic color values were accepted.");
+ } catch (error) {
+ setStatus(error instanceof Error ? error.message : "Theme could not be imported.");
+ }
+ };
+
+ const exportActiveTheme = () => {
+ const blob = new Blob([exportThemeJson(scopedTheme)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = slugify(scopedTheme.name) + ".gph-theme.json";
+ anchor.click();
+ URL.revokeObjectURL(url);
+ setStatus("Theme exported as portable, versioned JSON.");
+ };
+
+ const applyProjectAccent = () => {
+ const normalized = normalizeThemeColor(accentDraft);
+ if (!normalized || normalized.length !== 7) {
+ setStatus("Project accent must be a six-digit hex color.");
+ return;
+ }
+ applyCommand({
+ type: "project.updateSettings",
+ projectId: bundle.project.id,
+ patch: { accentColor: normalized }
+ });
+ setStatus("Shared project accent updated. Grillo automatically repairs its button text color.");
+ };
return (
-
+
-
- Theme
- setTheme(event.target.value as "light" | "dark" | "system")}>
- System
- Light
- Dark
-
-
+
+
+
+
+ Color mode
+ setTheme(event.target.value as "light" | "dark" | "system")}>
+ Follow system
+ Light
+ Dark
+
+
+
+ Contrast
+ setContrast(event.target.value as "system" | "standard" | "more")}>
+ Follow system
+ Standard
+ More contrast
+
+
+
+ Motion
+ setMotion(event.target.value as "system" | "reduce" | "full")}>
+ Follow system
+ Reduce motion
+ Full motion
+
+
+
+
+
+
+
+ setScope("global")}>Default on this device
+ setScope("project")}>Only {bundle.project.name}
+ {projectThemeId ? setProjectTheme(bundle.project.id, null)}>Clear project override : null}
+
+
+ {themes.map((theme) => (
+ applyTheme(theme.id)}
+ theme={theme}
+ />
+ ))}
+
+
+ Clone active theme
+ Export selected
+
+ Import theme
+
+
+ Reset appearance
+
+
+
+
+
+
+ Theme name
+ setNewThemeName(event.target.value)} />
+
+
+ Create theme
+
+
+
+
+
+
+ Apply project accent
+ {
+ setAccentDraft("");
+ applyCommand({ type: "project.updateSettings", projectId: bundle.project.id, patch: { accentColor: null } });
+ setStatus("Project accent now follows each person's selected theme.");
+ }}
+ >
+ Follow theme
+
+
+
+
+ {editingTheme ? (
+
+
+
+ Theme name
+ setDraft({ ...editingTheme, name: event.target.value, builtIn: false })}
+ />
+
+
+ setEditorMode("light")}>Light values
+ setEditorMode("dark")}>Dark values
+
+
+
+
+ {THEME_TOKEN_GROUPS.map((group, index) => (
+
+
+ {group.label}
+ {group.description}
+
+
+ {group.tokens.map((token) => (
+ setDraft({
+ ...editingTheme,
+ builtIn: false,
+ modes: {
+ ...editingTheme.modes,
+ [editorMode]: { ...editingTheme.modes[editorMode], [token.name]: value }
+ }
+ })}
+ />
+ ))}
+
+
+ ))}
+
+
+
+
+
Accessibility report
+
Warnings never lock you out, but shared themes should pass text at 4.5:1 and essential boundaries at 3:1.
+
+
+ {contrastChecks.map((check) => (
+
+ {check.label}
+ {check.ratio.toFixed(2)}:1 / {check.target}:1
+
+ ))}
+
+
+
+
+ setPreviewTheme(previewTheme ? null : editingTheme)}
+ >
+ {previewTheme ? "Stop preview" : "Preview across app"}
+
+ Save theme{contrastWarnings.length ? " anyway" : ""}
+ { setDraft(null); setPreviewTheme(null); }}>Discard draft
+ {!editingTheme.builtIn ? (
+ deleteArmed ? (
+
+ Delete {editingTheme.name}?
+ { deleteTheme(editingTheme.id); setDraft(null); setDeleteArmed(false); }}>Confirm delete
+ setDeleteArmed(false)}>Cancel
+
+ ) : setDeleteArmed(true)}>Delete theme
+ ) : null}
+
+
+ ) : null}
+
+
{status}
);
}
+
+function ThemeCard({
+ checked,
+ mode,
+ onSelect,
+ theme
+}: {
+ checked: boolean;
+ mode: ResolvedThemeMode;
+ onSelect: () => void;
+ theme: ThemeDefinition;
+}) {
+ const tokens = theme.modes[mode];
+ return (
+
+
+
+
+
+
+
+
+
+
+ {theme.name}
+ {theme.description}
+
+ {theme.builtIn ? "Built in" : "Custom"}
+
+ );
+}
+
+function ColorValueInput({ label, onChange, value }: { label: string; onChange: (value: string) => void; value: string }) {
+ const colorValue = isValidThemeColor(value) ? (normalizeThemeColor(value)?.slice(0, 7) ?? "#000000") : "#000000";
+ return (
+
+ {label}
+
+ onChange(event.target.value)} />
+ onChange(event.target.value)}
+ />
+
+
+ );
+}
+
+function uniqueThemeId(name: string, themes: ThemeDefinition[]): string {
+ const base = slugify(name) || "custom-theme";
+ let candidate = "custom-" + base;
+ let suffix = 2;
+ while (themes.some((theme) => theme.id === candidate)) {
+ candidate = "custom-" + base + "-" + suffix;
+ suffix += 1;
+ }
+ return candidate;
+}
+
+function slugify(value: string): string {
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
+}
+
+function auditThemeContrast(theme: ThemeDefinition) {
+ return (["light", "dark"] as ResolvedThemeMode[]).flatMap((mode) => {
+ const tokens = theme.modes[mode];
+ const checks = [
+ ["Primary text", "color-text-primary", "color-bg-surface", 4.5],
+ ["Secondary text", "color-text-secondary", "color-bg-surface", 4.5],
+ ["Accent button", "color-text-on-accent", "color-accent", 4.5],
+ ["Strong boundary", "color-border-strong", "color-bg-surface", 3]
+ ] as const;
+ return checks.map(([label, foreground, background, target]) => {
+ const ratio = contrastRatio(tokens[foreground], tokens[background]);
+ return { id: mode + "-" + foreground, label: mode + " · " + label, ratio, target, pass: ratio >= target };
+ });
+ });
+}
diff --git a/packages/ui/src/views/settings/LabelsMilestonesSettings.tsx b/packages/ui/src/views/settings/LabelsMilestonesSettings.tsx
index 4fd23c6..6e04f4a 100644
--- a/packages/ui/src/views/settings/LabelsMilestonesSettings.tsx
+++ b/packages/ui/src/views/settings/LabelsMilestonesSettings.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { colorForLabel } from "../../components";
import { useProjectStore } from "../../store/project-store";
import { SettingsPanelHeader, SettingsSectionCard } from "./settings-shared";
@@ -43,7 +44,7 @@ export function LabelsMilestonesSettings() {
{bundle.core.labels.map((label) => (
- {label.name}
+ {label.name}
{label.description ?? ""}
diff --git a/packages/ui/src/views/settings/SettingsView.test.tsx b/packages/ui/src/views/settings/SettingsView.test.tsx
index 46b46b4..9e572e2 100644
--- a/packages/ui/src/views/settings/SettingsView.test.tsx
+++ b/packages/ui/src/views/settings/SettingsView.test.tsx
@@ -10,6 +10,7 @@ import { SettingsView } from "./SettingsView";
describe("SettingsView", () => {
beforeEach(() => {
cleanup();
+ localStorage.clear();
const bundle = buildProjectFromTemplate("software-project", "Settings");
useProjectStore.setState({
bundle,
@@ -169,7 +170,9 @@ describe("SettingsView", () => {
}
await userEvent.click(within(tablist).getByRole("tab", { name: "Appearance" }));
- expect(screen.getByRole("tabpanel", { name: "Appearance" })).toContainElement(screen.getByLabelText("Theme"));
+ const appearancePanel = screen.getByRole("tabpanel", { name: "Appearance" });
+ expect(within(appearancePanel).getByLabelText("Color mode")).toBeInTheDocument();
+ expect(within(appearancePanel).getByRole("radiogroup", { name: "Available themes" })).toBeInTheDocument();
await userEvent.click(within(tablist).getByRole("tab", { name: "Storage" }));
expect(screen.getByRole("tabpanel", { name: "Storage" })).toHaveTextContent("Browser-local");
@@ -240,6 +243,53 @@ describe("SettingsView", () => {
expect(within(screen.getByRole("tabpanel", { name: "Workflow" })).getByDisplayValue("Draft status")).toBeInTheDocument();
});
+ it("offers polished theme presets and applies one through the semantic runtime", async () => {
+ render(
+
+
+
+
+
+ );
+
+ await userEvent.click(screen.getByRole("tab", { name: "Appearance" }));
+ const panel = screen.getByRole("tabpanel", { name: "Appearance" });
+ expect(within(panel).getByRole("radio", { name: /Grillo Adaptive/i })).toBeInTheDocument();
+ expect(within(panel).getByRole("radio", { name: /Graphite/i })).toBeInTheDocument();
+ expect(within(panel).getByRole("radio", { name: /Warm Sand/i })).toBeInTheDocument();
+ expect(within(panel).getByRole("radio", { name: /High Contrast/i })).toBeInTheDocument();
+
+ await userEvent.click(within(panel).getByRole("radio", { name: /Graphite/i }));
+ await waitFor(() => expect(document.documentElement).toHaveAttribute("data-theme-id", "graphite"));
+ expect(JSON.parse(localStorage.getItem("gph.appearance.v2") ?? "{}")).toMatchObject({ selectedThemeId: "graphite" });
+ });
+
+ it("creates an editable theme from a seed and exposes both mode token sets", async () => {
+ render(
+
+
+
+
+
+ );
+
+ await userEvent.click(screen.getByRole("tab", { name: "Appearance" }));
+ const panel = screen.getByRole("tabpanel", { name: "Appearance" });
+ const name = within(panel).getByLabelText("Theme name");
+ await userEvent.clear(name);
+ await userEvent.type(name, "Ocean Focus");
+ const seed = within(panel).getByLabelText("Seed color hex value");
+ await userEvent.clear(seed);
+ await userEvent.type(seed, "#245ea8");
+ await userEvent.click(within(panel).getByRole("button", { name: "Create theme" }));
+
+ expect(await within(panel).findByRole("radio", { name: /Ocean Focus/i })).toHaveAttribute("aria-checked", "true");
+ expect(within(panel).getByText("Advanced theme editor")).toBeInTheDocument();
+ expect(within(panel).getByRole("button", { name: "Light values" })).toBeInTheDocument();
+ expect(within(panel).getByRole("button", { name: "Dark values" })).toBeInTheDocument();
+ expect(within(panel).getByText("Accessibility report")).toBeInTheDocument();
+ });
+
it("shows truthful AI bridge status and command coverage instead of placeholder install instructions", async () => {
render(
diff --git a/packages/ui/src/views/table/TableView.tsx b/packages/ui/src/views/table/TableView.tsx
index 38203a6..236d809 100644
--- a/packages/ui/src/views/table/TableView.tsx
+++ b/packages/ui/src/views/table/TableView.tsx
@@ -14,6 +14,7 @@ import {
import {
Button,
CheckboxField,
+ colorForLabel,
DataTable,
EmptyState,
HelpTip,
@@ -364,7 +365,7 @@ export function TableView({ view }: { view?: TableViewDef }) {
render: ({ labels }) => (
{labels.map((label) => (
-
+
{label.name}
))}
diff --git a/tests/e2e/hybrid-parity.spec.ts b/tests/e2e/hybrid-parity.spec.ts
index 3d43df7..9dbd149 100644
--- a/tests/e2e/hybrid-parity.spec.ts
+++ b/tests/e2e/hybrid-parity.spec.ts
@@ -42,6 +42,17 @@ test("toggling theme switches between light and dark", async ({ page }) => {
expect(initialTheme).not.toBe(nextTheme);
});
+test("appearance presets apply through the semantic theme runtime", async ({ page }) => {
+ await page.goto("/");
+ await createProjectFromLauncher(page);
+ await page.getByRole("link", { name: "Settings" }).click();
+ await page.getByRole("tab", { name: "Appearance" }).click();
+
+ await page.getByRole("radio", { name: /Graphite/i }).click();
+ await expect.poll(() => page.evaluate(() => document.documentElement.dataset.themeId)).toBe("graphite");
+ await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("gph.appearance.v2") ?? "{}").selectedThemeId)).toBe("graphite");
+});
+
test("mobile shell exposes workspace navigation from the header", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
@@ -93,4 +104,11 @@ test("mobile project and settings screens do not overflow the viewport", async (
await sheet.getByRole("link", { name: "Settings" }).click();
await expect(page).toHaveURL(/\/settings/);
await expectNoPageOverflow();
+
+ await page.getByRole("tab", { name: "Appearance" }).click();
+ const appearancePanel = page.getByRole("tabpanel", { name: "Appearance" });
+ await expect(appearancePanel).toBeVisible();
+ const panelBox = await appearancePanel.boundingBox();
+ expect(panelBox, "Appearance content should be present in the first mobile viewport").not.toBeNull();
+ expect(panelBox!.y).toBeLessThan(844);
});