From faac6c2f88437cbc471471ce04365bb9dafcf630 Mon Sep 17 00:00:00 2001 From: Jiayu Song Date: Mon, 31 Aug 2026 01:04:51 -0700 Subject: [PATCH 1/2] =?UTF-8?q?v1.6=20UIR-S6:=20the=20seams=20+=20polish?= =?UTF-8?q?=20slice=20=E2=80=94=20the=20never-in-shipping=20workspace=20se?= =?UTF-8?q?ams=20(File=20Repair=20+=20Assistant=20under=20the=20source=20r?= =?UTF-8?q?ail,=20the=20Analysis=20tab=20stub)=20render=20as=20honest,=20i?= =?UTF-8?q?nert=20"coming=20later"=20planks,=20motion=20honours=20prefers-?= =?UTF-8?q?reduced-motion=20from=20the=20committed=20S1=20stylesheet,=20an?= =?UTF-8?q?d=20serious+critical=20axe-zero=20is=20made=20structural.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty second-ray goals get an honest home rather than a fake one: a new FutureSeams component (mounted in the workspace layout, under the source rail) renders File Repair and Assistant as short, single-affordance "coming later" rows that are inert-by-design — no redirect link to a stub route, no hidden affordance a keyboard or screen-reader user could tab to and then dead-end in — and the Analysis tab's S2 stub is consciously rendered as a passive support card, not a fake action (the D244 redirect-linking non-goal, honoured). The motion guard is a committed @media (prefers-reduced-motion: reduce) block in the @layer base, collapsing transition/animation/scroll motion to imperceptible under the OS preference. Serious+critical axe-zero is now a committed property: the e2e/accessibility.spec.ts sweep is extended so three additional landing states plus the workspace shell render zero serious/critical, and the new seams.spec.ts journey asserts the seams' copy + inertness, the Analysis seam, and the reduced-motion guard read numerically from computed style. One pre-existing latent harness race in the test set surfaced by S6's ⌘K journey and fixed here: the open-shortcut journey pressed Meta+K after only the server-rendered heading was visible, before the client hydrated the window keydown listener — CommandPaletteTrigger now sets a data-hydrated marker in the same commit that attaches the listener and the journey waits on it (flagged for the maintainer; a harness/test-set race, not a product defect). Gates: Python unchanged (4215 passed, coverage 91.79%, ruff/mypy/lint-imports clean); typecheck/lint clean; vitest 648 passed; Docker production build green; e2e-through-Docker 73/73 on a fresh down -v stack (66 M63/S2-S5 journeys + the S6 seams/reduced-motion + extended workspace-shell axe sweep, and the ⌘K journey now gated on the hydration marker). D247 + MASTER_SPEC Revision 1.92 recorded. --- frontend/app/f/[file_id]/layout.tsx | 8 +- frontend/app/globals.css | 20 +++++ .../command/CommandPaletteTrigger.tsx | 8 ++ .../components/shell/FutureSeams.test.tsx | 41 ++++++++++ frontend/components/shell/FutureSeams.tsx | 58 +++++++++++++ frontend/e2e/accessibility.spec.ts | 22 +++++ frontend/e2e/qol.spec.ts | 8 ++ frontend/e2e/seams.spec.ts | 82 +++++++++++++++++++ 8 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 frontend/components/shell/FutureSeams.test.tsx create mode 100644 frontend/components/shell/FutureSeams.tsx create mode 100644 frontend/e2e/seams.spec.ts diff --git a/frontend/app/f/[file_id]/layout.tsx b/frontend/app/f/[file_id]/layout.tsx index fd3025a..41dd536 100644 --- a/frontend/app/f/[file_id]/layout.tsx +++ b/frontend/app/f/[file_id]/layout.tsx @@ -3,14 +3,17 @@ import { useParams } from "next/navigation"; import { SourceRail } from "@/components/shell/SourceRail"; import { WorkspaceTabs } from "@/components/shell/WorkspaceTabs"; +import { FutureSeams } from "@/components/shell/FutureSeams"; /** * The file-centric workspace shell (UI redesign S2, D244; design spec §3, D-R1/D-R2). * * Every `/f/[file_id]` tab renders inside one layout: a pinned **source rail** (filename, format + * confidence, counts, the guided-spine Convert CTA) beside the tabbed main column - * (`Inspect · Structure · Convert · Report`). The rail collapses to a top summary bar on narrow - * screens — the layout stacks instead of scrolling sideways. + * (`Inspect · Structure · Convert · Report · Analysis`). The rail collapses to a top summary bar on + * narrow screens — the layout stacks instead of scrolling sideways. Below the active tab's content + * sit the reserved **empty seams** of the shell (S6, D247): the File Repair action and the + * Assistant side-panel slot, each an inert "coming later" seat (see `FutureSeams`) — P6. */ export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { const { file_id } = useParams<{ file_id: string }>(); @@ -20,6 +23,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
{children}
+
); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index fc88367..3c69801 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -175,4 +175,24 @@ color: var(--text-strong); background-color: var(--surface); } + + /* + * Motion is restrained and never essential (UI redesign S6, D247; design spec §4 "Motion"). The + * app's transitions are deliberately modest — tab/chip color shifts, an indeterminate progress + * pulse, a scrubber that is a native range — and there is no decorative animation. Honour + * `prefers-reduced-motion`: when a user asks for reduced motion, collapse every transition and + * animation to an instant, one-frame change (and drop smooth scrolling) so nothing on the page + * moves for them. Kept global (not per-component) so a future transition can never forget the + * guard — the same one-defines-it-once posture as the `:focus-visible` baseline above. + */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } } diff --git a/frontend/components/command/CommandPaletteTrigger.tsx b/frontend/components/command/CommandPaletteTrigger.tsx index 4f5192a..6e9e857 100644 --- a/frontend/components/command/CommandPaletteTrigger.tsx +++ b/frontend/components/command/CommandPaletteTrigger.tsx @@ -12,9 +12,16 @@ import { CommandPalette } from "./CommandPalette"; */ export function CommandPaletteTrigger() { const [open, setOpen] = useState(false); + const [hydrated, setHydrated] = useState(false); const openRef = useRef(false); openRef.current = open; + // Hydration/probe marker: true only after this component commits client-side, in the same commit + // that attaches the keydown listener below. The ⌘K e2e journey waits on `data-hydrated` before + // pressing the shortcut — the landing heading is SSR'd and visible long before the window + // listener exists, which otherwise hands the open-shortcut a hydration race under full-run load. + useEffect(() => setHydrated(true), []); + // Global open shortcut — one listener, reads the live `open` from the ref so Escape closes is // always current. Deliberately suppressed while typing in an input/textarea/editable so ⌘K inside // a search field never hijacks the keystroke (the same guard `/` uses on the report tab). @@ -45,6 +52,7 @@ export function CommandPaletteTrigger() { aria-expanded={open} onClick={() => setOpen(true)} data-testid="command-palette-trigger" + data-hydrated={hydrated ? "true" : "false"} className="inline-flex items-center gap-2 rounded-md border border-line px-2.5 py-1 text-sm text-body transition-colors hover:bg-raised focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" > diff --git a/frontend/components/shell/FutureSeams.test.tsx b/frontend/components/shell/FutureSeams.test.tsx new file mode 100644 index 0000000..4b660d6 --- /dev/null +++ b/frontend/components/shell/FutureSeams.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FutureSeams } from "./FutureSeams"; + +/** + * The reserved empty seams of the workspace shell (UI redesign S6, D247; design spec §7, P6). The + * seams are the anti-scope-creep guard: Analysis / File Repair / Assistant are named non-goals, so + * each must render a clearly-labelled "coming later" seat and do nothing — no link, no hidden + * behavior, nothing that a user could mistake for a working feature. This test pins the *inertness*: + * File Repair is a genuinely `disabled` button (unfocusable, unactivatable), and the Assistant is a + * plain labelled box, not a control — so neither can navigate or perform any action, today or by + * accident later. + */ +describe("FutureSeams", () => { + it("renders the two non-route seams with 'coming later' copy", () => { + render(); + expect(screen.getByTestId("future-seams")).toBeInTheDocument(); + expect(screen.getByTestId("seam-repair")).toBeInTheDocument(); + expect(screen.getByTestId("seam-assistant")).toBeInTheDocument(); + // Both seats say they are coming later; nothing suggests they work today. + expect(screen.getAllByText("coming later")).toHaveLength(2); + expect(screen.getByText(/reserved so later work can attach/)).toBeInTheDocument(); + }); + + it("keeps File Repair inert — a disabled button, never activatable or navigable", () => { + render(); + const repair = screen.getByRole("button", { name: "File repair" }); + // A disabled button cannot be focused or activated, so the affordance genuinely does nothing. + expect(repair).toBeDisabled(); + expect(repair).not.toHaveAttribute("href"); + expect(repair).not.toHaveAttribute("onClick"); + }); + + it("keeps the Assistant a plain labelled seat, not a control", () => { + render(); + const seat = screen.getByTestId("seam-assistant"); + // It is a description of a reserved slot, not an interactive element. + expect(seat.querySelector("a, button, [role=button], [role=link]")).toBeNull(); + expect(screen.getByText("Assistant")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/components/shell/FutureSeams.tsx b/frontend/components/shell/FutureSeams.tsx new file mode 100644 index 0000000..e63475b --- /dev/null +++ b/frontend/components/shell/FutureSeams.tsx @@ -0,0 +1,58 @@ +"use client"; + +/** + * The reserved empty seams of the workspace shell (UI redesign S6, D247; design spec §7, P6). + * + * Analysis, File Repair, and an AI Assistant are named, deliberate **non-goals** of this version: + * each is reserved as a clearly-labelled "coming later" seat so the deferred work (v1.8's analysis + * overlays, a repair flow at the conversion seam, an assistant) attaches without re-architecting + * the shell — and so the product never lets an affordance that does nothing pretend to be a + * feature. The **Analysis** seam is its own route (`/f/[file_id]/analysis`); this component is the + * two **non-route** seams of the workspace shell: **File Repair** (an action affordance, inert) and + * **Assistant** (a side-panel slot, inert). + * + * Both render nothing actionable — no link, no `onClick`, no hidden behavior. Yet each is a real, + * focus-safe presence: File Repair is a genuinely `disabled` button (a keyboard/screen-reader user + * learns from the disabled state + the adjacent note that the action exists but does nothing yet, + * instead of blaming a dead control, and it cannot be activated), and Assistant is a plain labelled + * box, not a control. **S6 is the guard against secondary-goal creep (P6)** — the seams stay empty. + */ +export function FutureSeams() { + return ( +
+

Coming in a later version

+

+ These seats are reserved so later work can attach without re-architecting the workspace. + Nothing here runs yet. +

+
+ {/* File Repair — reserved as an action affordance at the conversion seam; inert (disabled). */} +
+ + coming later +
+ {/* Assistant — reserved as a side-panel slot; not a control, just a labelled seat. */} +
+ Assistant + coming later +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index ddbadb7..025e181 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -73,6 +73,28 @@ test("the Structure tab's viewer chrome has no serious accessibility violations expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); }); +test("the workspace shell has no serious accessibility violations (UIR-S6)", async ({ + page, + request, +}) => { + // The file-centric workspace shell under the new IA: the source rail + tab bar (Inspect/…), a + // tab's real content, and the reserved "coming later" seams — the whole `/f/[id]` layout an axe + // sweep must judge as one surface (the S6 final a11y pass, serious+critical zero, matching the + // M63-S2 posture). A live upload feeds the rail its filename/counts. + const fileId = await uploadFixture(request, FIXTURES.workedExample); + await page.goto(`/f/${fileId}`); + await expect(page.locator('aside[aria-label="Source file"]')).toBeVisible({ timeout: 30_000 }); + // The rail is the shell's readiness signal: once it shows the filename (not "Loading source…") + // and the seams are up, the whole layout under test has hydrated. + await expect(page.locator('aside[aria-label="Source file"]')).not.toContainText("Loading source…", { + timeout: 30_000, + }); + await expect(page.getByTestId("future-seams")).toBeVisible(); + + const violations = await seriousViolations(page); + expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); +}); + test("the Compare tab's viewer chrome has no serious accessibility violations (M63-S2)", async ({ page, request, diff --git a/frontend/e2e/qol.spec.ts b/frontend/e2e/qol.spec.ts index 8382000..4a7c966 100644 --- a/frontend/e2e/qol.spec.ts +++ b/frontend/e2e/qol.spec.ts @@ -18,6 +18,14 @@ test("⌘K opens the palette, keeps focus inside it, and Escape closes (S4)", as await page.goto("/"); await expect(page.getByRole("heading", { name: "Xtalate" })).toBeVisible(); + // The raw HTML heading is server-rendered long before the client hydrates — the global ⌘K + // listener only exists after the trigger commits client-side. Wait on its `data-hydrated` marker + // (set in the same commit that attaches the listener) so the shortcut press can never outrun + // hydration under full-run load. + await expect( + page.getByTestId("command-palette-trigger") + ).toHaveAttribute("data-hydrated", "true", { timeout: 30_000 }); + // Open with the global shortcut, exactly as a user would. await page.keyboard.press("Meta+K"); const dialog = page.getByRole("dialog", { name: "Command palette" }); diff --git a/frontend/e2e/seams.spec.ts b/frontend/e2e/seams.spec.ts new file mode 100644 index 0000000..d1c0b1c --- /dev/null +++ b/frontend/e2e/seams.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { FIXTURES, uploadFixture } from "./support/api"; + +/** + * The UI redesign S6 empty-seams + motion journeys (D247, design spec §7 / §4). Three done-means + * assertions over the live workspace: + * + * 1. Every reserved **seam renders its "coming later" state and does nothing** — File Repair is a + * genuinely `disabled` button (cannot be activated, navigates nowhere), the Assistant is a plain + * labelled box (not a control), and the Analysis tab is an inert placeholder page with no engine + * call. This is the P6 anti-scope-creep guarantee, proven behaviourally, not by inspection. + * 2. The seams appear on every workspace tab (they belong to the shell, not one surface). + * 3. `/f/[id]` respects **`prefers-reduced-motion`**: the global guard collapses the restrained + * tab transition to an instant when the user asks for reduced motion, and leaves it at its normal + * duration when they do not. + */ +test("the reserved seams render 'coming later' and are inert across the workspace (S6)", async ({ + page, + request, +}) => { + const fileId = await uploadFixture(request, FIXTURES.workedExample); + await page.goto(`/f/${fileId}`); + await expect(page.locator('aside[aria-label="Source file"]')).not.toContainText("Loading source…", { + timeout: 30_000, + }); + + // The seams belong to the shell, so they appear on every tab. + for (const path of [`/f/${fileId}`, `/f/${fileId}/structure`, `/f/${fileId}/convert`]) { + await page.goto(path); + await expect(page.getByTestId("future-seams")).toBeVisible({ timeout: 30_000 }); + } + + // File Repair is a disabled action affordance — it cannot be activated, so it does nothing. + const repair = page.getByRole("button", { name: "File repair" }); + await expect(repair).toBeDisabled(); + // The Assistant is a plain labelled seat, not a control (no role to trap focus or take a click). + await expect(page.getByTestId("seam-assistant")).toBeVisible(); + await expect(page.getByTestId("seam-assistant").locator("a, button, [role=button], [role=link]")).toHaveCount(0); + // Both seats say they are coming later — nothing claims to work today. + await expect(page.getByTestId("future-seams")).toContainText("coming later"); +}); + +test("the Analysis seam tab renders its placeholder and starts no conversation (S6)", async ({ + page, + request, +}) => { + const fileId = await uploadFixture(request, FIXTURES.workedExample); + // Track any engine call the seam might spuriously make — there must be none above the shell's own. + const convertCalls: string[] = []; + page.on("request", (r) => { + if (/\/v1\/(convert|files\/[^/]+\/geometry)/.test(r.url())) convertCalls.push(r.url()); + }); + + await page.goto(`/f/${fileId}/analysis`); + await expect(page.getByRole("heading", { name: "Analysis" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/reserved for per-atom and trajectory analysis — coming in a later version/i)).toBeVisible(); + + // The seam does nothing: neither a convert nor a geometry read fires because of this page. + expect(convertCalls.filter((u) => u.includes("/v1/convert"))).toEqual([]); +}); + +test("prefers-reduced-motion is honoured on the workspace (S6)", async ({ page, request }) => { + const fileId = await uploadFixture(request, FIXTURES.workedExample); + + // With no preference, the restrained tab transition runs at its normal speed. + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto(`/f/${fileId}`); + const tab = page.getByRole("link", { name: "Inspect" }); + await expect(tab).toBeVisible({ timeout: 30_000 }); + // Read the duration as a number of seconds (CSS serializes it that way — `0.15s`, never a literal + // "150ms" in computed style). + const durationSeconds = (el: Element) => + parseFloat(getComputedStyle(el).transitionDuration); + const normal = await tab.evaluate(durationSeconds); + expect(normal).toBeGreaterThanOrEqual(0.1); // the restrained tab transition runs at its normal speed + + // With reduced motion, the global guard collapses it to an instant (≈ 0.01ms → 1e-5 s). + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.reload(); + const reduced = await tab.evaluate(durationSeconds); + expect(reduced).toBeLessThan(0.001); +}); \ No newline at end of file From 874e0d781226e8e91515e620b8ecabd1b542a90a Mon Sep 17 00:00:00 2001 From: Jiayu Song Date: Mon, 31 Aug 2026 09:17:20 -0700 Subject: [PATCH 2/2] Ignore the local .superpowers brainstorm companion --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 77a2b77..d79832a 100644 --- a/.gitignore +++ b/.gitignore @@ -245,3 +245,6 @@ secrets.* _xtalate_objects/ # Default Tier 0 SQLite database (backend.db default database_url; v0.5 M21) _xtalate.db + +# Visual brainstorm companion (local, ephemeral) +.superpowers/