diff --git a/canvas/src/lib/__tests__/palette-context.test.tsx b/canvas/src/lib/__tests__/palette-context.test.tsx
new file mode 100644
index 00000000..def5b4c6
--- /dev/null
+++ b/canvas/src/lib/__tests__/palette-context.test.tsx
@@ -0,0 +1,205 @@
+// @vitest-environment jsdom
+"use client";
+/**
+ * Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
+ *
+ * Test coverage (9 cases):
+ * 1. MobileAccentProvider renders children
+ * 2. usePalette(false) without provider → MOL_LIGHT
+ * 3. usePalette(true) without provider → MOL_DARK
+ * 4. accent=null returns base palette unchanged
+ * 5. accent=base.accent returns base palette unchanged (identity guard)
+ * 6. accent="#custom" overrides both accent and online
+ * 7. MOL_LIGHT singleton never mutated
+ * 8. MOL_DARK singleton never mutated
+ *
+ * Plus pure-function coverage for normalizeStatus + tierCode.
+ */
+import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
+import React from "react";
+import { render, screen, cleanup } from "@testing-library/react";
+import {
+ MOL_LIGHT,
+ MOL_DARK,
+ getPalette,
+ normalizeStatus,
+ tierCode,
+ MobileAccentProvider,
+ usePalette,
+} from "../palette-context";
+
+// ─── usePalette test helper ───────────────────────────────────────────────────
+// usePalette reads document.documentElement.dataset.theme internally.
+// We set this before rendering so the hook sees the right value.
+
+function setDataTheme(theme: "light" | "dark") {
+ if (typeof document !== "undefined") {
+ document.documentElement.dataset.theme = theme;
+ }
+}
+
+// ─── Pure function tests ──────────────────────────────────────────────────────
+
+describe("normalizeStatus", () => {
+ it("returns emerald-400 for online status", () => {
+ expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
+ expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
+ });
+
+ it("returns emerald-400 for degraded status", () => {
+ expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
+ expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
+ });
+
+ it("returns red-400 for failed status", () => {
+ expect(normalizeStatus("failed", false)).toBe("bg-red-400");
+ expect(normalizeStatus("failed", true)).toBe("bg-red-400");
+ });
+
+ it("returns amber-400 for paused status", () => {
+ expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
+ expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
+ });
+
+ it("returns amber-400 for not_configured status", () => {
+ expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
+ });
+
+ it("returns zinc-400 for unknown status", () => {
+ expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
+ expect(normalizeStatus("", false)).toBe("bg-zinc-400");
+ });
+});
+
+describe("tierCode", () => {
+ it("returns T1 for tier 1", () => {
+ expect(tierCode(1)).toBe("T1");
+ });
+
+ it("returns T2 for tier 2", () => {
+ expect(tierCode(2)).toBe("T2");
+ });
+
+ it("returns T4 for tier 4", () => {
+ expect(tierCode(4)).toBe("T4");
+ });
+
+ it("returns generic T{n} for non-standard tiers", () => {
+ expect(tierCode(99)).toBe("T99");
+ });
+});
+
+// ─── getPalette tests ─────────────────────────────────────────────────────────
+
+describe("getPalette — accent override", () => {
+ it("accent=null returns base palette unchanged (light)", () => {
+ const result = getPalette(null, false);
+ expect(result).toEqual({ ...MOL_LIGHT });
+ expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
+ });
+
+ it("accent=null returns base palette unchanged (dark)", () => {
+ const result = getPalette(null, true);
+ expect(result).toEqual({ ...MOL_DARK });
+ expect(result).not.toBe(MOL_DARK);
+ });
+
+ it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
+ const result = getPalette(MOL_LIGHT.accent, false);
+ expect(result).toEqual({ ...MOL_LIGHT });
+ expect(result).not.toBe(MOL_LIGHT);
+ });
+
+ it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
+ const result = getPalette(MOL_DARK.accent, true);
+ expect(result).toEqual({ ...MOL_DARK });
+ expect(result).not.toBe(MOL_DARK);
+ });
+
+ it("accent='#custom' overrides accent and online (light)", () => {
+ const result = getPalette("#ff0000", false);
+ expect(result.accent).toBe("#ff0000");
+ expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
+ });
+
+ it("accent='#custom' overrides accent and online (dark)", () => {
+ const result = getPalette("#00ff00", true);
+ expect(result.accent).toBe("#00ff00");
+ expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
+ });
+
+ it("MOL_LIGHT singleton is never mutated", () => {
+ getPalette("#mutate", false);
+ // All fields must still match the original freeze definition
+ expect(MOL_LIGHT.accent).toBe("bg-blue-500");
+ expect(MOL_LIGHT.online).toBe("bg-emerald-400");
+ expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
+ expect(MOL_LIGHT.ink).toBe("text-zinc-100");
+ expect(MOL_LIGHT.line).toBe("border-zinc-700");
+ expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
+ });
+
+ it("MOL_DARK singleton is never mutated", () => {
+ getPalette("#mutate", true);
+ expect(MOL_DARK.accent).toBe("bg-sky-400");
+ expect(MOL_DARK.online).toBe("bg-emerald-400");
+ expect(MOL_DARK.surface).toBe("bg-zinc-800");
+ expect(MOL_DARK.ink).toBe("text-zinc-100");
+ expect(MOL_DARK.line).toBe("border-zinc-700");
+ expect(MOL_DARK.bg).toBe("bg-zinc-950");
+ });
+
+ it("getPalette always returns a new object (no shared mutation risk)", () => {
+ const a = getPalette("#a", false);
+ const b = getPalette("#b", false);
+ expect(a).not.toBe(b);
+ expect(a.accent).not.toBe(b.accent);
+ });
+});
+
+// ─── MobileAccentProvider tests ───────────────────────────────────────────────
+
+describe("MobileAccentProvider", () => {
+ beforeEach(() => {
+ setDataTheme("light");
+ });
+
+ afterEach(() => {
+ cleanup();
+ if (typeof document !== "undefined") {
+ document.documentElement.dataset.theme = "";
+ }
+ });
+
+ it("renders children", () => {
+ render(
+
+ Hello
+ ,
+ );
+ expect(screen.getByTestId("child")).toBeTruthy();
+ });
+
+ // usePalette hook reads data-theme from to determine light/dark.
+ // In the test environment, data-theme is empty, which falls through to
+ // the "light" default in usePalette, giving MOL_LIGHT.
+ it("usePalette(false) without provider → MOL_LIGHT", () => {
+ setDataTheme("light");
+ function ShowPalette() {
+ const p = usePalette(false);
+ return {p.accent};
+ }
+ render();
+ expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
+ });
+
+ it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
+ setDataTheme("dark");
+ function ShowPalette() {
+ const p = usePalette(true);
+ return {p.accent};
+ }
+ render();
+ expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
+ });
+});
diff --git a/canvas/src/lib/palette-context.tsx b/canvas/src/lib/palette-context.tsx
new file mode 100644
index 00000000..c88cf2be
--- /dev/null
+++ b/canvas/src/lib/palette-context.tsx
@@ -0,0 +1,167 @@
+"use client";
+
+/**
+ * palette-context.tsx
+ *
+ * Mobile canvas accent palette system.
+ *
+ * - MOL_LIGHT / MOL_DARK — immutable base singletons
+ * - getPalette(accent, isDark) — returns base palette or accent-overridden copy
+ * - normalizeStatus(status, isDark) — maps workspace status → online dot color
+ * - tierCode(tier) — maps tier number → display label
+ * - MobileAccentProvider — React context that propagates accent override
+ * - usePalette(allowAccentOverride) — hook; returns the effective palette
+ */
+
+import { createContext, useContext } from "react";
+
+// ─── Types ─────────────────────────────────────────────────────────────────────
+
+export interface Palette {
+ /** Accent colour (CSS colour string). */
+ accent: string;
+ /** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */
+ online: string;
+ /** Surface background colour class. */
+ surface: string;
+ /** Primary text colour class. */
+ ink: string;
+ /** Border/divider colour class. */
+ line: string;
+ /** Background colour class. */
+ bg: string;
+ /** Tier display code, e.g. "T1". */
+ tier: string;
+}
+
+// ─── Singleton base palettes ────────────────────────────────────────────────────
+
+/** Light-mode base palette — must never be mutated. */
+export const MOL_LIGHT: Readonly = Object.freeze({
+ accent: "bg-blue-500",
+ online: "bg-emerald-400",
+ surface: "bg-zinc-900",
+ ink: "text-zinc-100",
+ line: "border-zinc-700",
+ bg: "bg-zinc-950",
+ tier: "T1",
+});
+
+/** Dark-mode base palette — must never be mutated. */
+export const MOL_DARK: Readonly = Object.freeze({
+ accent: "bg-sky-400",
+ online: "bg-emerald-400",
+ surface: "bg-zinc-800",
+ ink: "text-zinc-100",
+ line: "border-zinc-700",
+ bg: "bg-zinc-950",
+ tier: "T1",
+});
+
+// ─── Pure helpers ─────────────────────────────────────────────────────────────
+
+/**
+ * Maps workspace status string → online dot colour class.
+ * Returns the appropriate green for light/dark mode.
+ */
+export function normalizeStatus(
+ status: string,
+ _isDark: boolean,
+): string {
+ if (status === "online" || status === "degraded") {
+ return "bg-emerald-400";
+ }
+ if (status === "failed") {
+ return "bg-red-400";
+ }
+ if (status === "paused" || status === "not_configured") {
+ return "bg-amber-400";
+ }
+ return "bg-zinc-400";
+}
+
+/**
+ * Maps tier number → display code.
+ */
+export function tierCode(tier: number): string {
+ return `T${tier}`;
+}
+
+/**
+ * Returns the effective palette.
+ *
+ * - `accent = null` → base palette (light or dark) unchanged
+ * - `accent = basePalette.accent` → base palette unchanged (identity guard)
+ * - `accent = "#custom"` → copy with `accent` and `online` overridden
+ *
+ * Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated.
+ */
+export function getPalette(
+ accent: string | null,
+ isDark: boolean,
+): Palette {
+ const base: Readonly = isDark ? MOL_DARK : MOL_LIGHT;
+
+ // null accent → use base unchanged
+ if (accent === null) return { ...base };
+
+ // identity guard — accent same as base accent → no override needed
+ if (accent === base.accent) return { ...base };
+
+ // Custom accent: override accent + online to keep them in sync
+ return { ...base, accent, online: normalizeStatus("online", isDark) };
+}
+
+// ─── Context ──────────────────────────────────────────────────────────────────
+
+type MobileAccentContextValue = {
+ /** Override accent colour (null = no override, use default). */
+ accent: string | null;
+};
+
+const MobileAccentContext = createContext({
+ accent: null,
+});
+
+export { MobileAccentContext };
+
+/**
+ * Renders children inside the accent override context.
+ */
+export function MobileAccentProvider({
+ accent,
+ children,
+}: {
+ accent: string | null;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+// ─── Hook ─────────────────────────────────────────────────────────────────────
+
+/**
+ * Returns the effective `Palette` for the current context.
+ *
+ * @param allowAccentOverride When false, always returns the base palette
+ * even when an override is set (useful for
+ * non-accent-aware child components).
+ */
+export function usePalette(allowAccentOverride: boolean): Palette {
+ const { accent } = useContext(MobileAccentContext);
+
+ // Resolved from the OS-level theme preference. In a real app this would
+ // be derived from useTheme().resolvedTheme; for this hook we default
+ // to light (the safe default for SSR / component-library use).
+ // We read data-theme from to stay in sync with the theme system.
+ const isDark =
+ typeof document !== "undefined" &&
+ document.documentElement.dataset.theme === "dark";
+
+ const effectiveAccent = allowAccentOverride ? accent : null;
+ return getPalette(effectiveAccent, isDark);
+}