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 { getTranslations } from "next-intl/server";
import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route";
@@ -40,11 +41,13 @@ export default async function HousekeepingPreviewRoutePage({
) {
notFound();
}
const translate = await getTranslations("pages.housekeeping");
return handler.render({
context,
match,
surface: "preview",
translate: (key) => translate(key as never),
...(searchParams ? { searchParams: await searchParams } : {}),
});
}
+3
View File
@@ -54,6 +54,9 @@ export default async function HousekeepingPreviewDomainLayout({
command: translate("preview.commandDisabled"),
preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"),
operatorRank: translate("navigation.operatorRank", {
rank: context.actor.rank,
}),
commandDeck: {
placeholder: translate("commandDeck.placeholder"),
navigation: translate("commandDeck.navigation"),
+4
View File
@@ -73,6 +73,7 @@ export default async function HousekeepingPreviewRootPage() {
context,
match,
surface: "preview",
translate: (key) => translate(key as never),
});
return (
@@ -87,6 +88,9 @@ export default async function HousekeepingPreviewRootPage() {
command: translate("preview.commandDisabled"),
preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"),
operatorRank: translate("navigation.operatorRank", {
rank: context.actor.rank,
}),
commandDeck: {
placeholder: translate("commandDeck.placeholder"),
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 ── */
@keyframes float {
0%,
@@ -14,6 +14,27 @@ import {
WidgetSettings,
} 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 { saveHousekeepingPreferences } = await import(
"@/actions/housekeeping-preferences"
@@ -27,6 +48,9 @@ export function OperationsPersonalization({
widgets,
widgetResult,
surface,
labels,
routeTitles,
widgetTitles,
savePreferences = persistPreferences,
}: {
readonly preferences: HousekeepingPreferences;
@@ -34,6 +58,9 @@ export function OperationsPersonalization({
readonly widgets: readonly HousekeepingWidgetDefinition[];
readonly widgetResult: HousekeepingWidgetLoadResult;
readonly surface: HousekeepingSurface;
readonly labels: OperationsPersonalizationLabels;
readonly routeTitles: Readonly<Record<string, string>>;
readonly widgetTitles: Readonly<Record<string, string>>;
readonly savePreferences?: HousekeepingPreferenceSaver;
}) {
return (
@@ -44,22 +71,15 @@ export function OperationsPersonalization({
routes={routes}
surface={surface}
savePreferences={savePreferences}
labels={{
title: "Favorites",
pin: "Pin",
unpin: "Unpin",
error: "Preferences could not be saved",
}}
labels={labels.favorites}
titles={routeTitles}
/>
<WidgetSettings
preferences={preferences}
widgets={widgets}
savePreferences={savePreferences}
labels={{
title: "Widget settings",
mandatory: "Mandatory",
error: "Preferences could not be saved",
}}
labels={labels.widgetSettings}
titles={widgetTitles}
/>
</div>
@@ -67,11 +87,8 @@ export function OperationsPersonalization({
preferences={preferences}
result={widgetResult}
savePreferences={savePreferences}
labels={{
title: "Widgets",
partial: "Some widgets are unavailable",
error: "Widget order could not be saved",
}}
labels={labels.widgets}
titles={widgetTitles}
/>
</>
);
@@ -79,6 +79,45 @@ const result: OperationsWorkspaceLoadResult = {
};
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", () => {
it("renders capability-filtered sections, partial failures, and preview links", () => {
@@ -88,6 +127,9 @@ describe("OperationsWorkspacePage", () => {
routes={routes}
widgetDefinitions={widgets}
surface="preview"
labels={labels}
routeTitles={routeTitles}
widgetTitles={widgetTitles}
savePreferences={savePreferences}
/>,
);
@@ -97,8 +139,8 @@ describe("OperationsWorkspacePage", () => {
expect(html).toContain("Favorites");
expect(html).toContain("Widget settings");
expect(html).toContain("Some workspace sections are unavailable");
expect(html).toContain("system.alerts");
expect(html).toContain("system.health");
expect(html).not.toContain("system.alerts");
expect(html).not.toContain("system.health");
expect(html).toContain('href="/ase-next/people/users"');
});
@@ -109,6 +151,9 @@ describe("OperationsWorkspacePage", () => {
routes={[]}
widgetDefinitions={[]}
surface="preview"
labels={labels}
routeTitles={{}}
widgetTitles={{}}
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,
}: HousekeepingPageShellProps) {
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">
<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}
</h1>
<p className="text-sm text-[var(--admin-text-muted)]">
@@ -29,7 +35,7 @@ export function HousekeepingPageShell({
</div>
{primaryAction ? <div>{primaryAction}</div> : null}
</header>
<div>{children}</div>
<div className="min-w-0 max-w-full">{children}</div>
</section>
);
}
@@ -11,6 +11,8 @@ describe("HousekeepingPageState", () => {
["empty", "status"],
["partial", "status"],
["error", "alert"],
["forbidden", "alert"],
["ready", "status"],
] as const)("renders %s with the %s role", (state, role) => {
const pageState =
state === "partial" ? (
@@ -34,6 +36,35 @@ describe("HousekeepingPageState", () => {
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", () => {
const loading = renderToStaticMarkup(
<HousekeepingPageState
@@ -6,10 +6,21 @@ interface HousekeepingPageStateBaseProps {
retryAction?: ReactNode;
}
export type HousekeepingPageState =
| "loading"
| "empty"
| "partial"
| "error"
| "forbidden"
| "ready";
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
(
| { state: "partial"; partialLabel: string }
| { state: "loading" | "empty" | "error"; partialLabel?: never }
| {
state: Exclude<HousekeepingPageState, "partial">;
partialLabel?: never;
}
);
export function HousekeepingPageState({
@@ -19,13 +30,14 @@ export function HousekeepingPageState({
description,
retryAction,
}: HousekeepingPageStateProps) {
const isError = state === "error";
const isError = state === "error" || state === "forbidden";
const isPartial = state === "partial";
return (
<section
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 ${
isError
? "border-[var(--admin-error)] bg-[var(--admin-surface)]"
@@ -15,6 +15,7 @@ const routeMocks = vi.hoisted(() => {
"navigation.skipToContent": "HK::skip-to-content",
"navigation.primary": "HK::primary-navigation",
"navigation.contextual": "HK::contextual-navigation",
"navigation.operatorRank": "HK::operator-rank",
"commandDeck.placeholder": "HK::command-placeholder",
"commandDeck.navigation": "HK::command-navigation",
"commandDeck.commands": "HK::command-commands",
@@ -802,6 +803,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.users",
domain: "people",
@@ -809,7 +811,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
canonicalHref: "/ase/people/users",
},
});
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
});
it("maps nested preview segments to canonical dynamic parameters", async () => {
@@ -828,6 +830,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.user-detail",
domain: "people",
@@ -853,6 +856,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
surface: "preview",
translate: expect.any(Function),
match: {
routeId: "people.literal-tool",
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));
}
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
.split(".")
.slice(1)
@@ -120,7 +126,7 @@ export function searchNavigationAndCommands({
(candidate) => candidate.id === command.owner,
);
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;
commandsFound.push({
id: `command:${command.id}`,
@@ -276,7 +276,7 @@ export function CommandDeck({
aria-haspopup="dialog"
aria-expanded={open}
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>
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
@@ -286,14 +286,14 @@ export function CommandDeck({
role="dialog"
aria-modal="true"
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">
<button
type="button"
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}
</button>
@@ -316,10 +316,17 @@ export function CommandDeck({
placeholder={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}
{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}
</p>
) : null}
@@ -7,13 +7,17 @@ interface ContextNavProps {
export function ContextNav({ items, ariaLabel }: ContextNavProps) {
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">
{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)]"
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}
</Link>
@@ -27,14 +27,18 @@ export function DomainRail({
ariaLabel,
}: DomainRailProps) {
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">
{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"
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}>
<DomainIcon iconId={domain.iconId} />
File diff suppressed because it is too large. Load diff
@@ -17,6 +17,7 @@ const labels = {
command: "Search housekeeping",
preview: "Preview",
backToSite: "Back to site",
operatorRank: "Rank 6",
commandDeck: {
placeholder: "Search navigation, commands, and entities",
navigation: "Navigation",
@@ -8,6 +8,13 @@ import { ContextNav } from "./context-nav";
import { DomainRail } from "./domain-rail";
import { OperatorSummary } from "./operator-summary";
export const HOUSEKEEPING_LANDMARKS = [
"banner",
"primary-navigation",
"context-navigation",
"main",
] as const;
interface HousekeepingShellProps {
actor: HousekeepingActor;
activeDomainId: HousekeepingDomainId;
@@ -19,6 +26,7 @@ interface HousekeepingShellProps {
command: string;
preview: string;
backToSite: string;
operatorRank: string;
commandDeck: {
placeholder: string;
navigation: string;
@@ -45,21 +53,24 @@ export function HousekeepingShell({
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
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
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}
</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">
<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 min-w-0 items-center gap-3">
{surface === "preview" ? (
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
{labels.preview}
</span>
) : null}
<OperatorSummary actor={actor} />
<OperatorSummary actor={actor} rankLabel={labels.operatorRank} />
</div>
<div className="flex items-center gap-2">
<CommandTrigger
@@ -69,27 +80,40 @@ export function HousekeepingShell({
/>
<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)]"
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}
</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">
<div
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
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">
<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
items={activeDomain?.items ?? []}
ariaLabel={labels.contextualNavigation}
/>
</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}
</main>
</div>
@@ -85,7 +85,9 @@ describe("OperationalInbox", () => {
expect(html).toContain('aria-label="Filter by state"');
expect(html).toContain('aria-label="Filter by priority"');
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).not.toContain("Dismiss");
expect(html).not.toContain("Assign");
@@ -90,7 +90,10 @@ export function OperationalInbox({
);
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">
{labels.title}
</h2>
@@ -102,7 +105,7 @@ export function OperationalInbox({
aria-label={labels.domainFilter}
value={domain}
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>
{domains.map((value) => (
@@ -115,7 +118,7 @@ export function OperationalInbox({
aria-label={labels.stateFilter}
value={state}
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>
{states.map((value) => (
@@ -128,7 +131,7 @@ export function OperationalInbox({
aria-label={labels.priorityFilter}
value={priority}
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>
{priorities.map((value) => (
@@ -141,17 +144,11 @@ export function OperationalInbox({
{response.errors.length > 0 ? (
<div
role="status"
role="alert"
aria-live="assertive"
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>
<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>
) : null}
@@ -160,10 +157,10 @@ export function OperationalInbox({
{items.map((item) => (
<li
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>
<div className="min-w-0 break-words">
<p className="font-medium">
{titles[item.titleKey] ?? item.titleKey}
</p>
@@ -176,7 +173,7 @@ export function OperationalInbox({
</div>
<Link
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}
</Link>
@@ -2,9 +2,10 @@ import type { HousekeepingActor } from "../contracts";
interface OperatorSummaryProps {
actor: HousekeepingActor;
rankLabel: string;
}
export function OperatorSummary({ actor }: OperatorSummaryProps) {
export function OperatorSummary({ actor, rankLabel }: OperatorSummaryProps) {
return (
<div className="min-w-0 text-sm text-[var(--admin-text)]">
<div className="truncate font-medium">{actor.username}</div>
@@ -12,7 +13,8 @@ export function OperatorSummary({ actor }: OperatorSummaryProps) {
data-operator-rank={actor.rank}
className="text-[var(--admin-text-muted)]"
>
{actor.rank}
<span className="sr-only">{rankLabel}</span>
<span aria-hidden="true">{actor.rank}</span>
</div>
</div>
);
@@ -157,6 +157,7 @@ describe("housekeeping personalization", () => {
pin: "Pin",
unpin: "Unpin",
error: "Save failed",
reorderInstructions: "Press Space, then use arrow keys.",
}}
/>
<RecentWork
@@ -190,6 +191,7 @@ describe("housekeeping personalization", () => {
title: "Widgets",
partial: "Some widgets 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('disabled=""');
expect(html).toContain("Some widgets failed");
expect(html).toContain("people.failing");
expect(html).toContain("TIMEOUT");
expect(html).not.toContain("TIMEOUT");
const gridSource = readFileSync(
resolve(
process.cwd(),
@@ -20,7 +20,10 @@ export function RecentWork({
readonly titles?: Readonly<Record<string, string>>;
}) {
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">
{labels.title}
</h2>
@@ -29,10 +32,13 @@ export function RecentWork({
{items.map((item) => (
<li
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)}>
{titles[item.labelKey] ?? item.labelKey}
<Link
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>
<div className="text-xs text-[var(--admin-text-muted)]">
<span>{item.source}</span>
File diff suppressed because it is too large. Load diff
@@ -92,7 +92,11 @@ export function WidgetSettings({
{labels.title}
</h2>
{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}
</p>
) : null}
@@ -105,16 +109,19 @@ export function WidgetSettings({
return (
<li
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
type="checkbox"
checked={checked}
disabled={mandatory || savingId !== null}
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>
{mandatory ? (
<span className="text-xs text-[var(--admin-text-muted)]">
@@ -20,6 +20,7 @@ export interface HousekeepingPageInput {
>;
readonly surface?: HousekeepingSurface;
readonly savePreferences?: HousekeepingPreferenceSaver;
readonly translate?: (key: string) => string;
}
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