diff --git a/DESIGN.md b/DESIGN.md index b33c1931..a0136d69 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -308,6 +308,18 @@ default, hover, focus, active, disabled, loading, and error behavior where those ### Navigation +- **Workspace readiness:** the Workspace navigation button carries an 8px dot to + the right of its label. Green means a selected workspace with confirmed ready + preprocessing and no query error; all other states are red. The button's + tooltip and accessible description retain the translated exact state. Do not + add a separate readiness text row or change the backend readiness gate. +- **Session groups:** one accessible multi-open accordion contains Active sessions + (initially open) and Archive (initially closed). Preserve the existing archived + flag as the grouping rule, independent of whether a Pi process is running. + Open sections share the rail's remaining height; each list scrolls independently + with a cap of `min(18rem, 35dvh)`, while its trigger remains outside that scroll + area. The mobile navigation dialog supplies a bounded viewport-height container. + Keyboard users can focus and scroll each labelled panel. - **Session entry:** one Session button returns to the current unfinished session, including provisional creation, without resetting or reconnecting it. Otherwise it prepares a new question using the normal readiness and unsaved-work guards. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index c4ac82e2..f530c6a0 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -39,6 +39,27 @@ for standalone OIDC, not Omics upstream. No runtime configuration or deployment was changed by this documentation pass. Closing/merging the branch and actual PSD acceptance remain separate, unperformed steps. +### Navigation readiness and session accordions — 2026-09-13 + +The Workspace navigation button now carries an accessible green/red readiness +dot instead of a separate text row. Green requires a selected workspace and a +successful, current `ready` response; checking, unavailable and other states are +red, with the state exposed through the tooltip and accessible description. +The backend readiness gate is unchanged. + +Active sessions (non-archived) start expanded; Archive starts collapsed. Both +accordion sections can remain open and share the remaining sidebar height, +with independently scrolling content capped at `min(18rem, 35dvh)`. The mobile +navigation dialog also provides a bounded height. Keyboard controls and labels +are retained. See `DESIGN.md` and `docs/guida-utente.md` for the UI contract. + +Verification: 763 frontend unit tests, 20 browser scenarios (including 80 mocked +sessions at 390/1280px), TypeScript and the production frontend build passed. +Only the Mac frontend was recreated; it is healthy at `127.0.0.1:8080`. +Core, catalog, Qdrant and embedding containers were not changed. The prior +frontend image is retained as +`thothii-frontend:before-navigation-accordions-20260913` for rollback. + ### Agreed Omics delivery route — 2026-09-13 The owner approved GitHub as an intermediate transport for Omics: publish only diff --git a/docs/guida-utente.md b/docs/guida-utente.md index 03f5d5ae..11909aa4 100644 --- a/docs/guida-utente.md +++ b/docs/guida-utente.md @@ -59,6 +59,15 @@ The workflow phases are fixed: ## Resume, archive, and the meaning of saved state +In Administration, the dot beside **Workspace** is green when readiness is +confirmed and red otherwise. Hover the button for the exact state; assistive +technology receives the same description. Select Workspace to inspect preparation. + +The session sidebar has two accordion sections: **Active sessions** starts open, +**Archive** starts closed. Both can be open together; long lists scroll inside +their own panels. Here active means not archived, not necessarily a running model +process. Existing groups and session actions remain inside those sections. + The sidebar lists sessions and their current lifecycle. Resuming returns to the last incomplete phase. A finalized or archived session cannot be resumed. diff --git a/frontend/e2e/ui-visual-review.spec.ts b/frontend/e2e/ui-visual-review.spec.ts index 95dc6730..97fb7045 100644 --- a/frontend/e2e/ui-visual-review.spec.ts +++ b/frontend/e2e/ui-visual-review.spec.ts @@ -103,6 +103,55 @@ async function inspect(page: Page) { }); } +for (const width of [390, 1280]) { + test(`session navigation has readiness dot and bounded accordion lists at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 1000 }); + const writes = await fixtures(page); + const sessions = Array.from({ length: 80 }, (_, index) => ({ + id: `nav-${index}`, name: `Session ${index}`, question: `Question ${index}`, + archived: index >= 40, active: false, status: index >= 40 ? "finalized" : "open", + workspace, group: null, author: null, summary: null, + created_at: "2026-09-01T12:00:00Z", updated_at: null, + })); + await page.route("**/api/sessions?*", route => route.fulfill({ json: sessions })); + await page.route("**/api/sessions", route => route.fulfill({ json: sessions })); + await page.goto("/"); + await navigation(page); + await page.getByRole("button", { name: "Administration", exact: true }).click(); + const workspaceButton = page.getByRole("button", { name: "Workspace", exact: true }); + await expect(workspaceButton.getByRole("img")).toHaveAttribute("data-ready", "true"); + await expect(workspaceButton).toHaveAccessibleDescription("Workspace readiness: ready"); + await expect(page.getByText("Workspace readiness: ready", { exact: true })).toHaveCount(0); + const green = await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor); + const active = page.getByRole("button", { name: "Active sessions", exact: true }); + const archive = page.getByRole("button", { name: "Archive (40)", exact: true }); + await expect(active).toHaveAttribute("aria-expanded", "true"); + await expect(archive).toHaveAttribute("aria-expanded", "false"); + await archive.click(); + for (const name of ["Active sessions", "Archive (40)"]) { + const panel = page.getByRole("region", { name, exact: true }); + await expect(panel).toHaveCSS("overflow-y", "auto"); + const dimensions = await panel.evaluate(el => ({ height: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight })); + expect(dimensions.height).toBeLessThanOrEqual(288); + expect(dimensions.client).toBeGreaterThan(30); + expect(dimensions.scroll).toBeGreaterThan(dimensions.client); + const box = await panel.boundingBox(); + expect(box!.y + box!.height).toBeLessThanOrEqual(1000); + await panel.evaluate(el => { el.scrollTop = el.scrollHeight; }); + expect(await panel.evaluate(el => el.scrollTop)).toBeGreaterThan(0); + } + await page.screenshot({ path: testInfo.outputPath("session-accordions.png"), animations: "disabled" }); + await expect(archive).toBeInViewport(); + expect((await inspect(page)).overflow).toBe(0); + // A refetch failure must not keep a stale green signal. + await page.route("**/api/workspaces/*/preprocessing", route => route.fulfill({ status: 503, json: { error: "Fixture unavailable" } })); + await expect(workspaceButton.getByRole("img")).toHaveAttribute("data-ready", "false", { timeout: 15000 }); + await expect(workspaceButton).toHaveAccessibleDescription("Workspace readiness: unavailable"); + expect(await workspaceButton.getByRole("img").evaluate(el => getComputedStyle(el).backgroundColor)).not.toBe(green); + expect(writes).toEqual([]); + }); +} + for (const width of [390, 1280, 2400]) { test(`knowledge readers use available width and readable paragraphs at ${width}px`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 1100 }); diff --git a/frontend/src/index.css b/frontend/src/index.css index f5adbffb..e4eaf6af 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -319,6 +319,46 @@ body.thot-full-document { margin: 0; } padding: 0 1rem 0.75rem; } + .thot-session-accordion { display: flex; flex-direction: column; } + .thot-session-accordion__item { + display: flex; + flex-direction: column; + flex: 0 0 auto; + border-top: 1px solid oklch(var(--border)); + } + .thot-session-accordion__item[data-open] { + flex: 1 1 0; + min-height: 2.5rem; + max-height: calc(min(18rem, 35dvh) + 2.5rem); + } + .thot-session-accordion__item > h3 { flex: none; } + .thot-session-accordion__trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + width: 100%; + min-height: 2.5rem; + padding: 0.5rem; + font: 600 var(--text-control)/1.5 var(--font-sans); + text-align: left; + } + .thot-session-accordion__trigger:hover { background: oklch(var(--muted)); } + .thot-session-accordion__panel { + flex: 1 1 auto; + max-height: min(18rem, 35dvh); + min-height: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + scrollbar-gutter: stable; + padding: 0.25rem; + } + .thot-session-accordion__trigger:focus-visible, + .thot-session-accordion__panel:focus-visible { + outline: 2px solid oklch(var(--ring)); + outline-offset: -2px; + } + /* UI labels use the same family as controls; monospace is for technical data. */ .thot-label { font-family: var(--font-sans); diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx index 4eedcf06..d82fc4b1 100644 --- a/frontend/src/shell/AppShell.auth.test.tsx +++ b/frontend/src/shell/AppShell.auth.test.tsx @@ -159,7 +159,9 @@ describe("authenticated shell permissions", () => { expect(panel.children[4]).toBe(workspace); expect(panel.children[5]).toBe(pi); expect(within(panel).queryByRole("region", { name: "Workspace preprocessing" })).not.toBeInTheDocument(); - expect(within(panel).getByText(/Workspace readiness/)).toBeInTheDocument(); + expect(within(panel).queryByText(/Workspace readiness/)).not.toBeInTheDocument(); + expect(within(workspace).getByRole("img", { name: /Workspace readiness/ })).toBeInTheDocument(); + expect(workspace).toHaveAccessibleDescription(/Workspace readiness/); expect(screen.getByRole("tab", { name: "All sessions" })).toBeInTheDocument(); await user.keyboard(" "); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 23c7abd3..d6598144 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -423,6 +423,26 @@ test("Archive accordion expands to reveal archived sessions", async () => { expect(screen.getByText("Attiva uno")).toBeInTheDocument(); }); +test("session accordion exposes labelled panels and supports independent keyboard toggles", async () => { + wrap(); + await screen.findByText("Attiva uno"); + const active = screen.getByRole("button", { name: "Active sessions" }); + const archive = screen.getByRole("button", { name: "Archive (1)" }); + expect(active).toHaveAttribute("aria-expanded", "true"); + expect(archive).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByRole("region", { name: "Active sessions" })).toHaveClass("thot-session-accordion__panel"); + archive.focus(); + await userEvent.keyboard("{Enter}"); + expect(archive).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("region", { name: "Archive (1)" })).toHaveAttribute("tabindex", "0"); + expect(active).toHaveAttribute("aria-expanded", "true"); + active.focus(); + await userEvent.keyboard(" "); + expect(active).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("Attiva uno")).not.toBeInTheDocument(); + expect(screen.getByText("Archiviata due")).toBeInTheDocument(); +}); + test("Resume from the panel activates the session and closes the panel", async () => { let resumed: string | null = null; useSessionStore.setState({ diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 96a943de..95a632fa 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -193,6 +193,10 @@ export function AppShell({ canLogout }: AppShellProps) { && preprocessingQuery.data && preprocessingQuery.data.state !== "ready", ); + const workspaceReady = Boolean(selectedWorkspaceId && !preprocessingQuery.isError && preprocessingQuery.data?.state === "ready"); + const workspaceReadinessLabel = t("Workspace readiness: {state}", { + state: t(preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"), + }); const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery({ queryKey: ["sessions", sessionScope], queryFn: async () => { @@ -951,7 +955,7 @@ export function AppShell({ canLogout }: AppShellProps) { {/* Right session rail */} {( -