feat: build housekeeping command deck shell

This commit is contained in:
Simo committed 2026-08-25 20:05:56 +02:00
1 parent cfeb0f19b8
commit addc9c7b1a
9 files changed
+528

No files matched your search

@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
interface HousekeepingPageShellProps {
title: string;
description: string;
primaryAction?: ReactNode;
context?: ReactNode;
children: ReactNode;
}
export function HousekeepingPageShell({
title,
description,
primaryAction,
context,
children,
}: HousekeepingPageShellProps) {
return (
<section className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4">
<div className="min-w-0 space-y-1">
<h1 className="text-xl font-semibold text-[var(--admin-text)]">
{title}
</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
{description}
</p>
{context ? <div>{context}</div> : null}
</div>
{primaryAction ? <div>{primaryAction}</div> : null}
</header>
<div>{children}</div>
</section>
);
}
@@ -0,0 +1,84 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { HousekeepingPageShell } from "./housekeeping-page-shell";
import { HousekeepingPageState } from "./housekeeping-page-state";
describe("HousekeepingPageState", () => {
it.each([
["loading", "status"],
["empty", "status"],
["partial", "status"],
["error", "alert"],
] as const)("renders %s with the %s role", (state, role) => {
const html = renderToStaticMarkup(
<HousekeepingPageState
state={state}
title={`${state} title`}
description={`${state} description`}
/>,
);
expect(html).toContain(`role="${role}"`);
expect(html).toContain(`>${state} title<`);
expect(html).toContain(`>${state} description<`);
});
it("announces loading politely and makes the partial warning visible", () => {
const loading = renderToStaticMarkup(
<HousekeepingPageState
state="loading"
title="Loading title"
description="Loading description"
/>,
);
const partial = renderToStaticMarkup(
<HousekeepingPageState
state="partial"
title="Partial title"
description="Partial description"
/>,
);
expect(loading).toContain('aria-live="polite"');
expect(partial).toContain('data-state-tone="warning"');
expect(partial).toContain(">Partial title<");
});
it("renders supplied retry content for an error without owning a callback", () => {
const html = renderToStaticMarkup(
<HousekeepingPageState
state="error"
title="Could not load"
description="Try again later"
retryAction={<a href="/admin-next/operations">Retry preview</a>}
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain('href="/admin-next/operations"');
expect(html).toContain(">Retry preview<");
});
});
describe("HousekeepingPageShell", () => {
it("renders supplied header, context, action, and body content", () => {
const html = renderToStaticMarkup(
<HousekeepingPageShell
title="People"
description="Review operator-facing people data"
context={<span>Preview context</span>}
primaryAction={<a href="/admin-next/people/new">Create preview</a>}
>
<p>Page body</p>
</HousekeepingPageShell>,
);
expect(html).toContain("<header");
expect(html).toContain(">People<");
expect(html).toContain(">Review operator-facing people data<");
expect(html).toContain(">Preview context<");
expect(html).toContain('href="/admin-next/people/new"');
expect(html).toContain(">Create preview<");
expect(html).toContain(">Page body</p>");
});
});
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
interface HousekeepingPageStateProps {
state: "loading" | "empty" | "partial" | "error";
title: string;
description: string;
retryAction?: ReactNode;
}
export function HousekeepingPageState({
state,
title,
description,
retryAction,
}: HousekeepingPageStateProps) {
const isError = state === "error";
const isPartial = state === "partial";
return (
<section
role={isError ? "alert" : "status"}
aria-live={state === "loading" ? "polite" : undefined}
className={`rounded-lg border p-4 ${
isError
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
: isPartial
? "border-[var(--admin-warning)] bg-[var(--admin-surface)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
}`}
>
{isPartial ? (
<p
data-state-tone="warning"
className="font-medium text-[var(--admin-warning)]"
>
{title}
</p>
) : (
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
)}
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{description}
</p>
{retryAction ? <div className="mt-3">{retryAction}</div> : null}
</section>
);
}
@@ -0,0 +1,15 @@
interface CommandTriggerProps {
label: string;
}
export function CommandTrigger({ label }: CommandTriggerProps) {
return (
<button
type="button"
disabled
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text-muted)]"
>
{label}
</button>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
interface ContextNavProps {
items: readonly { id: string; href: string; label: string }[];
ariaLabel: string;
}
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
return (
<nav aria-label={ariaLabel} className="min-w-0">
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
{items.map((item) => (
<li key={item.id} className="min-w-max">
<Link
href={item.href}
className="block rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,49 @@
import { FileText, Gem, Hotel, Inbox, Settings, Users } from "lucide-react";
import Link from "next/link";
import type { HousekeepingNavigationDomain } from "../navigation";
interface DomainRailProps {
domains: readonly HousekeepingNavigationDomain[];
activeDomainId: HousekeepingNavigationDomain["id"];
ariaLabel: string;
}
function DomainIcon({ iconId }: Pick<HousekeepingNavigationDomain, "iconId">) {
const Icon = {
inbox: Inbox,
users: Users,
"file-text": FileText,
gem: Gem,
hotel: Hotel,
settings: Settings,
}[iconId];
return <Icon aria-hidden="true" size={18} />;
}
export function DomainRail({
domains,
activeDomainId,
ariaLabel,
}: DomainRailProps) {
return (
<nav aria-label={ariaLabel} className="w-full lg:w-20">
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
{domains.map((domain) => (
<li key={domain.id} className="min-w-max lg:min-w-0">
<Link
href={domain.href}
aria-current={domain.id === activeDomainId ? "page" : undefined}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-surface)] hover:text-[var(--admin-text)] lg:justify-center"
>
<span data-domain-icon={domain.iconId}>
<DomainIcon iconId={domain.iconId} />
</span>
<span className="lg:sr-only">{domain.label}</span>
</Link>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,174 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { HousekeepingNavigationDomain } from "../navigation";
import { HousekeepingShell } from "./housekeeping-shell";
const labels = {
skipToContent: "Skip to housekeeping content",
primaryNavigation: "Primary housekeeping navigation",
contextualNavigation: "Operations navigation",
command: "Commands are unavailable in preview",
preview: "Preview",
backToSite: "Back to site",
};
const domains: readonly HousekeepingNavigationDomain[] = [
{
id: "operations",
href: "/admin-next/operations",
iconId: "inbox",
label: "Operations",
description: "Manage operations",
items: [
{ id: "queue", href: "/admin-next/operations/queue", label: "Queue" },
],
},
{
id: "people",
href: "/admin-next/people",
iconId: "users",
label: "People",
description: "Manage people",
items: [],
},
{
id: "content",
href: "/admin-next/content",
iconId: "file-text",
label: "Content",
description: "Manage content",
items: [],
},
{
id: "economy",
href: "/admin-next/economy",
iconId: "gem",
label: "Economy",
description: "Manage economy",
items: [],
},
{
id: "hotel",
href: "/admin-next/hotel",
iconId: "hotel",
label: "Hotel",
description: "Manage hotel",
items: [],
},
{
id: "system",
href: "/admin-next/system",
iconId: "settings",
label: "System",
description: "Manage system",
items: [],
},
];
function renderShell(
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
): string {
return renderToStaticMarkup(
<HousekeepingShell
actor={{ id: 7, username: "Nora", rank: 6 }}
activeDomainId={activeDomainId}
domains={domains}
labels={labels}
>
<p>Deck body</p>
</HousekeepingShell>,
);
}
describe("HousekeepingShell", () => {
it("renders the accessible command deck landmarks and supplied shell content", () => {
const html = renderShell();
expect(html).toContain('href="#housekeeping-content"');
expect(html).toContain(">Skip to housekeeping content<");
expect(html).toContain("<aside");
expect(html).toContain("<header");
expect(html).toContain('<nav aria-label="Primary housekeeping navigation"');
expect(html).toContain('<nav aria-label="Operations navigation"');
expect(html).toContain('<main id="housekeeping-content"');
expect(html).toContain(">Deck body</p>");
expect(html).toMatch(
/<a(?=[^>]*href="\/admin-next\/operations")(?=[^>]*aria-current="page")/,
);
expect(html).toContain('href="/admin-next/operations/queue"');
expect(html).toContain(">Queue<");
expect(html).toContain(">Nora<");
expect(html).toContain(">6<");
expect(html).toContain(">Preview<");
expect(html).toContain('href="/"');
expect(html).toContain(">Back to site<");
expect(html).toMatch(
/<button type="button" disabled=""[^>]*>Commands are unavailable in preview<\/button>/,
);
});
it("maps every authorized domain icon and only marks the active domain", () => {
const html = renderShell("people");
for (const iconId of [
"inbox",
"users",
"file-text",
"gem",
"hotel",
"settings",
]) {
expect(html).toContain(`data-domain-icon="${iconId}"`);
}
expect(html).toMatch(
/<a(?=[^>]*href="\/admin-next\/people")(?=[^>]*aria-current="page")/,
);
expect(html).not.toContain(
'href="/admin-next/operations" aria-current="page"',
);
});
it("renders no contextual items when the active domain is absent", () => {
const html = renderShell("system");
expect(html).not.toContain('href="/admin-next/operations/queue"');
expect(html).not.toContain(">Queue<");
});
it("keeps Task 10 modules within the server-rendered source boundary", () => {
const taskFiles = [
"src/features/housekeeping/foundation/shell/housekeeping-shell.tsx",
"src/features/housekeeping/foundation/shell/domain-rail.tsx",
"src/features/housekeeping/foundation/shell/context-nav.tsx",
"src/features/housekeeping/foundation/shell/command-trigger.tsx",
"src/features/housekeeping/foundation/shell/operator-summary.tsx",
"src/features/housekeeping/foundation/page/housekeeping-page-shell.tsx",
"src/features/housekeeping/foundation/page/housekeeping-page-state.tsx",
"src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx",
"src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx",
];
const prohibited = [
new RegExp("use " + "client"),
new RegExp("cmd" + "k", "i"),
new RegExp("addEvent" + "Listener"),
new RegExp(
"from\\s+['\"][^'\"]*(?:@/lib/" +
"db|drizzle|/" +
"actions)[^'\"]*['\"]",
),
new RegExp("next" + "-intl"),
/(?:bg|text|border|from|to)-(?:red|blue|green|yellow|orange|purple|pink|gray|slate|zinc|neutral)-\d{2,3}/,
new RegExp("#" + "[0-9a-fA-F]{3,8}"),
new RegExp("(?:r" + "gb|h" + "sl)\\("),
];
for (const path of taskFiles) {
const source = readFileSync(resolve(process.cwd(), path), "utf8");
for (const pattern of prohibited) {
expect(source).not.toMatch(pattern);
}
}
});
});
@@ -0,0 +1,80 @@
import Link from "next/link";
import type { ReactNode } from "react";
import type { HousekeepingDomainId } from "../../migration/types";
import type { HousekeepingActor } from "../contracts";
import type { HousekeepingNavigationDomain } from "../navigation";
import { CommandTrigger } from "./command-trigger";
import { ContextNav } from "./context-nav";
import { DomainRail } from "./domain-rail";
import { OperatorSummary } from "./operator-summary";
interface HousekeepingShellProps {
actor: HousekeepingActor;
activeDomainId: HousekeepingDomainId;
domains: readonly HousekeepingNavigationDomain[];
labels: {
skipToContent: string;
primaryNavigation: string;
contextualNavigation: string;
command: string;
preview: string;
backToSite: string;
};
children: ReactNode;
}
export function HousekeepingShell({
actor,
activeDomainId,
domains,
labels,
children,
}: HousekeepingShellProps) {
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
return (
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]">
<a
href="#housekeeping-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-10 rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)]"
>
{labels.skipToContent}
</a>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-3">
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview}
</span>
<OperatorSummary actor={actor} />
</div>
<div className="flex items-center gap-2">
<CommandTrigger label={labels.command} />
<Link
href="/"
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
>
{labels.backToSite}
</Link>
</div>
</header>
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row">
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r">
<DomainRail
domains={domains}
activeDomainId={activeDomainId}
ariaLabel={labels.primaryNavigation}
/>
</aside>
<div className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:border-b-0 lg:border-r">
<ContextNav
items={activeDomain?.items ?? []}
ariaLabel={labels.contextualNavigation}
/>
</div>
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import type { HousekeepingActor } from "../contracts";
interface OperatorSummaryProps {
actor: HousekeepingActor;
}
export function OperatorSummary({ actor }: OperatorSummaryProps) {
return (
<div className="min-w-0 text-sm text-[var(--admin-text)]">
<div className="truncate font-medium">{actor.username}</div>
<div
data-operator-rank={actor.rank}
className="text-[var(--admin-text-muted)]"
>
{actor.rank}
</div>
</div>
);
}