From 0c1496d2fe897b502c962e619e0b0a6d470e82bf Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 16:27:21 +0000 Subject: [PATCH] refactor(nexus): rewrite theme preview panel for design.md palette --- ui/src/components/ThemePreviewPanel.test.tsx | 74 ++++- ui/src/components/ThemePreviewPanel.tsx | 271 ++++++++++++++----- 2 files changed, 259 insertions(+), 86 deletions(-) diff --git a/ui/src/components/ThemePreviewPanel.test.tsx b/ui/src/components/ThemePreviewPanel.test.tsx index 2b322dd9..be6b454b 100644 --- a/ui/src/components/ThemePreviewPanel.test.tsx +++ b/ui/src/components/ThemePreviewPanel.test.tsx @@ -1,24 +1,36 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render } from "@testing-library/react"; +import { render, cleanup } from "@testing-library/react"; import { ThemePreviewPanel } from "./ThemePreviewPanel"; +/** + * Minimal generated-palette fixture. These hex values are ARBITRARY and + * exist only to verify the preview plumbs role → CSS variable correctly. + * They are NOT the DESIGN.md palette — DESIGN.md tokens live in index.css + * and are exercised via the Tailwind token utilities the preview renders + * (bg-background, border-border, text-primary, ...). + */ const makePalette = () => [ { name: "background", - dark: { oklch: "oklch(0.14 0.01 240)", hex: "#1e1e2e", wcagAA: true }, - light: { oklch: "oklch(0.94 0.005 240)", hex: "#eff1f5", wcagAA: true }, + dark: { oklch: "oklch(0 0 0)", hex: "#000000", wcagAA: true }, + light: { oklch: "oklch(0.99 0 0)", hex: "#fafafa", wcagAA: true }, }, { name: "surface", - dark: { oklch: "oklch(0.18 0.01 240)", hex: "#181825", wcagAA: true }, - light: { oklch: "oklch(0.90 0.006 240)", hex: "#e6e9ef", wcagAA: true }, + dark: { oklch: "oklch(0.08 0 0)", hex: "#141414", wcagAA: true }, + light: { oklch: "oklch(0.97 0 0)", hex: "#ffffff", wcagAA: true }, }, { name: "text", - dark: { oklch: "oklch(0.93 0.008 240)", hex: "#cdd6f4", wcagAA: true }, - light: { oklch: "oklch(0.28 0.008 240)", hex: "#4c4f69", wcagAA: true }, + dark: { oklch: "oklch(1 0 0)", hex: "#ffffff", wcagAA: true }, + light: { oklch: "oklch(0.1 0 0)", hex: "#0a0a0a", wcagAA: true }, + }, + { + name: "accent-1", + dark: { oklch: "oklch(0.96 0.2 110)", hex: "#faff69", wcagAA: true }, + light: { oklch: "oklch(0.45 0.1 140)", hex: "#166534", wcagAA: true }, }, ]; @@ -37,6 +49,7 @@ describe("ThemePreviewPanel", () => { afterEach(() => { CSSStyleDeclaration.prototype.setProperty = originalSetProperty; vi.restoreAllMocks(); + cleanup(); }); it("renders a container with className nexus-theme-preview", () => { @@ -77,10 +90,14 @@ describe("ThemePreviewPanel", () => { const { container } = render( , ); - const preview = container.querySelector(".nexus-theme-preview") as HTMLElement; + const preview = container.querySelector( + ".nexus-theme-preview", + ) as HTMLElement; expect(preview).toBeTruthy(); // dark background role should set --background to dark hex - expect(preview.style.getPropertyValue("--background")).toBe("#1e1e2e"); + expect(preview.style.getPropertyValue("--background")).toBe("#000000"); + // accent-1 → --primary + expect(preview.style.getPropertyValue("--primary")).toBe("#faff69"); }); it("sets CSS variables on the container element for light variant", () => { @@ -88,10 +105,13 @@ describe("ThemePreviewPanel", () => { const { container } = render( , ); - const preview = container.querySelector(".nexus-theme-preview") as HTMLElement; + const preview = container.querySelector( + ".nexus-theme-preview", + ) as HTMLElement; expect(preview).toBeTruthy(); // light background role should set --background to light hex - expect(preview.style.getPropertyValue("--background")).toBe("#eff1f5"); + expect(preview.style.getPropertyValue("--background")).toBe("#fafafa"); + expect(preview.style.getPropertyValue("--primary")).toBe("#166534"); }); it("does NOT call document.documentElement.style.setProperty", () => { @@ -99,4 +119,36 @@ describe("ThemePreviewPanel", () => { render(); expect(documentSetPropertySpy).not.toHaveBeenCalled(); }); + + it("renders the design-system section labels", () => { + const palette = makePalette(); + const { getByText } = render( + , + ); + // Uppercase section overlines per DESIGN.md §3 + expect(getByText("Typography")).toBeTruthy(); + expect(getByText("Palette tokens")).toBeTruthy(); + expect(getByText("Components")).toBeTruthy(); + }); + + it("renders primary, forest, dark-solid, and ghost button samples", () => { + const palette = makePalette(); + const { getByRole } = render( + , + ); + expect(getByRole("button", { name: "Primary action" })).toBeTruthy(); + expect(getByRole("button", { name: "Get started" })).toBeTruthy(); + expect(getByRole("button", { name: "Secondary" })).toBeTruthy(); + expect(getByRole("button", { name: "Ghost" })).toBeTruthy(); + }); + + it("renders resolved swatch hex values when palette is provided", () => { + const palette = makePalette(); + const { getAllByText } = render( + , + ); + // #000000 appears as the background swatch label; at least one hit + expect(getAllByText("#000000").length).toBeGreaterThan(0); + expect(getAllByText("#faff69").length).toBeGreaterThan(0); + }); }); diff --git a/ui/src/components/ThemePreviewPanel.tsx b/ui/src/components/ThemePreviewPanel.tsx index 68b22996..6cfdcce0 100644 --- a/ui/src/components/ThemePreviewPanel.tsx +++ b/ui/src/components/ThemePreviewPanel.tsx @@ -1,8 +1,13 @@ -import { useEffect, useRef, useState } from "react"; -import { Card } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { PaletteRole } from "./ThemePaletteGrid"; +/** + * Which CSS variable each generated palette role drives inside the preview + * sandbox. The container scopes these custom properties locally so the + * Tailwind token utilities (`bg-background`, `border-border`, ...) inside + * the preview resolve against the generated bundle rather than the app's + * global theme. + */ const ROLE_TO_TOKEN: Record = { background: "--background", surface: "--card", @@ -19,6 +24,42 @@ interface ThemePreviewPanelProps { className?: string; } +interface ResolvedSwatch { + label: string; + token: string; + hex: string; +} + +const SWATCH_ORDER: Array<{ role: string; label: string; token: string }> = [ + { role: "background", label: "Background", token: "--background" }, + { role: "text", label: "Foreground", token: "--foreground" }, + { role: "surface", label: "Card", token: "--card" }, + { role: "accent-1", label: "Primary", token: "--primary" }, + { role: "overlay", label: "Secondary", token: "--secondary" }, + { role: "accent-3", label: "Muted", token: "--muted" }, +]; + +function buildSwatches( + palette: PaletteRole[], + variant: "dark" | "light", +): ResolvedSwatch[] { + const byName = new Map(palette.map((role) => [role.name, role])); + return SWATCH_ORDER.flatMap(({ role, label, token }) => { + const entry = byName.get(role); + if (!entry) return []; + const hex = variant === "dark" ? entry.dark.hex : entry.light.hex; + return [{ label, token, hex }]; + }); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + export function ThemePreviewPanel({ palette, variant, @@ -41,88 +82,168 @@ export function ThemePreviewPanel({ setAnnouncement("Palette updated"); }, [palette, variant]); + const swatches = useMemo( + () => buildSwatches(palette, variant), + [palette, variant], + ); + return (
{/* Visually hidden aria-live announcer */} -
+
{announcement}
- {/* Mini Nexus UI mock */} -
- {/* Narrow sidebar strip */} -
-
-
-
-
- - {/* Main content area */} -
- -
-
-
-
-
- -
+
+ {/* Typography samples -------------------------------------------- */} +
+ Typography +
+
+ Nexus
- -
+
+ Build at the speed of thought +
+

+ Body copy renders in Inter 400 against the generated background. + Pure contrast, no soft shadows. +

+

+ Caption and metadata use silver on black for secondary content. +

+ + {"$ nexus theme apply --bundle ./preview.json"} + +
+ + + {/* Color swatches ------------------------------------------------ */} +
+ Palette tokens +
+ {swatches.length === 0 + ? SWATCH_ORDER.map(({ label, token }) => ( + + )) + : swatches.map((s) => ( +
+
+
+ + {s.label} + + + {s.hex} + +
+
+ ))} +
+
+ + {/* Component samples --------------------------------------------- */} +
+ Components +
+ {/* Neon Primary — volt on near-black */} + + {/* Forest Green CTA */} + + {/* Dark Solid */} + + {/* Ghost with olive-tinted volt-border */} + +
+ + {/* Card samples: standard + pressed/inset */} +
+
+
+ Card +
+
+ Charcoal border +
+

+ Standard container: 8px radius, 1px charcoal border, no + diffused shadow. +

+
+
+
+ Card · pressed +
+
+ Inset depth +
+

+ Active state sinks into the surface with an inset shadow. +

+
+
+
+
+
+ ); +} + +function FallbackSwatch({ label, token }: { label: string; token: string }) { + return ( +
+
+
+ + {label} + + + {token} +
);