diff --git a/ui/src/components/settings/SettingsSection.test.tsx b/ui/src/components/settings/SettingsSection.test.tsx new file mode 100644 index 00000000..f9de46df --- /dev/null +++ b/ui/src/components/settings/SettingsSection.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SettingsSection, SettingsRow } from "./SettingsSection"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("SettingsSection", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) { + container.remove(); + } + }); + + function render(ui: React.ReactNode) { + root = createRoot(container); + act(() => { + root!.render(ui); + }); + } + + it("renders a semantic section with the title as aria-label", () => { + render( + + child + , + ); + const section = container.querySelector("section"); + expect(section).not.toBeNull(); + expect(section?.getAttribute("aria-label")).toBe("Workspace"); + }); + + it("renders the uppercase title and hairline rule", () => { + render( + + child + , + ); + const heading = container.querySelector("h2"); + expect(heading?.textContent).toBe("Local AI"); + expect(heading?.className).toContain("uppercase"); + expect(container.querySelector("hr")).not.toBeNull(); + }); + + it("renders the description when provided", () => { + render( + + child + , + ); + expect(container.textContent).toContain("Version info"); + }); + + it("renders children", () => { + render( + +
+ , + ); + expect(container.querySelector("[data-testid='child']")).not.toBeNull(); + }); +}); + +describe("SettingsRow", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) { + container.remove(); + } + }); + + function render(ui: React.ReactNode) { + root = createRoot(container); + act(() => { + root!.render(ui); + }); + } + + it("renders the label and children", () => { + render( + + + , + ); + expect(container.textContent).toContain("Theme"); + const button = container.querySelector("button"); + expect(button?.textContent).toBe("Dark"); + }); + + it("renders the optional description", () => { + render( + + value + , + ); + expect(container.textContent).toContain("Choose the visual theme"); + }); +}); diff --git a/ui/src/components/settings/SettingsSection.tsx b/ui/src/components/settings/SettingsSection.tsx new file mode 100644 index 00000000..5e5c8898 --- /dev/null +++ b/ui/src/components/settings/SettingsSection.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +interface SettingsSectionProps { + title: string; + description?: string; + children: ReactNode; + className?: string; +} + +/** + * Shared shell for a single settings section card. + * + * - 1px charcoal border, 8px radius, transparent fill + * - 24px padding + * - Uppercase 12px title with 1.4px tracking on the muted silver tone, + * followed by a hairline rule + * - Section content stacked vertically with 16px gaps + * + * Spec: docs/specs/2026-04-11-nexus-layout-overhaul.md ยง8.2 + */ +export function SettingsSection({ title, description, children, className }: SettingsSectionProps) { + return ( +
+
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+
+
{children}
+
+ ); +} + +interface SettingsRowProps { + label: string; + description?: string; + children: ReactNode; + className?: string; +} + +/** + * A single label / value / action row inside a SettingsSection. + * + * The layout is `label (+ optional description) | children`, where `children` + * is the interactive control or value display. Children align right on wide + * screens and stack below the label on narrow screens. + */ +export function SettingsRow({ label, description, children, className }: SettingsRowProps) { + return ( +
+
+
{label}
+ {description ? ( +

{description}

+ ) : null} +
+
{children}
+
+ ); +}