refactor(nexus): rewrite theme preview panel for design.md palette

This commit is contained in:
Nexus Dev 2026-04-11 16:27:21 +00:00
parent 16adf9f83b
commit 0c1496d2fe
2 changed files with 259 additions and 86 deletions

View file

@ -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(
<ThemePreviewPanel palette={palette} variant="dark" />,
);
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(
<ThemePreviewPanel palette={palette} variant="light" />,
);
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(<ThemePreviewPanel palette={palette} variant="dark" />);
expect(documentSetPropertySpy).not.toHaveBeenCalled();
});
it("renders the design-system section labels", () => {
const palette = makePalette();
const { getByText } = render(
<ThemePreviewPanel palette={palette} variant="dark" />,
);
// 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(
<ThemePreviewPanel palette={palette} variant="dark" />,
);
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(
<ThemePreviewPanel palette={palette} variant="dark" />,
);
// #000000 appears as the background swatch label; at least one hit
expect(getAllByText("#000000").length).toBeGreaterThan(0);
expect(getAllByText("#faff69").length).toBeGreaterThan(0);
});
});

View file

@ -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<string, string> = {
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 (
<div className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{children}
</div>
);
}
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 (
<div
ref={containerRef}
className={`nexus-theme-preview relative rounded-lg border border-[var(--border,hsl(var(--border)))] overflow-hidden${className ? ` ${className}` : ""}`}
className={`nexus-theme-preview relative overflow-hidden rounded-sm border border-border bg-background text-foreground${
className ? ` ${className}` : ""
}`}
aria-label="Theme preview"
style={{ backgroundColor: "var(--background, #1e1e2e)" }}
>
{/* Visually hidden aria-live announcer */}
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announcement}
</div>
{/* Mini Nexus UI mock */}
<div className="flex h-40">
{/* Narrow sidebar strip */}
<div
className="flex flex-col items-center gap-2 py-3 px-2"
style={{
width: 48,
backgroundColor: "var(--card, #181825)",
borderRight: "1px solid var(--border, #313244)",
}}
>
<div
className="h-5 w-5 rounded"
style={{ backgroundColor: "var(--primary, #89b4fa)" }}
/>
<div
className="h-4 w-4 rounded opacity-60"
style={{ backgroundColor: "var(--muted, #313244)" }}
/>
<div
className="h-4 w-4 rounded opacity-60"
style={{ backgroundColor: "var(--muted, #313244)" }}
/>
</div>
{/* Main content area */}
<div
className="flex-1 p-3"
style={{ backgroundColor: "var(--background, #1e1e2e)" }}
>
<Card
className="p-3 h-full"
style={{
backgroundColor: "var(--card, #181825)",
color: "var(--foreground, #cdd6f4)",
border: "1px solid var(--border, #313244)",
}}
>
<div className="flex flex-col gap-2 h-full">
<div
className="h-3 w-3/4 rounded"
style={{ backgroundColor: "var(--foreground, #cdd6f4)", opacity: 0.7 }}
/>
<div
className="h-2 w-full rounded"
style={{ backgroundColor: "var(--muted, #313244)" }}
/>
<div
className="h-2 w-2/3 rounded"
style={{ backgroundColor: "var(--muted, #313244)" }}
/>
<div className="mt-auto">
<Button
size="xs"
style={{
backgroundColor: "var(--primary, #89b4fa)",
color: "var(--primary-foreground, #1e1e2e)",
}}
className="border-0"
>
Action
</Button>
</div>
<div className="flex flex-col gap-8 p-6">
{/* Typography samples -------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionLabel>Typography</SectionLabel>
<div className="flex flex-col gap-2">
<div
className="font-black leading-none tracking-tight text-foreground"
style={{ fontSize: "72px", lineHeight: 1 }}
>
Nexus
</div>
</Card>
</div>
<div className="text-[36px] font-bold leading-tight text-foreground">
Build at the speed of thought
</div>
<p className="text-[16px] font-normal leading-[1.5] text-foreground">
Body copy renders in Inter 400 against the generated background.
Pure contrast, no soft shadows.
</p>
<p className="text-[14px] font-medium leading-[1.43] text-muted-foreground">
Caption and metadata use silver on black for secondary content.
</p>
<code className="font-mono text-[16px] font-semibold text-primary">
{"$ nexus theme apply --bundle ./preview.json"}
</code>
</div>
</section>
{/* Color swatches ------------------------------------------------ */}
<section className="flex flex-col gap-3">
<SectionLabel>Palette tokens</SectionLabel>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{swatches.length === 0
? SWATCH_ORDER.map(({ label, token }) => (
<FallbackSwatch key={token} label={label} token={token} />
))
: swatches.map((s) => (
<div
key={s.token}
className="flex items-center gap-3 rounded-sm border border-border p-2"
>
<div
className="h-10 w-10 rounded-sm border border-border"
style={{ backgroundColor: s.hex }}
aria-label={`${s.label} swatch ${s.hex}`}
/>
<div className="flex min-w-0 flex-col">
<span className="text-[11px] font-semibold uppercase tracking-[0.1em] text-foreground">
{s.label}
</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">
{s.hex}
</span>
</div>
</div>
))}
</div>
</section>
{/* Component samples --------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionLabel>Components</SectionLabel>
<div className="flex flex-wrap gap-2">
{/* Neon Primary — volt on near-black */}
<button
type="button"
className="rounded-sm border border-primary bg-primary px-4 py-2 text-[14px] font-semibold text-primary-foreground active:text-[color:var(--volt-pale,#f4f692)]"
>
Primary action
</button>
{/* Forest Green CTA */}
<button
type="button"
className="rounded-sm border border-[color:var(--near-black,#141414)] bg-[color:var(--forest,#166534)] px-4 py-2 text-[14px] font-semibold text-white"
>
Get started
</button>
{/* Dark Solid */}
<button
type="button"
className="rounded-sm border border-[color:var(--near-black,#141414)] bg-[color:var(--near-black,#141414)] px-4 py-2 text-[14px] font-semibold text-foreground"
>
Secondary
</button>
{/* Ghost with olive-tinted volt-border */}
<button
type="button"
className="rounded-sm border border-[color:var(--volt-border,#4f5100)] bg-transparent px-8 py-2 text-[14px] font-semibold text-foreground"
>
Ghost
</button>
</div>
{/* Card samples: standard + pressed/inset */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-border bg-card p-4">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
Card
</div>
<div className="text-[18px] font-bold text-foreground">
Charcoal border
</div>
<p className="mt-1 text-[14px] text-muted-foreground">
Standard container: 8px radius, 1px charcoal border, no
diffused shadow.
</p>
</div>
<div
className="rounded-lg border border-border bg-card p-4"
style={{
boxShadow: "inset 0 4px 25px rgba(0,0,0,0.14)",
}}
>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
Card · pressed
</div>
<div className="text-[18px] font-bold text-foreground">
Inset depth
</div>
<p className="mt-1 text-[14px] text-muted-foreground">
Active state sinks into the surface with an inset shadow.
</p>
</div>
</div>
</section>
</div>
</div>
);
}
function FallbackSwatch({ label, token }: { label: string; token: string }) {
return (
<div className="flex items-center gap-3 rounded-sm border border-border p-2">
<div
className="h-10 w-10 rounded-sm border border-border"
style={{ backgroundColor: `var(${token})` }}
aria-label={`${label} token ${token}`}
/>
<div className="flex min-w-0 flex-col">
<span className="text-[11px] font-semibold uppercase tracking-[0.1em] text-foreground">
{label}
</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">
{token}
</span>
</div>
</div>
);