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
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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";
|
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 = () => [
|
const makePalette = () => [
|
||||||
{
|
{
|
||||||
name: "background",
|
name: "background",
|
||||||
dark: { oklch: "oklch(0.14 0.01 240)", hex: "#1e1e2e", wcagAA: true },
|
dark: { oklch: "oklch(0 0 0)", hex: "#000000", wcagAA: true },
|
||||||
light: { oklch: "oklch(0.94 0.005 240)", hex: "#eff1f5", wcagAA: true },
|
light: { oklch: "oklch(0.99 0 0)", hex: "#fafafa", wcagAA: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "surface",
|
name: "surface",
|
||||||
dark: { oklch: "oklch(0.18 0.01 240)", hex: "#181825", wcagAA: true },
|
dark: { oklch: "oklch(0.08 0 0)", hex: "#141414", wcagAA: true },
|
||||||
light: { oklch: "oklch(0.90 0.006 240)", hex: "#e6e9ef", wcagAA: true },
|
light: { oklch: "oklch(0.97 0 0)", hex: "#ffffff", wcagAA: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "text",
|
name: "text",
|
||||||
dark: { oklch: "oklch(0.93 0.008 240)", hex: "#cdd6f4", wcagAA: true },
|
dark: { oklch: "oklch(1 0 0)", hex: "#ffffff", wcagAA: true },
|
||||||
light: { oklch: "oklch(0.28 0.008 240)", hex: "#4c4f69", 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(() => {
|
afterEach(() => {
|
||||||
CSSStyleDeclaration.prototype.setProperty = originalSetProperty;
|
CSSStyleDeclaration.prototype.setProperty = originalSetProperty;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a container with className nexus-theme-preview", () => {
|
it("renders a container with className nexus-theme-preview", () => {
|
||||||
|
|
@ -77,10 +90,14 @@ describe("ThemePreviewPanel", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ThemePreviewPanel palette={palette} variant="dark" />,
|
<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();
|
expect(preview).toBeTruthy();
|
||||||
// dark background role should set --background to dark hex
|
// 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", () => {
|
it("sets CSS variables on the container element for light variant", () => {
|
||||||
|
|
@ -88,10 +105,13 @@ describe("ThemePreviewPanel", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ThemePreviewPanel palette={palette} variant="light" />,
|
<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();
|
expect(preview).toBeTruthy();
|
||||||
// light background role should set --background to light hex
|
// 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", () => {
|
it("does NOT call document.documentElement.style.setProperty", () => {
|
||||||
|
|
@ -99,4 +119,36 @@ describe("ThemePreviewPanel", () => {
|
||||||
render(<ThemePreviewPanel palette={palette} variant="dark" />);
|
render(<ThemePreviewPanel palette={palette} variant="dark" />);
|
||||||
expect(documentSetPropertySpy).not.toHaveBeenCalled();
|
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { PaletteRole } from "./ThemePaletteGrid";
|
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> = {
|
const ROLE_TO_TOKEN: Record<string, string> = {
|
||||||
background: "--background",
|
background: "--background",
|
||||||
surface: "--card",
|
surface: "--card",
|
||||||
|
|
@ -19,6 +24,42 @@ interface ThemePreviewPanelProps {
|
||||||
className?: string;
|
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({
|
export function ThemePreviewPanel({
|
||||||
palette,
|
palette,
|
||||||
variant,
|
variant,
|
||||||
|
|
@ -41,88 +82,168 @@ export function ThemePreviewPanel({
|
||||||
setAnnouncement("Palette updated");
|
setAnnouncement("Palette updated");
|
||||||
}, [palette, variant]);
|
}, [palette, variant]);
|
||||||
|
|
||||||
|
const swatches = useMemo(
|
||||||
|
() => buildSwatches(palette, variant),
|
||||||
|
[palette, variant],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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"
|
aria-label="Theme preview"
|
||||||
style={{ backgroundColor: "var(--background, #1e1e2e)" }}
|
|
||||||
>
|
>
|
||||||
{/* Visually hidden aria-live announcer */}
|
{/* Visually hidden aria-live announcer */}
|
||||||
<div
|
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
className="sr-only"
|
|
||||||
>
|
|
||||||
{announcement}
|
{announcement}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mini Nexus UI mock */}
|
<div className="flex flex-col gap-8 p-6">
|
||||||
<div className="flex h-40">
|
{/* Typography samples -------------------------------------------- */}
|
||||||
{/* Narrow sidebar strip */}
|
<section className="flex flex-col gap-3">
|
||||||
<div
|
<SectionLabel>Typography</SectionLabel>
|
||||||
className="flex flex-col items-center gap-2 py-3 px-2"
|
<div className="flex flex-col gap-2">
|
||||||
style={{
|
<div
|
||||||
width: 48,
|
className="font-black leading-none tracking-tight text-foreground"
|
||||||
backgroundColor: "var(--card, #181825)",
|
style={{ fontSize: "72px", lineHeight: 1 }}
|
||||||
borderRight: "1px solid var(--border, #313244)",
|
>
|
||||||
}}
|
Nexus
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</Card>
|
<div className="text-[36px] font-bold leading-tight text-foreground">
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue