Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
8 changes: 6 additions & 2 deletions frontend/app/f/[file_id]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand All @@ -20,6 +23,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<div className="min-w-0 flex-1">
<WorkspaceTabs fileId={file_id} />
<div className="mt-5">{children}</div>
<FutureSeams />
</div>
</div>
);
Expand Down
20 changes: 20 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
8 changes: 8 additions & 0 deletions frontend/components/command/CommandPaletteTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"
>
<span aria-hidden="true">⌘</span>
Expand Down
41 changes: 41 additions & 0 deletions frontend/components/shell/FutureSeams.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FutureSeams />);
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(<FutureSeams />);
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(<FutureSeams />);
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();
});
});
58 changes: 58 additions & 0 deletions frontend/components/shell/FutureSeams.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
aria-label="Coming in a later version"
data-testid="future-seams"
className="mt-8 space-y-3 border-t border-line pt-4"
>
<h2 className="text-sm font-semibold text-strong">Coming in a later version</h2>
<p className="text-sm text-muted">
These seats are reserved so later work can attach without re-architecting the workspace.
Nothing here runs yet.
</p>
<div className="flex flex-wrap gap-3">
{/* File Repair — reserved as an action affordance at the conversion seam; inert (disabled). */}
<div
data-testid="seam-repair"
className="flex items-center gap-2 rounded-md border border-dashed border-line px-3 py-2"
>
<button
type="button"
disabled
className="cursor-not-allowed rounded border border-line px-2 py-1 text-sm text-faint"
>
File repair
</button>
<span className="text-sm text-faint">coming later</span>
</div>
{/* Assistant — reserved as a side-panel slot; not a control, just a labelled seat. */}
<div
data-testid="seam-assistant"
className="flex items-center gap-2 rounded-md border border-dashed border-line px-3 py-2"
>
<span className="text-sm font-medium text-body">Assistant</span>
<span className="text-sm text-faint">coming later</span>
</div>
</div>
</section>
);
}
22 changes: 22 additions & 0 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions frontend/e2e/qol.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
82 changes: 82 additions & 0 deletions frontend/e2e/seams.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading