feat(housekeeping): finish accessible command deck

This commit is contained in:
Simo committed 2026-08-30 18:49:10 +02:00
1 parent 85ad0452d3
commit a113e48880
55 files changed
+17064 -509

No files matched your search

@@ -1,4 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route"; import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route";
@@ -40,11 +41,13 @@ export default async function HousekeepingPreviewRoutePage({
) { ) {
notFound(); notFound();
} }
const translate = await getTranslations("pages.housekeeping");
return handler.render({ return handler.render({
context, context,
match, match,
surface: "preview", surface: "preview",
translate: (key) => translate(key as never),
...(searchParams ? { searchParams: await searchParams } : {}), ...(searchParams ? { searchParams: await searchParams } : {}),
}); });
} }
+3
View File
@@ -54,6 +54,9 @@ export default async function HousekeepingPreviewDomainLayout({
command: translate("preview.commandDisabled"), command: translate("preview.commandDisabled"),
preview: translate("preview.badge"), preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"), backToSite: translate("preview.backToSite"),
operatorRank: translate("navigation.operatorRank", {
rank: context.actor.rank,
}),
commandDeck: { commandDeck: {
placeholder: translate("commandDeck.placeholder"), placeholder: translate("commandDeck.placeholder"),
navigation: translate("commandDeck.navigation"), navigation: translate("commandDeck.navigation"),
+4
View File
@@ -73,6 +73,7 @@ export default async function HousekeepingPreviewRootPage() {
context, context,
match, match,
surface: "preview", surface: "preview",
translate: (key) => translate(key as never),
}); });
return ( return (
@@ -87,6 +88,9 @@ export default async function HousekeepingPreviewRootPage() {
command: translate("preview.commandDisabled"), command: translate("preview.commandDisabled"),
preview: translate("preview.badge"), preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"), backToSite: translate("preview.backToSite"),
operatorRank: translate("navigation.operatorRank", {
rank: context.actor.rank,
}),
commandDeck: { commandDeck: {
placeholder: translate("commandDeck.placeholder"), placeholder: translate("commandDeck.placeholder"),
navigation: translate("commandDeck.navigation"), navigation: translate("commandDeck.navigation"),
+36
View File
@@ -1618,6 +1618,42 @@ details[open] > summary .details-open\:rotate-180 {
} }
} }
/* Housekeeping command deck: keyboard focus, narrow screens, and motion safety. */
[data-housekeeping-root] :is(a, button, input, select, textarea):focus-visible {
outline: 2px solid var(--admin-accent);
outline-offset: 2px;
}
#housekeeping-content {
max-width: 100%;
overflow-x: clip;
}
#housekeeping-content table {
display: block;
max-width: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
#housekeeping-content :is(pre, code, img, svg, canvas) {
max-width: 100%;
}
@media (prefers-reduced-motion: reduce) {
[data-housekeeping-root],
[data-housekeeping-root] * {
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
scroll-behavior: auto !important;
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
animation-duration: 0.01ms !important;
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
animation-iteration-count: 1 !important;
/* biome-ignore lint/complexity/noImportantStyles: accessibility preference must override component styles */
transition-duration: 0.01ms !important;
}
}
/* ── Premium effects ── */ /* ── Premium effects ── */
@keyframes float { @keyframes float {
0%, 0%,
@@ -14,6 +14,27 @@ import {
WidgetSettings, WidgetSettings,
} from "../../../foundation/shell/widget-settings"; } from "../../../foundation/shell/widget-settings";
export interface OperationsPersonalizationLabels {
readonly favorites: {
readonly title: string;
readonly pin: string;
readonly unpin: string;
readonly error: string;
readonly reorderInstructions: string;
};
readonly widgetSettings: {
readonly title: string;
readonly mandatory: string;
readonly error: string;
};
readonly widgets: {
readonly title: string;
readonly partial: string;
readonly error: string;
readonly reorderInstructions: string;
};
}
const persistPreferences: HousekeepingPreferenceSaver = async (preferences) => { const persistPreferences: HousekeepingPreferenceSaver = async (preferences) => {
const { saveHousekeepingPreferences } = await import( const { saveHousekeepingPreferences } = await import(
"@/actions/housekeeping-preferences" "@/actions/housekeeping-preferences"
@@ -27,6 +48,9 @@ export function OperationsPersonalization({
widgets, widgets,
widgetResult, widgetResult,
surface, surface,
labels,
routeTitles,
widgetTitles,
savePreferences = persistPreferences, savePreferences = persistPreferences,
}: { }: {
readonly preferences: HousekeepingPreferences; readonly preferences: HousekeepingPreferences;
@@ -34,6 +58,9 @@ export function OperationsPersonalization({
readonly widgets: readonly HousekeepingWidgetDefinition[]; readonly widgets: readonly HousekeepingWidgetDefinition[];
readonly widgetResult: HousekeepingWidgetLoadResult; readonly widgetResult: HousekeepingWidgetLoadResult;
readonly surface: HousekeepingSurface; readonly surface: HousekeepingSurface;
readonly labels: OperationsPersonalizationLabels;
readonly routeTitles: Readonly<Record<string, string>>;
readonly widgetTitles: Readonly<Record<string, string>>;
readonly savePreferences?: HousekeepingPreferenceSaver; readonly savePreferences?: HousekeepingPreferenceSaver;
}) { }) {
return ( return (
@@ -44,22 +71,15 @@ export function OperationsPersonalization({
routes={routes} routes={routes}
surface={surface} surface={surface}
savePreferences={savePreferences} savePreferences={savePreferences}
labels={{ labels={labels.favorites}
title: "Favorites", titles={routeTitles}
pin: "Pin",
unpin: "Unpin",
error: "Preferences could not be saved",
}}
/> />
<WidgetSettings <WidgetSettings
preferences={preferences} preferences={preferences}
widgets={widgets} widgets={widgets}
savePreferences={savePreferences} savePreferences={savePreferences}
labels={{ labels={labels.widgetSettings}
title: "Widget settings", titles={widgetTitles}
mandatory: "Mandatory",
error: "Preferences could not be saved",
}}
/> />
</div> </div>
@@ -67,11 +87,8 @@ export function OperationsPersonalization({
preferences={preferences} preferences={preferences}
result={widgetResult} result={widgetResult}
savePreferences={savePreferences} savePreferences={savePreferences}
labels={{ labels={labels.widgets}
title: "Widgets", titles={widgetTitles}
partial: "Some widgets are unavailable",
error: "Widget order could not be saved",
}}
/> />
</> </>
); );
@@ -79,6 +79,45 @@ const result: OperationsWorkspaceLoadResult = {
}; };
const savePreferences = vi.fn(async (value) => ok(value, "saved")); const savePreferences = vi.fn(async (value) => ok(value, "saved"));
const labels = {
title: "Operations workspace",
description: "Capability-derived operational home",
noDomainsTitle: "No operational domains are available",
noDomainsDescription: "No authorized domain",
partial: "Some workspace sections are unavailable",
inbox: {
title: "Operational inbox",
domainFilter: "Filter by domain",
stateFilter: "Filter by state",
priorityFilter: "Filter by priority",
all: "All",
loading: "Loading",
empty: "Empty",
partial: "Some sources are unavailable",
open: "Open",
},
recent: { title: "Recent work", empty: "Nothing recent" },
favorites: {
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Preferences could not be saved",
reorderInstructions: "Press Space, then use arrow keys.",
},
widgetSettings: {
title: "Widget settings",
mandatory: "Mandatory",
error: "Preferences could not be saved",
},
widgets: {
title: "Widgets",
partial: "Some widgets are unavailable",
error: "Widget order could not be saved",
reorderInstructions: "Press Space, then use arrow keys.",
},
} as const;
const routeTitles = { Users: "Users" };
const widgetTitles = { "people.queue": "People queue" };
describe("OperationsWorkspacePage", () => { describe("OperationsWorkspacePage", () => {
it("renders capability-filtered sections, partial failures, and preview links", () => { it("renders capability-filtered sections, partial failures, and preview links", () => {
@@ -88,6 +127,9 @@ describe("OperationsWorkspacePage", () => {
routes={routes} routes={routes}
widgetDefinitions={widgets} widgetDefinitions={widgets}
surface="preview" surface="preview"
labels={labels}
routeTitles={routeTitles}
widgetTitles={widgetTitles}
savePreferences={savePreferences} savePreferences={savePreferences}
/>, />,
); );
@@ -97,8 +139,8 @@ describe("OperationsWorkspacePage", () => {
expect(html).toContain("Favorites"); expect(html).toContain("Favorites");
expect(html).toContain("Widget settings"); expect(html).toContain("Widget settings");
expect(html).toContain("Some workspace sections are unavailable"); expect(html).toContain("Some workspace sections are unavailable");
expect(html).toContain("system.alerts"); expect(html).not.toContain("system.alerts");
expect(html).toContain("system.health"); expect(html).not.toContain("system.health");
expect(html).toContain('href="/ase-next/people/users"'); expect(html).toContain('href="/ase-next/people/users"');
}); });
@@ -109,6 +151,9 @@ describe("OperationsWorkspacePage", () => {
routes={[]} routes={[]}
widgetDefinitions={[]} widgetDefinitions={[]}
surface="preview" surface="preview"
labels={labels}
routeTitles={{}}
widgetTitles={{}}
savePreferences={savePreferences} savePreferences={savePreferences}
/>, />,
); );
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -16,10 +16,16 @@ export function HousekeepingPageShell({
children, children,
}: HousekeepingPageShellProps) { }: HousekeepingPageShellProps) {
return ( return (
<section className="space-y-6"> <section
aria-labelledby="housekeeping-page-title"
className="min-w-0 max-w-full space-y-6"
>
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--admin-border)] pb-4"> <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"> <div className="min-w-0 space-y-1">
<h1 className="text-xl font-semibold text-[var(--admin-text)]"> <h1
id="housekeeping-page-title"
className="text-xl font-semibold text-[var(--admin-text)]"
>
{title} {title}
</h1> </h1>
<p className="text-sm text-[var(--admin-text-muted)]"> <p className="text-sm text-[var(--admin-text-muted)]">
@@ -29,7 +35,7 @@ export function HousekeepingPageShell({
</div> </div>
{primaryAction ? <div>{primaryAction}</div> : null} {primaryAction ? <div>{primaryAction}</div> : null}
</header> </header>
<div>{children}</div> <div className="min-w-0 max-w-full">{children}</div>
</section> </section>
); );
} }
@@ -11,6 +11,8 @@ describe("HousekeepingPageState", () => {
["empty", "status"], ["empty", "status"],
["partial", "status"], ["partial", "status"],
["error", "alert"], ["error", "alert"],
["forbidden", "alert"],
["ready", "status"],
] as const)("renders %s with the %s role", (state, role) => { ] as const)("renders %s with the %s role", (state, role) => {
const pageState = const pageState =
state === "partial" ? ( state === "partial" ? (
@@ -34,6 +36,35 @@ describe("HousekeepingPageState", () => {
expect(html).toContain(`>${state} description<`); expect(html).toContain(`>${state} description<`);
}); });
it.each([
["loading", "polite"],
["empty", "polite"],
["partial", "polite"],
["error", "assertive"],
["forbidden", "assertive"],
["ready", "polite"],
] as const)("announces %s changes with %s priority", (state, live) => {
const pageState =
state === "partial" ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial"
title={`${state} title`}
description={`${state} description`}
/>
) : (
<HousekeepingPageState
state={state}
title={`${state} title`}
description={`${state} description`}
/>
);
const html = renderToStaticMarkup(pageState);
expect(html).toContain(`data-page-state="${state}"`);
expect(html).toContain(`aria-live="${live}"`);
});
it("announces loading politely", () => { it("announces loading politely", () => {
const loading = renderToStaticMarkup( const loading = renderToStaticMarkup(
<HousekeepingPageState <HousekeepingPageState
@@ -6,10 +6,21 @@ interface HousekeepingPageStateBaseProps {
retryAction?: ReactNode; retryAction?: ReactNode;
} }
export type HousekeepingPageState =
| "loading"
| "empty"
| "partial"
| "error"
| "forbidden"
| "ready";
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps & type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
( (
| { state: "partial"; partialLabel: string } | { state: "partial"; partialLabel: string }
| { state: "loading" | "empty" | "error"; partialLabel?: never } | {
state: Exclude<HousekeepingPageState, "partial">;
partialLabel?: never;
}
); );
export function HousekeepingPageState({ export function HousekeepingPageState({
@@ -19,13 +30,14 @@ export function HousekeepingPageState({
description, description,
retryAction, retryAction,
}: HousekeepingPageStateProps) { }: HousekeepingPageStateProps) {
const isError = state === "error"; const isError = state === "error" || state === "forbidden";
const isPartial = state === "partial"; const isPartial = state === "partial";
return ( return (
<section <section
role={isError ? "alert" : "status"} role={isError ? "alert" : "status"}
aria-live={state === "loading" ? "polite" : undefined} aria-live={isError ? "assertive" : "polite"}
data-page-state={state}
className={`rounded-lg border p-4 ${ className={`rounded-lg border p-4 ${
isError isError
? "border-[var(--admin-error)] bg-[var(--admin-surface)]" ? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
@@ -15,6 +15,7 @@ const routeMocks = vi.hoisted(() => {
"navigation.skipToContent": "HK::skip-to-content", "navigation.skipToContent": "HK::skip-to-content",
"navigation.primary": "HK::primary-navigation", "navigation.primary": "HK::primary-navigation",
"navigation.contextual": "HK::contextual-navigation", "navigation.contextual": "HK::contextual-navigation",
"navigation.operatorRank": "HK::operator-rank",
"commandDeck.placeholder": "HK::command-placeholder", "commandDeck.placeholder": "HK::command-placeholder",
"commandDeck.navigation": "HK::command-navigation", "commandDeck.navigation": "HK::command-navigation",
"commandDeck.commands": "HK::command-commands", "commandDeck.commands": "HK::command-commands",
@@ -802,6 +803,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({ expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context, context,
surface: "preview", surface: "preview",
translate: expect.any(Function),
match: { match: {
routeId: "people.users", routeId: "people.users",
domain: "people", domain: "people",
@@ -809,7 +811,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
canonicalHref: "/ase/people/users", canonicalHref: "/ase/people/users",
}, },
}); });
expect(routeMocks.getTranslations).not.toHaveBeenCalled(); expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
}); });
it("maps nested preview segments to canonical dynamic parameters", async () => { it("maps nested preview segments to canonical dynamic parameters", async () => {
@@ -828,6 +830,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({ expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context, context,
surface: "preview", surface: "preview",
translate: expect.any(Function),
match: { match: {
routeId: "people.user-detail", routeId: "people.user-detail",
domain: "people", domain: "people",
@@ -853,6 +856,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({ expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context, context,
surface: "preview", surface: "preview",
translate: expect.any(Function),
match: { match: {
routeId: "people.literal-tool", routeId: "people.literal-tool",
domain: "people", domain: "people",
@@ -0,0 +1,97 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { CommandDeck } from "./shell/command-deck";
import { HousekeepingShell } from "./shell/housekeeping-shell";
vi.mock("@/actions/housekeeping-search", () => ({
executeHousekeepingSearch: vi.fn(),
}));
const labels = {
skipToContent: "Skip",
primaryNavigation: "Primary",
contextualNavigation: "Context",
command: "Search",
preview: "Preview",
backToSite: "Back",
operatorRank: "Rank 6",
commandDeck: {
placeholder: "Search",
navigation: "Navigation",
commands: "Commands",
entities: "Entities",
loading: "Loading",
empty: "Empty",
partial: "Partial",
close: "Close",
},
};
describe("housekeeping responsive contract", () => {
it("stacks both navigation levels below lg without removing capabilities", () => {
const html = renderToStaticMarkup(
<HousekeepingShell
actor={{ id: 7, username: "Nora", rank: 6 }}
activeDomainId="operations"
domains={[
{
id: "operations",
href: "/ase/operations",
iconId: "inbox",
label: "Operations",
description: "Operations",
items: [{ id: "workspace", href: "/ase", label: "Workspace" }],
},
]}
labels={labels}
surface="canonical"
>
<div>Body</div>
</HousekeepingShell>,
);
expect(html).toContain('data-responsive-mode="stacked-below-lg"');
expect(html).toContain('data-navigation-collapse="domain"');
expect(html).toContain('data-navigation-collapse="context"');
expect(html).toContain('href="/ase/operations"');
expect(html).toContain('href="/ase"');
expect(html).toContain("overflow-x-auto");
expect(html).toContain("min-w-0");
});
it("keeps the command deck bounded at a 320px viewport", () => {
const source = readFileSync(
resolve(
process.cwd(),
"src/features/housekeeping/foundation/shell/command-deck.tsx",
),
"utf8",
);
const trigger = renderToStaticMarkup(
<CommandDeck
labels={{ ...labels.commandDeck, trigger: "Search" }}
surface="canonical"
/>,
);
expect(trigger).toContain("min-w-0");
expect(source).toContain("max-h-[calc(100dvh-2rem)]");
expect(source).toContain("overflow-hidden");
expect(source).toContain("p-2 sm:p-4");
});
it("provides non-overflow and reduced-motion safeguards for housekeeping content", () => {
const css = readFileSync(
resolve(process.cwd(), "src/app/globals.css"),
"utf8",
);
expect(css).toContain("[data-housekeeping-root]");
expect(css).toContain("#housekeeping-content table");
expect(css).toContain("overflow-x: auto");
expect(css).toContain("@media (prefers-reduced-motion: reduce)");
expect(css).toContain("transition-duration: 0.01ms");
});
});
@@ -58,7 +58,13 @@ function matches(tokens: readonly string[], ...values: readonly string[]) {
return tokens.every((token) => haystack.includes(token)); return tokens.every((token) => haystack.includes(token));
} }
function commandTitle(commandId: string): string { function commandTitle(
commandId: string,
translate: (key: string) => string,
): string {
const key = `pages.housekeeping.commands.${commandId}`;
const translated = translate(key);
if (translated !== key) return translated;
return commandId return commandId
.split(".") .split(".")
.slice(1) .slice(1)
@@ -120,7 +126,7 @@ export function searchNavigationAndCommands({
(candidate) => candidate.id === command.owner, (candidate) => candidate.id === command.owner,
); );
if (!domain || !satisfiesCapability(context, domain.capability)) continue; if (!domain || !satisfiesCapability(context, domain.capability)) continue;
const title = commandTitle(command.id); const title = commandTitle(command.id, translate);
if (!matches(tokens, command.id, command.owner, title)) continue; if (!matches(tokens, command.id, command.owner, title)) continue;
commandsFound.push({ commandsFound.push({
id: `command:${command.id}`, id: `command:${command.id}`,
@@ -276,7 +276,7 @@ export function CommandDeck({
aria-haspopup="dialog" aria-haspopup="dialog"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)]" className="flex min-w-0 items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
<span>{labels.trigger}</span> <span>{labels.trigger}</span>
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd> <kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
@@ -286,14 +286,14 @@ export function CommandDeck({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={labels.trigger} aria-label={labels.trigger}
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-4 pt-[10vh]" className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-2 sm:p-4 pt-4 sm:pt-[10vh]"
> >
<div className="w-full max-w-2xl rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl"> <div className="max-h-[calc(100dvh-2rem)] min-w-0 w-full max-w-2xl overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
<div className="flex justify-end border-b border-[var(--admin-border)] p-2"> <div className="flex justify-end border-b border-[var(--admin-border)] p-2">
<button <button
type="button" type="button"
onClick={close} onClick={close}
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)]" className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
{labels.close} {labels.close}
</button> </button>
@@ -316,10 +316,17 @@ export function CommandDeck({
placeholder={labels.placeholder} placeholder={labels.placeholder}
aria-label={labels.placeholder} aria-label={labels.placeholder}
/> />
<CommandList aria-busy={loading}> <CommandList
aria-busy={loading}
className="min-w-0 overflow-y-auto"
>
{loading ? <p role="status">{labels.loading}</p> : null} {loading ? <p role="status">{labels.loading}</p> : null}
{response.errors.length > 0 ? ( {response.errors.length > 0 ? (
<p role="status" className="px-3 py-2 text-sm"> <p
role="alert"
aria-live="assertive"
className="px-3 py-2 text-sm"
>
{labels.partial} {labels.partial}
</p> </p>
) : null} ) : null}
@@ -7,13 +7,17 @@ interface ContextNavProps {
export function ContextNav({ items, ariaLabel }: ContextNavProps) { export function ContextNav({ items, ariaLabel }: ContextNavProps) {
return ( return (
<nav aria-label={ariaLabel} className="min-w-0"> <nav
aria-label={ariaLabel}
data-housekeeping-landmark="context-navigation"
className="min-w-0"
>
<ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible"> <ul className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible">
{items.map((item) => ( {items.map((item) => (
<li key={item.id} className="min-w-max"> <li key={item.id} className="min-w-max">
<Link <Link
href={item.href} 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)]" className="block min-h-11 rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
{item.label} {item.label}
</Link> </Link>
@@ -27,14 +27,18 @@ export function DomainRail({
ariaLabel, ariaLabel,
}: DomainRailProps) { }: DomainRailProps) {
return ( return (
<nav aria-label={ariaLabel} className="w-full lg:w-20"> <nav
aria-label={ariaLabel}
data-housekeeping-landmark="primary-navigation"
className="min-w-0 w-full lg:w-20"
>
<ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible"> <ul className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
{domains.map((domain) => ( {domains.map((domain) => (
<li key={domain.id} className="min-w-max lg:min-w-0"> <li key={domain.id} className="min-w-max lg:min-w-0">
<Link <Link
href={domain.href} href={domain.href}
aria-current={domain.id === activeDomainId ? "page" : undefined} 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" className="flex min-h-11 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)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)] lg:justify-center"
> >
<span data-domain-icon={domain.iconId}> <span data-domain-icon={domain.iconId}>
<DomainIcon iconId={domain.iconId} /> <DomainIcon iconId={domain.iconId} />
File diff suppressed because it is too large. Load diff
@@ -17,6 +17,7 @@ const labels = {
command: "Search housekeeping", command: "Search housekeeping",
preview: "Preview", preview: "Preview",
backToSite: "Back to site", backToSite: "Back to site",
operatorRank: "Rank 6",
commandDeck: { commandDeck: {
placeholder: "Search navigation, commands, and entities", placeholder: "Search navigation, commands, and entities",
navigation: "Navigation", navigation: "Navigation",
@@ -8,6 +8,13 @@ import { ContextNav } from "./context-nav";
import { DomainRail } from "./domain-rail"; import { DomainRail } from "./domain-rail";
import { OperatorSummary } from "./operator-summary"; import { OperatorSummary } from "./operator-summary";
export const HOUSEKEEPING_LANDMARKS = [
"banner",
"primary-navigation",
"context-navigation",
"main",
] as const;
interface HousekeepingShellProps { interface HousekeepingShellProps {
actor: HousekeepingActor; actor: HousekeepingActor;
activeDomainId: HousekeepingDomainId; activeDomainId: HousekeepingDomainId;
@@ -19,6 +26,7 @@ interface HousekeepingShellProps {
command: string; command: string;
preview: string; preview: string;
backToSite: string; backToSite: string;
operatorRank: string;
commandDeck: { commandDeck: {
placeholder: string; placeholder: string;
navigation: string; navigation: string;
@@ -45,21 +53,24 @@ export function HousekeepingShell({
const activeDomain = domains.find((domain) => domain.id === activeDomainId); const activeDomain = domains.find((domain) => domain.id === activeDomainId);
return ( return (
<div className="min-h-screen bg-[var(--admin-canvas)] text-[var(--admin-text)]"> <div
data-housekeeping-root="true"
className="min-h-screen min-w-0 bg-[var(--admin-canvas)] text-[var(--admin-text)]"
>
<a <a
href="#housekeeping-content" 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)]" className="sr-only rounded-md bg-[var(--admin-surface)] px-3 py-2 text-[var(--admin-text)] focus-visible:not-sr-only focus-visible:absolute focus-visible:left-4 focus-visible:top-4 focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
{labels.skipToContent} {labels.skipToContent}
</a> </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"> <header className="flex min-w-0 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"> <div className="flex min-w-0 items-center gap-3">
{surface === "preview" ? ( {surface === "preview" ? (
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]"> <span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview} {labels.preview}
</span> </span>
) : null} ) : null}
<OperatorSummary actor={actor} /> <OperatorSummary actor={actor} rankLabel={labels.operatorRank} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CommandTrigger <CommandTrigger
@@ -69,27 +80,40 @@ export function HousekeepingShell({
/> />
<Link <Link
href="/" 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)]" className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
{labels.backToSite} {labels.backToSite}
</Link> </Link>
</div> </div>
</header> </header>
<div className="flex min-h-[calc(100vh-4rem)] flex-col lg:flex-row"> <div
<aside className="border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r"> data-responsive-mode="stacked-below-lg"
className="flex min-h-[calc(100vh-4rem)] min-w-0 flex-col lg:flex-row"
>
<aside
data-navigation-collapse="domain"
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:border-b-0 lg:border-r"
>
<DomainRail <DomainRail
domains={domains} domains={domains}
activeDomainId={activeDomainId} activeDomainId={activeDomainId}
ariaLabel={labels.primaryNavigation} ariaLabel={labels.primaryNavigation}
/> />
</aside> </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"> <div
data-navigation-collapse="context"
className="min-w-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 lg:w-56 lg:shrink-0 lg:border-b-0 lg:border-r"
>
<ContextNav <ContextNav
items={activeDomain?.items ?? []} items={activeDomain?.items ?? []}
ariaLabel={labels.contextualNavigation} ariaLabel={labels.contextualNavigation}
/> />
</div> </div>
<main id="housekeeping-content" className="min-w-0 flex-1 p-4 lg:p-6"> <main
id="housekeeping-content"
tabIndex={-1}
className="housekeeping-content min-w-0 max-w-full flex-1 p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--admin-accent)] lg:p-6"
>
{children} {children}
</main> </main>
</div> </div>
@@ -85,7 +85,9 @@ describe("OperationalInbox", () => {
expect(html).toContain('aria-label="Filter by state"'); expect(html).toContain('aria-label="Filter by state"');
expect(html).toContain('aria-label="Filter by priority"'); expect(html).toContain('aria-label="Filter by priority"');
expect(html).toContain("Some sources are unavailable"); expect(html).toContain("Some sources are unavailable");
expect(html).toContain("content.publication"); expect(html).not.toContain("content.publication");
expect(html).not.toContain("TIMEOUT");
expect(html).toContain('role="alert"');
expect(html).toContain('href="/ase-next/people/users"'); expect(html).toContain('href="/ase-next/people/users"');
expect(html).not.toContain("Dismiss"); expect(html).not.toContain("Dismiss");
expect(html).not.toContain("Assign"); expect(html).not.toContain("Assign");
@@ -90,7 +90,10 @@ export function OperationalInbox({
); );
return ( return (
<section aria-labelledby="operational-inbox-title" className="space-y-4"> <section
aria-labelledby="operational-inbox-title"
className="min-w-0 space-y-4"
>
<h2 id="operational-inbox-title" className="text-lg font-semibold"> <h2 id="operational-inbox-title" className="text-lg font-semibold">
{labels.title} {labels.title}
</h2> </h2>
@@ -102,7 +105,7 @@ export function OperationalInbox({
aria-label={labels.domainFilter} aria-label={labels.domainFilter}
value={domain} value={domain}
onChange={(event) => setDomain(event.target.value)} onChange={(event) => setDomain(event.target.value)}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2" className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
<option value="all">{labels.all}</option> <option value="all">{labels.all}</option>
{domains.map((value) => ( {domains.map((value) => (
@@ -115,7 +118,7 @@ export function OperationalInbox({
aria-label={labels.stateFilter} aria-label={labels.stateFilter}
value={state} value={state}
onChange={(event) => setState(event.target.value)} onChange={(event) => setState(event.target.value)}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2" className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
<option value="all">{labels.all}</option> <option value="all">{labels.all}</option>
{states.map((value) => ( {states.map((value) => (
@@ -128,7 +131,7 @@ export function OperationalInbox({
aria-label={labels.priorityFilter} aria-label={labels.priorityFilter}
value={priority} value={priority}
onChange={(event) => setPriority(event.target.value)} onChange={(event) => setPriority(event.target.value)}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2" className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
<option value="all">{labels.all}</option> <option value="all">{labels.all}</option>
{priorities.map((value) => ( {priorities.map((value) => (
@@ -141,17 +144,11 @@ export function OperationalInbox({
{response.errors.length > 0 ? ( {response.errors.length > 0 ? (
<div <div
role="status" role="alert"
aria-live="assertive"
className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3" className="rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] p-3"
> >
<p className="text-[var(--admin-warning)]">{labels.partial}</p> <p className="text-[var(--admin-warning)]">{labels.partial}</p>
<ul className="mt-2 list-disc pl-5 text-sm">
{response.errors.map((error) => (
<li key={error.sourceId}>
{error.sourceId}: {error.code}
</li>
))}
</ul>
</div> </div>
) : null} ) : null}
@@ -160,10 +157,10 @@ export function OperationalInbox({
{items.map((item) => ( {items.map((item) => (
<li <li
key={`${item.sourceId}:${item.itemId}`} key={`${item.sourceId}:${item.itemId}`}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4" className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
> >
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div className="min-w-0 break-words">
<p className="font-medium"> <p className="font-medium">
{titles[item.titleKey] ?? item.titleKey} {titles[item.titleKey] ?? item.titleKey}
</p> </p>
@@ -176,7 +173,7 @@ export function OperationalInbox({
</div> </div>
<Link <Link
href={projectOperationalInboxHref(item.href, surface)} href={projectOperationalInboxHref(item.href, surface)}
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm" className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
> >
{labels.open} {labels.open}
</Link> </Link>
@@ -2,9 +2,10 @@ import type { HousekeepingActor } from "../contracts";
interface OperatorSummaryProps { interface OperatorSummaryProps {
actor: HousekeepingActor; actor: HousekeepingActor;
rankLabel: string;
} }
export function OperatorSummary({ actor }: OperatorSummaryProps) { export function OperatorSummary({ actor, rankLabel }: OperatorSummaryProps) {
return ( return (
<div className="min-w-0 text-sm text-[var(--admin-text)]"> <div className="min-w-0 text-sm text-[var(--admin-text)]">
<div className="truncate font-medium">{actor.username}</div> <div className="truncate font-medium">{actor.username}</div>
@@ -12,7 +13,8 @@ export function OperatorSummary({ actor }: OperatorSummaryProps) {
data-operator-rank={actor.rank} data-operator-rank={actor.rank}
className="text-[var(--admin-text-muted)]" className="text-[var(--admin-text-muted)]"
> >
{actor.rank} <span className="sr-only">{rankLabel}</span>
<span aria-hidden="true">{actor.rank}</span>
</div> </div>
</div> </div>
); );
@@ -157,6 +157,7 @@ describe("housekeeping personalization", () => {
pin: "Pin", pin: "Pin",
unpin: "Unpin", unpin: "Unpin",
error: "Save failed", error: "Save failed",
reorderInstructions: "Press Space, then use arrow keys.",
}} }}
/> />
<RecentWork <RecentWork
@@ -190,6 +191,7 @@ describe("housekeeping personalization", () => {
title: "Widgets", title: "Widgets",
partial: "Some widgets failed", partial: "Some widgets failed",
error: "Save failed", error: "Save failed",
reorderInstructions: "Press Space, then use arrow keys.",
}} }}
/> />
</>, </>,
@@ -198,8 +200,7 @@ describe("housekeeping personalization", () => {
expect(html).toContain("Required"); expect(html).toContain("Required");
expect(html).toContain('disabled=""'); expect(html).toContain('disabled=""');
expect(html).toContain("Some widgets failed"); expect(html).toContain("Some widgets failed");
expect(html).toContain("people.failing"); expect(html).not.toContain("TIMEOUT");
expect(html).toContain("TIMEOUT");
const gridSource = readFileSync( const gridSource = readFileSync(
resolve( resolve(
process.cwd(), process.cwd(),
@@ -20,7 +20,10 @@ export function RecentWork({
readonly titles?: Readonly<Record<string, string>>; readonly titles?: Readonly<Record<string, string>>;
}) { }) {
return ( return (
<section aria-labelledby="housekeeping-recent-title" className="space-y-3"> <section
aria-labelledby="housekeeping-recent-title"
className="min-w-0 space-y-3"
>
<h2 id="housekeeping-recent-title" className="text-lg font-semibold"> <h2 id="housekeeping-recent-title" className="text-lg font-semibold">
{labels.title} {labels.title}
</h2> </h2>
@@ -29,10 +32,13 @@ export function RecentWork({
{items.map((item) => ( {items.map((item) => (
<li <li
key={item.routeId} key={item.routeId}
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3" className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
> >
<Link href={toHousekeepingHref(item.canonicalHref, surface)}> <Link
{titles[item.labelKey] ?? item.labelKey} href={toHousekeepingHref(item.canonicalHref, surface)}
className="break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
>
{titles[item.labelKey] ?? item.routeId}
</Link> </Link>
<div className="text-xs text-[var(--admin-text-muted)]"> <div className="text-xs text-[var(--admin-text-muted)]">
<span>{item.source}</span> <span>{item.source}</span>
File diff suppressed because it is too large. Load diff
@@ -92,7 +92,11 @@ export function WidgetSettings({
{labels.title} {labels.title}
</h2> </h2>
{error ? ( {error ? (
<p role="alert" className="text-[var(--admin-danger)]"> <p
role="alert"
aria-live="assertive"
className="text-[var(--admin-danger)]"
>
{error === labels.error ? error : labels.error} {error === labels.error ? error : labels.error}
</p> </p>
) : null} ) : null}
@@ -105,16 +109,19 @@ export function WidgetSettings({
return ( return (
<li <li
key={widget.id} key={widget.id}
className="flex items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3" className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-[var(--admin-border)] p-3"
> >
<label className="flex items-center gap-2"> <label className="flex min-w-0 items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
disabled={mandatory || savingId !== null} disabled={mandatory || savingId !== null}
onChange={() => void toggle(widget.id)} onChange={() => void toggle(widget.id)}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
/> />
<span>{titles[widget.id] ?? widget.id}</span> <span className="min-w-0 break-words">
{titles[widget.id] ?? widget.id}
</span>
</label> </label>
{mandatory ? ( {mandatory ? (
<span className="text-xs text-[var(--admin-text-muted)]"> <span className="text-xs text-[var(--admin-text-muted)]">
@@ -20,6 +20,7 @@ export interface HousekeepingPageInput {
>; >;
readonly surface?: HousekeepingSurface; readonly surface?: HousekeepingSurface;
readonly savePreferences?: HousekeepingPreferenceSaver; readonly savePreferences?: HousekeepingPreferenceSaver;
readonly translate?: (key: string) => string;
} }
export interface HousekeepingRouteHandler { export interface HousekeepingRouteHandler {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+572 -100
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+561 -92
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff