feat(ui): Settings destination Explorer plumbing (slice 26 Checkpoint A)

Adds the "settings" surface to ExplorerSurface dispatch + new
SettingsExplorer sidebar component + path-to-surface mapping so the
Settings destination joins Topology / Project / Library as a true
Explorer-bearing peer.

NEW packages/ui/src/components/system/SettingsExplorer.tsx:
- Flat sidebar list of 4 items (Settings / Policies / Log / Status)
  matching the dispatch's Explorer-item mapping
- Each item is a TanStack Router Link to its sub-route:
    /settings, /settings/policies, /settings/log, /settings/status
- Active state derived from current pathname via useRouterState;
  /settings is the bare-match for Settings (does NOT trigger sub-route
  active state)
- Stable testid surface: settings-explorer, settings-explorer-heading,
  settings-explorer-item-{settings|policies|log|status}

MODIFY packages/ui/src/components/Explorer.tsx:
- ExplorerSurface union extends with "settings"
- treeForSurface() dispatch adds `if (surface === "settings") return
  <SettingsExplorer />` branch alongside topology/project/specs/for-you
- Existing "none" fallthrough preserved for Dashboard

MODIFY packages/ui/src/components/AppShell.tsx:
- surfaceForPath() adds `if (pathname.startsWith("/settings")) return
  "settings"` line. Prior "none" return reachable only for Dashboard.

Tests (TDD; 8 leaf tests):
- 4 items render (HG-2)
- Each item is a Link with the expected href (HG-3)
- Per-route active-state derivation: /settings only activates
  Settings; /settings/policies activates Policies; /settings/log
  activates Log; /settings/status activates Status
- Explorer root testid + item labels match dispatch

Test infrastructure: tests use createAppTestRouter (vs createTestRouter)
to register all 4 sub-routes so the TanStack Link href + active-state
derivation work at any starting path. waitFor() wraps the synchronous
queries since the test router mounts asynchronously.

Typecheck: touched files clean.

WHAT THIS CHECKPOINT DOES NOT DO (Checkpoint B):
- Register the 3 new routes (/settings/policies, /settings/log,
  /settings/status) in routes.tsx
- Refactor SettingsCenter to be route-driven (remove inline tab state
  + render content per active route segment)
- Create PoliciesPage empty-scaffold component
- Remove the top-row tabs from Settings center

SC-29 EXCEPTION declared: NONE (UI presentation-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-12 02:37:36 -07:00
1 parent f27da1a48a
commit e6b20d082e
4 files changed
+194

No files matched your search

+1
View File
@@ -237,6 +237,7 @@ function surfaceForPath(pathname: string): ExplorerSurface {
if (pathname.startsWith("/project")) return "project";
if (pathname.startsWith("/specs") || pathname.startsWith("/plugins")) return "specs";
if (pathname.startsWith("/for-you")) return "for-you";
if (pathname.startsWith("/settings")) return "settings";
return "none";
}
+5
View File
@@ -16,6 +16,7 @@ import {
import { EmptyState } from "./ui/empty-state.js";
import { ProjectTreeView } from "./project/ProjectTreeView.js";
import { SpecsTreeView } from "./specs/SpecsTreeView.js";
import { SettingsExplorer } from "./system/SettingsExplorer.js";
import { TopologyTreeView } from "./topology/TopologyTreeView.js";
import { SubscriptionToggleList } from "./for-you/SubscriptionToggleList.js";
@@ -33,6 +34,7 @@ export type ExplorerSurface =
| "project"
| "specs"
| "for-you"
| "settings"
| "none";
interface ExplorerProps {
@@ -214,6 +216,9 @@ function SurfaceBody({
if (surface === "specs") {
return <SpecsTreeView />;
}
if (surface === "settings") {
return <SettingsExplorer />;
}
if (surface === "for-you") {
// Subscription affordance — settings-shaped surface per for-you-feed.md L134-L140.
// The PRIMARY UX of /for-you is the FEED in the center; subscriptions live
@@ -0,0 +1,90 @@
// Slice 26 — Settings destination Explorer sidebar.
//
// Renders the 4 Settings destinations as a flat sidebar list (peer to
// Topology / Project / Library / For-You destinations per dispatch).
// Each item is a TanStack Router Link to its sub-route. The active
// item is derived from the current router pathname so the sidebar
// stays in sync regardless of how the user navigated.
import { Link, useRouterState } from "@tanstack/react-router";
import { cn } from "../../lib/utils.js";
interface SettingsExplorerItem {
id: "settings" | "policies" | "log" | "status";
label: string;
href: string;
/**
* Predicate matching the current pathname to "active" state.
* The Settings root is the exact match `/settings`; sub-routes
* match their full path.
*/
isActive: (pathname: string) => boolean;
}
const SETTINGS_ITEMS: SettingsExplorerItem[] = [
{
id: "settings",
label: "Settings",
href: "/settings",
// Active for the bare /settings only — not for /settings/<sub>.
isActive: (path) => path === "/settings",
},
{
id: "policies",
label: "Policies",
href: "/settings/policies",
isActive: (path) => path.startsWith("/settings/policies"),
},
{
id: "log",
label: "Log",
href: "/settings/log",
isActive: (path) => path.startsWith("/settings/log"),
},
{
id: "status",
label: "Status",
href: "/settings/status",
isActive: (path) => path.startsWith("/settings/status"),
},
];
export function SettingsExplorer() {
const routerState = useRouterState();
const pathname = routerState.location.pathname;
return (
<div data-testid="settings-explorer" className="flex-1 overflow-y-auto py-2">
<div className="px-2 mb-2">
<span
data-testid="settings-explorer-heading"
className="block font-mono text-[11px] uppercase tracking-wide text-stone-900 px-2 py-1"
>
{"> "}Settings
</span>
</div>
<ul className="px-2 space-y-0.5">
{SETTINGS_ITEMS.map((item) => {
const active = item.isActive(pathname);
return (
<li key={item.id}>
<Link
to={item.href}
data-testid={`settings-explorer-item-${item.id}`}
data-active={active}
className={cn(
"block font-mono text-[11px] uppercase tracking-wide px-2 py-1",
active
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:text-stone-900 hover:bg-surface-low",
)}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,98 @@
// Slice 26 Checkpoint A — SettingsExplorer (sidebar) tests.
import { describe, it, expect, afterEach } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { SettingsExplorer } from "../src/components/system/SettingsExplorer.js";
import { createAppTestRouter } from "./helpers/test-router.js";
afterEach(() => {
cleanup();
});
function renderAt(initialPath: string) {
// All 4 Settings sub-routes mount the SettingsExplorer so the Link
// active-state derivation can be exercised at any of them.
const explorerComponent = () => <SettingsExplorer />;
return render(
createAppTestRouter({
initialPath,
routes: [
{ path: "/settings", component: explorerComponent },
{ path: "/settings/policies", component: explorerComponent },
{ path: "/settings/log", component: explorerComponent },
{ path: "/settings/status", component: explorerComponent },
],
}),
);
}
describe("SettingsExplorer", () => {
it("renders 4 items: Settings / Policies / Log / Status (HG-2)", async () => {
renderAt("/settings");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer-item-settings")).toBeTruthy();
});
expect(screen.getByTestId("settings-explorer-item-policies")).toBeTruthy();
expect(screen.getByTestId("settings-explorer-item-log")).toBeTruthy();
expect(screen.getByTestId("settings-explorer-item-status")).toBeTruthy();
});
it("each item is a navigation link with the expected href", async () => {
renderAt("/settings");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer-item-settings")).toBeTruthy();
});
expect(screen.getByTestId("settings-explorer-item-settings").getAttribute("href")).toBe("/settings");
expect(screen.getByTestId("settings-explorer-item-policies").getAttribute("href")).toBe("/settings/policies");
expect(screen.getByTestId("settings-explorer-item-log").getAttribute("href")).toBe("/settings/log");
expect(screen.getByTestId("settings-explorer-item-status").getAttribute("href")).toBe("/settings/status");
});
it("on /settings: Settings item is marked active (data-active=true)", async () => {
renderAt("/settings");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer-item-settings").getAttribute("data-active")).toBe("true");
});
expect(screen.getByTestId("settings-explorer-item-policies").getAttribute("data-active")).toBe("false");
});
it("on /settings/policies: Policies item is marked active", async () => {
renderAt("/settings/policies");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer-item-policies").getAttribute("data-active")).toBe("true");
});
expect(screen.getByTestId("settings-explorer-item-settings").getAttribute("data-active")).toBe("false");
});
it("on /settings/log: Log item is marked active", async () => {
renderAt("/settings/log");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer-item-log").getAttribute("data-active")).toBe("true");
});
});
it("on /settings/status: Status item is marked active", async () => {
renderAt("/settings/status");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer-item-status").getAttribute("data-active")).toBe("true");
});
});
it("explorer root has discoverable testid for downstream selectors", async () => {
renderAt("/settings");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer")).toBeTruthy();
});
});
it("item labels are human-readable + match dispatch (Settings / Policies / Log / Status)", async () => {
renderAt("/settings");
await waitFor(() => {
expect(screen.getByTestId("settings-explorer-item-settings")).toBeTruthy();
});
expect(screen.getByTestId("settings-explorer-item-settings").textContent).toMatch(/settings/i);
expect(screen.getByTestId("settings-explorer-item-policies").textContent).toMatch(/policies/i);
expect(screen.getByTestId("settings-explorer-item-log").textContent).toMatch(/log/i);
expect(screen.getByTestId("settings-explorer-item-status").textContent).toMatch(/status/i);
});
});