fix(frontend): isolate administration rail from portal CSS

This commit is contained in:
Codex
2026-09-07 11:05:38 +02:00
parent 28db30bd78
commit 651a5c7902
5 changed files with 148 additions and 22 deletions
@@ -0,0 +1,107 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
test.describe.configure({ mode: "serial" });
test.use({ viewport: { width: 1660, height: 822 } });
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
function json(route: Route, body: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function signInAsAdmin(page: Page) {
const account = stack.localAccount("admin");
await page.getByLabel("Username").fill(account.username);
await page.locator('input[name="password"]').fill(account.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
}
test.beforeAll(async () => {
stack = await createAuthenticationStack({ withF1Workspace: true });
await stack.useLocalMode();
});
test.afterAll(async () => {
await stack?.close();
});
test("Administration stays within its rail when hosted by the Omics portal", async ({ page }) => {
await page.route("**/api/settings", (route) => json(route, {
workspace: "fixture-workspace",
provider: "zai",
model: "glm-5.2",
thinking: "medium",
}));
await page.route("**/api/workspaces", (route) => json(route, [{
id: "fixture-workspace",
name: "fixture-workspace",
file: "fixture-workspace/workspace.yaml",
displayName: "Fixture workspace",
configurationState: "ready",
revision: {
id: "fixture-workspace",
commit: "a".repeat(40),
blob: "b".repeat(40),
snapshotPath: `/snapshots/${"a".repeat(40)}/fixture-workspace.yaml`,
},
}]));
await page.route("**/api/workspaces/fixture-workspace/preprocessing", (route) => json(route, {
schemaVersion: 1,
workspaceId: "fixture-workspace",
state: "running",
actionable: false,
clearable: false,
detail: "Building schema and LSH.",
progress: {
stage: "schema_index",
step: 2,
totalSteps: 4,
},
}));
await page.goto(stack.publicUrl);
await signInAsAdmin(page);
await expect(page.getByRole("combobox", { name: "Workspace" })).toHaveValue("fixture-workspace");
// Hyper/Bootstrap and Thoth currently share the portal document. These
// utility names are therefore resolved with the portal's !important values.
await page.addStyleTag({ content: `
.px-2 { padding-right: 0.75rem !important; padding-left: 0.75rem !important; }
.px-3 { padding-right: 1.5rem !important; padding-left: 1.5rem !important; }
.px-4 { padding-right: 2.25rem !important; padding-left: 2.25rem !important; }
` });
const rail = page.getByRole("complementary", { name: "Session navigation" });
await rail.getByRole("button", { name: "Administration", exact: true }).click();
const administration = rail.getByRole("region", { name: "Administration" });
const boundary = rail.getByTestId("administration-boundary");
await expect(administration).toBeVisible();
await expect(rail.getByText("2 / 4", { exact: true })).toBeVisible();
const containment = await administration.evaluate((panel) => {
const elements = Array.from(panel.querySelectorAll<HTMLElement>('button, [role="progressbar"], span'))
.filter((element) => element.matches('button, [role="progressbar"]') || element.textContent?.trim() === "2 / 4");
return elements.map((element) => {
const box = element.getBoundingClientRect();
return {
label: element.textContent?.trim() || element.getAttribute("aria-label") || element.tagName,
left: box.left,
right: box.right,
};
});
});
const boundaryBox = await boundary.boundingBox();
expect(boundaryBox).not.toBeNull();
if (!boundaryBox) return;
for (const item of containment) {
expect.soft(item.left, `${item.label} crosses the Administration left edge`).toBeGreaterThanOrEqual(boundaryBox.x - 1);
expect.soft(item.right, `${item.label} crosses the Administration right edge`).toBeLessThanOrEqual(boundaryBox.x + boundaryBox.width + 1);
}
});
@@ -426,12 +426,15 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
const administrationPanel = adminNavigationRail.getByRole("region", { name: "Administration" }); const administrationPanel = adminNavigationRail.getByRole("region", { name: "Administration" });
await expect(administrationPanel).toBeVisible(); await expect(administrationPanel).toBeVisible();
expect(await administrationPanel.locator(":scope > *").evaluateAll((elements) => elements.map((element) => ( expect(await administrationPanel.locator(":scope > *").evaluateAll((elements) => elements.map((element) => (
element.getAttribute("role") === "separator" ? "separator" : element.textContent?.trim() element.getAttribute("role") === "separator"
? "separator"
: element.getAttribute("aria-label") ?? element.textContent?.trim()
)))).toEqual([ )))).toEqual([
"Database management", "Database management",
"separator", "separator",
"Workspace management", "Workspace management",
"Pi management", "Pi management",
"Workspace preprocessing",
]); ]);
await administration.click(); await administration.click();
+15
View File
@@ -181,6 +181,21 @@
* Scoped so it never leaks into widgets. * Scoped so it never leaks into widgets.
*/ */
@layer components { @layer components {
/* Omics Portal and Thoth share one document in the embedded deployment.
Keep rail geometry on product-specific class names so Hyper/Bootstrap's
global spacing utilities (for example .px-4 !important) cannot resize it. */
.thot-session-navigation__header {
padding: 1.25rem 1rem 0.75rem;
}
.thot-session-navigation__primary-controls {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
padding: 0 1rem 0.75rem;
}
/* Standard micro-label: panel headers, nav sections, meta rows. Set in the /* Standard micro-label: panel headers, nav sections, meta rows. Set in the
mono register so labels/meta read as a distinct typographic layer from the mono register so labels/meta read as a distinct typographic layer from the
sans body prose (mono needs less tracking than the sans did). */ sans body prose (mono needs less tracking than the sans did). */
+12 -11
View File
@@ -855,7 +855,7 @@ export function AppShell({ canLogout }: AppShellProps) {
{/* Right session rail */} {/* Right session rail */}
{(activeSurface === "database-management" || !showActivity) && ( {(activeSurface === "database-management" || !showActivity) && (
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar"> <aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
<div className="relative px-4 pb-3 pt-5 text-center"> <div className="thot-session-navigation__header relative text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground"> <h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span> Thoth<span className="text-primary">II</span>
</h1> </h1>
@@ -878,11 +878,11 @@ export function AppShell({ canLogout }: AppShellProps) {
)} )}
</div> </div>
<div className="flex flex-col gap-2 px-4 pb-3"> <div className="thot-session-navigation__primary-controls">
<Button <Button
variant={currentNavigation === "core" ? "navigationActive" : "outline"} variant={currentNavigation === "core" ? "navigationActive" : "outline"}
size="sm" size="sm"
className="w-full" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "core" ? "page" : undefined} aria-current={currentNavigation === "core" ? "page" : undefined}
data-navigation-state={preprocessingBlocksNewSession data-navigation-state={preprocessingBlocksNewSession
? "unavailable" ? "unavailable"
@@ -897,15 +897,16 @@ export function AppShell({ canLogout }: AppShellProps) {
<Accordion.Root <Accordion.Root
value={adminNavigationValue} value={adminNavigationValue}
onValueChange={setAdminNavigationValue} onValueChange={setAdminNavigationValue}
className="rounded-lg border border-border/80 bg-card/45 p-1.5" data-testid="administration-boundary"
className="min-w-0 max-w-full rounded-lg border border-border/80 bg-card/45 p-1.5"
> >
<Accordion.Item value="administration"> <Accordion.Item value="administration" className="min-w-0 max-w-full">
<Accordion.Header className="m-0"> <Accordion.Header className="m-0 min-w-0 max-w-full">
<Accordion.Trigger <Accordion.Trigger
className={buttonVariants({ className={buttonVariants({
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline", variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
size: "sm", size: "sm",
className: "w-full justify-between px-2.5", className: "w-full min-w-0 max-w-full justify-between px-2.5",
})} })}
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"} data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
> >
@@ -917,11 +918,11 @@ export function AppShell({ canLogout }: AppShellProps) {
/> />
</Accordion.Trigger> </Accordion.Trigger>
</Accordion.Header> </Accordion.Header>
<Accordion.Panel className="grid gap-1.5 px-0.5 pt-1.5"> <Accordion.Panel className="grid min-w-0 max-w-full gap-1.5 px-0.5 pt-1.5">
<Button <Button
variant={currentNavigation === "database" ? "navigationActive" : "outline"} variant={currentNavigation === "database" ? "navigationActive" : "outline"}
size="sm" size="sm"
className="w-full" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "database" ? "page" : undefined} aria-current={currentNavigation === "database" ? "page" : undefined}
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"} data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
disabled={!canManageDatabase} disabled={!canManageDatabase}
@@ -937,7 +938,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<Button <Button
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"} variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
size="sm" size="sm"
className="w-full" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "workspace" ? "page" : undefined} aria-current={currentNavigation === "workspace" ? "page" : undefined}
aria-expanded={activeManagementPanel === "workspace"} aria-expanded={activeManagementPanel === "workspace"}
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"} data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
@@ -952,7 +953,7 @@ export function AppShell({ canLogout }: AppShellProps) {
<Button <Button
variant={currentNavigation === "pi" ? "navigationActive" : "outline"} variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
size="sm" size="sm"
className="w-full" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
aria-current={currentNavigation === "pi" ? "page" : undefined} aria-current={currentNavigation === "pi" ? "page" : undefined}
aria-expanded={activeManagementPanel === "pi"} aria-expanded={activeManagementPanel === "pi"}
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"} data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
@@ -57,8 +57,8 @@ function StatusIcon({ state }: { state?: WorkspacePreprocessingState }) {
function PhaseProgress({ progress }: { progress: PreprocessingProgress }) { function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
const step = Math.max(1, Math.min(progress.step, progress.totalSteps)); const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
return ( return (
<div className="col-span-2 mt-1 grid gap-1"> <div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
<div className="flex items-center justify-between gap-2 text-[9px] leading-none"> <div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-[9px] leading-none">
<span className="min-w-0 truncate font-medium text-foreground"> <span className="min-w-0 truncate font-medium text-foreground">
{PROGRESS_LABELS[progress.stage]} {PROGRESS_LABELS[progress.stage]}
</span> </span>
@@ -240,7 +240,7 @@ export function WorkspacePreprocessingControl({
<section <section
aria-label="Workspace preprocessing" aria-label="Workspace preprocessing"
aria-busy={state === "running" || loading || clearMutation.isPending} aria-busy={state === "running" || loading || clearMutation.isPending}
className="mt-0.5 border-t border-border/90 px-0.5 pt-2" className="mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
> >
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary"> <p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
Preprocessing Preprocessing
@@ -272,11 +272,11 @@ export function WorkspacePreprocessingControl({
<span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span> <span><strong className="block text-foreground">Keep</strong><small className="text-muted-foreground">Memory and solved questions</small></span>
</div> </div>
</div> </div>
<div className="grid grid-cols-[0.78fr_1.22fr] gap-1.5"> <div className="grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button type="button" variant="outline" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}> <Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
Cancel Cancel
</Button> </Button>
<Button type="button" variant="destructive" size="xs" className="h-7 text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}> <Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />} {clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
{clearMutation.isPending ? "Clearing" : "Clear derived data"} {clearMutation.isPending ? "Clearing" : "Clear derived data"}
</Button> </Button>
@@ -284,7 +284,7 @@ export function WorkspacePreprocessingControl({
</div> </div>
) : ( ) : (
<> <>
<div className="grid grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5"> <div className="grid min-w-0 max-w-full grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-1.5">
<span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}> <span className={`grid size-5 place-items-center rounded-full bg-muted ${stateClasses(state)}`}>
<StatusIcon state={state} /> <StatusIcon state={state} />
</span> </span>
@@ -293,12 +293,12 @@ export function WorkspacePreprocessingControl({
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>} {state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={detail}>{detail}</span>}
</div> </div>
{progress && <PhaseProgress progress={progress} />} {progress && <PhaseProgress progress={progress} />}
<div className="col-span-2 mt-0.5 grid grid-cols-[0.78fr_1.22fr] gap-1.5"> <div className="col-span-2 mt-0.5 grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="xs" size="xs"
className="h-7 px-2 text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive" className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
disabled={!clearable} disabled={!clearable}
title={!canManage ? "Database management permission is required" : undefined} title={!canManage ? "Database management permission is required" : undefined}
onClick={() => setConfirmingClear(true)} onClick={() => setConfirmingClear(true)}
@@ -309,7 +309,7 @@ export function WorkspacePreprocessingControl({
type="button" type="button"
variant="outline" variant="outline"
size="xs" size="xs"
className="h-7 px-2 text-[10px]" className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px]"
disabled={!actionable} disabled={!actionable}
title={!canManage ? "Database management permission is required" : undefined} title={!canManage ? "Database management permission is required" : undefined}
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}