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:
parent
87b45c730c
commit
7aa98d385f
2 changed files with 206 additions and 0 deletions
130
ui/src/components/settings/SettingsSection.test.tsx
Normal file
130
ui/src/components/settings/SettingsSection.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
76
ui/src/components/settings/SettingsSection.tsx
Normal file
76
ui/src/components/settings/SettingsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue