Files
PaperClipAI/ui/src/components/SidebarShell.test.tsx
T
scotttongandClaude Fable 5 e0bbf3c911 refactor(ui): extract remaining bracket values, add token gate check (Phase 2 batch 4/4)
Final batch of the design-token extraction: mints tokens for every
remaining value-bearing arbitrary Tailwind bracket in
ui/src/components/** and ui/src/pages/**, then adds a gate script so
the "zero hardcoded value" rule is enforced going forward instead of
re-audited by hand each time.

Codemod: scripts/codemod-extract-misc.mjs (198 sites, 84 files, 96 new
tokens: 58 --gtc-*/3 --gtr-* grid track lists, 19 --tp-*
transition-property lists, 6 --z-* z-index values, --s-*/--e-*/--va-*/
--sw-* scale/ease/vertical-align/stroke-width, 2 --blur-*, 1
--drop-shadow-extract-*, 4 *-resolved wrapper tokens resolving
FileViewerSheet.tsx's half-migrated var(x, fallback) sites Batch 1
deferred, and 2 --gradient-extract-* continuing Batch 1's counter for
2 hsl(var())/color-mix() gradient sites its color-literal audit didn't
catch). Mandatory Step 0 syntax spike confirmed every paren form
byte-equivalent via scratch stories + built-CSS grep, and confirmed
the tw-animate-css plugin utilities (zoom-in-[0.97] etc.) are
currently dead/no-op classes (plugin not installed) — allowlisted,
not rewritten, since there's no visual behavior to preserve or change.

Gate script: scripts/check-token-gates.mjs (new, wired as
`pnpm check:token-gates`) implements the three DONE-WHEN gates
(hex/color-literal, arbitrary-bracket-value, raw-font-size) over
ui/src/components/** and ui/src/pages/**, parsing a new
machine-readable ALLOWLIST block appended to ui/src/index.css that
consolidates Batches 1-3's free-form allowlist prose into
`* allow <path> — <reason>` lines. Resolved TOKEN-AUDIT.md's open
"test file policy" question by allowlisting 43 test-fixture hex-literal
sites across 18 *.test.tsx files (mock prop/API-response data, not
rendered CSS). Gate exits 0 clean.

AGENTS.md's existing Design system section now names the gate command.
TOKEN-AUDIT.md gets a Batch 4 log appendix with counts, allowlist
rationale, and the remaining out-of-scope debt (36 hex sites in
ui/src/lib|context|plugins; the ~3,115-site Tailwind-palette-class
scope question is still unresolved and flagged for a human decision).

Verify: node scripts/check-token-gates.mjs exit 0 (all 3 gates clean);
pnpm build-storybook exit 0; Storybook visual suite 510/510 (Playwright,
tests/storybook-visual); pnpm typecheck (ui/) exit 0; codemod idempotent
on re-run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:50:09 -07:00

202 lines
6.3 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SidebarShell, SIDEBAR_RAIL_WIDTH } from "./SidebarShell";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function pointerEvent(type: string, clientX: number) {
const event = new MouseEvent(type, { bubbles: true, clientX });
Object.defineProperty(event, "pointerId", { value: 1 });
return event;
}
describe("SidebarShell", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
window.localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
window.localStorage.clear();
});
// The in-flow spacer that reserves layout width.
function spacer() {
return container.firstElementChild as HTMLDivElement;
}
// The absolutely-positioned overlay panel that holds the sidebar content.
function panel() {
return spacer().firstElementChild as HTMLDivElement;
}
function handle() {
return container.querySelector('[role="separator"]') as HTMLDivElement | null;
}
it("uses a persisted width when expanded", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// Both the reserved spacer and the panel match the expanded width; no overlay.
expect(spacer().style.width).toBe("320px");
expect(panel().style.width).toBe("320px");
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()?.getAttribute("aria-valuenow")).toBe("320");
});
it("resizes by dragging and persists the new width", () => {
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
const separator = handle();
expect(separator).not.toBeNull();
separator!.setPointerCapture = vi.fn();
act(() => {
separator!.dispatchEvent(pointerEvent("pointerdown", 240));
separator!.dispatchEvent(pointerEvent("pointermove", 320));
separator!.dispatchEvent(pointerEvent("pointerup", 320));
});
expect(panel().style.width).toBe("320px");
expect(window.localStorage.getItem("test.sidebar.width")).toBe("320");
});
it("supports keyboard resizing and clamps to the configured bounds", () => {
act(() => {
root.render(
<SidebarShell open resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
const separator = handle();
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
});
expect(panel().style.width).toBe("256px");
expect(window.localStorage.getItem("test.sidebar.width")).toBe("256");
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
});
expect(panel().style.width).toBe("208px");
act(() => {
separator?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
});
expect(panel().style.width).toBe("420px");
});
it("can render without a resize handle", () => {
act(() => {
root.render(
<SidebarShell open resizable={false}>
<div>Sidebar</div>
</SidebarShell>,
);
});
expect(handle()).toBeNull();
expect(panel().style.width).toBe("240px");
});
it("reserves only the rail width when collapsed and hides the resize handle", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open collapsed resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// Reserved spacer and panel both collapse to the rail; content never reflows
// beyond the rail, and the rail is not user-resizable.
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()).toBeNull();
});
it("hides all sidebar width when closed, even if pinned collapsed", () => {
window.localStorage.setItem("test.sidebar.width", "320");
act(() => {
root.render(
<SidebarShell open={false} collapsed resizable storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
expect(spacer().style.width).toBe("0px");
expect(panel().style.width).toBe("0px");
expect(panel().getAttribute("data-sidebar-overlay")).toBeNull();
expect(handle()).toBeNull();
});
it("overlays content while peeking without expanding the reserved spacer", () => {
window.localStorage.setItem("test.sidebar.width", "300");
act(() => {
root.render(
<SidebarShell open collapsed peeking storageKey="test.sidebar.width">
<div>Sidebar</div>
</SidebarShell>,
);
});
// The reserved spacer stays at the rail (no page reflow) while the panel
// grows to the expanded width and gets overlay styling.
expect(spacer().style.width).toBe(`${SIDEBAR_RAIL_WIDTH}px`);
expect(panel().style.width).toBe("300px");
expect(panel().getAttribute("data-sidebar-overlay")).toBe("");
expect(panel().className).toContain("shadow-lg");
expect(panel().className).toContain("z-30");
});
it("opens and closes instantly with no width transition (PAP-10676)", () => {
act(() => {
root.render(
<SidebarShell open>
<div>Sidebar</div>
</SidebarShell>,
);
});
// Open/close must be instant: the panel never animates its width, so neither
// the transition nor its reduced-motion fallback should be present.
expect(panel().className).not.toContain("transition-(--tp-width)");
expect(panel().className).not.toContain("motion-reduce:transition-none");
});
});