refactor(nexus): rewrite theme preview panel for design.md palette
This commit is contained in:
parent
16adf9f83b
commit
0c1496d2fe
2 changed files with 259 additions and 86 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue