feat(nexus): add SettingsSection shell primitive (phase 13)

Shared section card shell for the Nexus Settings page per spec §8.2:
1px charcoal border, 8px radius, transparent fill, uppercase silver
title with hairline rule. Used by all 8 section cards that replace the
nested Paperclip instance-settings tree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:16:32 +00:00
parent 87b45c730c
commit 7aa98d385f
2 changed files with 206 additions and 0 deletions

View file

@ -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<typeof createRoot> | 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(
<SettingsSection title="Workspace">
<span>child</span>
</SettingsSection>,
);
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(
<SettingsSection title="Local AI">
<span>child</span>
</SettingsSection>,
);
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(
<SettingsSection title="About" description="Version info">
<span>child</span>
</SettingsSection>,
);
expect(container.textContent).toContain("Version info");
});
it("renders children", () => {
render(
<SettingsSection title="Workspace">
<div data-testid="child" />
</SettingsSection>,
);
expect(container.querySelector("[data-testid='child']")).not.toBeNull();
});
});
describe("SettingsRow", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | 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(
<SettingsRow label="Theme">
<button type="button">Dark</button>
</SettingsRow>,
);
expect(container.textContent).toContain("Theme");
const button = container.querySelector("button");
expect(button?.textContent).toBe("Dark");
});
it("renders the optional description", () => {
render(
<SettingsRow label="Theme" description="Choose the visual theme">
<span>value</span>
</SettingsRow>,
);
expect(container.textContent).toContain("Choose the visual theme");
});
});

View file

@ -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 (
<section
aria-label={title}
className={cn(
"rounded-[8px] border border-border bg-transparent p-6",
className,
)}
>
<header className="mb-4">
<h2 className="text-[12px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{title}
</h2>
{description ? (
<p className="mt-1 text-sm text-muted-foreground/80">{description}</p>
) : null}
<hr className="mt-2 border-border" />
</header>
<div className="space-y-4">{children}</div>
</section>
);
}
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 (
<div
className={cn(
"flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-6",
className,
)}
>
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium text-primary">{label}</div>
{description ? (
<p className="max-w-prose text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">{children}</div>
</div>
);
}