Files
PaperClipAI/tests/storybook-visual/task-row-design.spec.ts
T
7b9334cd10 Draft: Build agent-ready design proposal system (#16875)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - People need consistent UI patterns to review and control that work.
> - The current board uses related layouts and controls with different
spacing, type, and behavior.
> - That drift makes it hard for a designer or an agent to build a new
page from a reliable reference.
> - Storybook can show the shared patterns and the states they need to
support.
> - This pull request adds design proposals, a local sandbox, and
reusable UI controls so people can compare the patterns in context.
> - The benefit is a clearer design system that agents can use to build
consistent UI.

## Linked Issues or Issue Description

**Subsystem affected**

`ui/` — React board UI and Storybook.

**Problem or motivation**

Board pages repeat visual and interaction decisions. Storybook did not
provide a complete reference for task views, conversation states,
typography, toolbars, tabs, and light/dark colors. People could not
compare a proposed pattern with the running board in one place.

**Proposed solution**

Add a staged Design Proposals catalog with foundations, components,
layouts, and page specimens. Add an opt-in sandbox. Reuse production
components where possible. Include synthetic conversation states and
shared controls so reviewers can compare and refine them.

**Alternatives considered**

Separate static mockups would not show keyboard behavior, responsive
layout, or real component states. Rebuilding each page only in Storybook
would allow the reference to drift from the board.

**Roadmap alignment**

This improves existing board UI and Skills Manager surfaces. It does not
add a new control-plane capability.

## What Changed

- Added dark and light color references, typography and conversation
specimens, and component stories for buttons, tabs, toolbars, sidebar,
popovers, activity, and task lists.
- Added a local design sandbox with synthetic task conversation playback
and representative states.
- Shared collection toolbars and navigation patterns across task, agent,
project, routine, skill, connector, and artifact pages.
- Added the streamlined Skills tabs and a My Skills collection that uses
the shared skill cards.
- Restored saved-thread access during synthetic playback, kept connector
searchbox semantics, and updated reproduction checks after moving trial
stories.
- Documented the proposed patterns and added focused UI, catalog, and
browser checks.

## Verification

- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node
scripts/storybook-catalog.mjs --check` passed.
- Full workspace `pnpm -r typecheck` and `pnpm build` passed after
merging master. After the review fixes, focused UI typecheck, `pnpm
build-storybook`, 23 Skills Sources tests, and all 15 Storybook
typography browser tests passed.
- Focused task-thread and system-notice suites passed: 251 tests.
Connector Browse passed 84 tests; the connector, attachment, recovery,
and Storybook flyout browser journeys passed locally.
- A local full Vitest run hit one intermittent chat-channels integration
failure; that test passed in isolation. GitHub CI
[39347996059](https://github.com/paperclipai/paperclip/actions/runs/39347996059)
passed on the exact latest commit: 55 successful checks and two skipped
draft-only Storybook jobs.
- Greptile reviewed the exact latest commit at 5/5 with no new comments
or open review threads. GitHub reports the branch as cleanly mergeable.

## Risks

- This PR changes 206 files and exceeds the repository's 100-file review
target. Review by commit and consider a scoped split before merge.
- Design Proposals are staged references. Fixture interactions do not
prove API behavior, permissions, or provider execution.
- UI changes to shared controls may affect pages outside the proposal
sandbox. Focused browser checks and the full CI matrix passed.

## Model Used

- OpenAI Codex, GPT-6 family. The exact runtime model ID and context
window are not exposed in this session. The model used reasoning, code
execution, browser inspection, and local tests. Earlier branch commits
were also AI-assisted in this Codex conversation.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details available in this session)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above where applicable
- [x] I have either linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Scott Tong <scott@scotts-mbp-m5-max.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-19 17:45:26 -07:00

702 lines
44 KiB
TypeScript

import { expect, test, type Locator, type Page } from "@playwright/test";
const longTitle = "Document connector recovery behavior when an expired credential interrupts an active task and requires a human to reconnect the account";
test.use({ viewport: { width: 1512, height: 862 } });
function rowFor(page: Page, number: number): Locator {
return page.locator('[data-slot="task-row"]').filter({
has: page.getByRole("link", { name: new RegExp(`^Open PAP-${number}:`) }),
});
}
async function openStory(page: Page, name = "specimen") {
await page.goto(`/iframe.html?id=tasks-row-design--${name}&viewMode=story&globals=theme:dark`);
await expect(page.getByTestId("task-row-design-preview")).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await page.mouse.move(0, 0);
}
async function titleOverflows(title: Locator) {
return title.evaluate(element => element.querySelector('[data-slot="task-row-title-text"]')!.scrollWidth > element.clientWidth);
}
test("playground changes density and width independently without losing hierarchy or title states", async ({ page }, testInfo) => {
await openStory(page);
const preview = page.getByTestId("task-row-playground-container");
const row = preview.locator('[data-slot="task-row"]').first();
const widths = page.getByRole("group", { name: "Container width", exact: true });
const densities = page.getByRole("group", { name: "Row density", exact: true });
for (const { label, height } of [{ label: "Comfortable (44px)", height: "44px" }, { label: "Compact (32px)", height: "32px" }]) {
await densities.getByRole("button", { name: label, exact: true }).click();
await expect(densities.getByRole("button", { name: label, exact: true })).toHaveAttribute("aria-pressed", "true");
for (const width of ["Standard", "Narrow", "Smallest"]) {
await widths.getByRole("button", { name: width, exact: true }).click();
await expect(widths.getByRole("button", { name: width, exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(preview.locator('[data-slot="task-row"]')).toHaveCount(9);
await expect(row).toHaveCSS("height", height);
await expect(row.locator('[data-slot="task-row-identifier"]')).toBeVisible({ visible: width === "Standard" });
await expect(row.locator('[data-slot="task-row-metadata"]')).toBeVisible({ visible: width === "Standard" });
await expect(row.locator('[data-slot="task-row-timestamp"]')).toBeVisible({ visible: width !== "Smallest" });
await expect(rowFor(page, 431).locator('[data-slot="task-row-tree-guide"]')).toHaveCount(2);
for (const { number, weight } of [{ number: 456, weight: "400" }, { number: 457, weight: "600" }]) {
const title = rowFor(page, number).locator('[data-slot="task-row-title"]');
await expect(title).toHaveCSS("font-size", "14px");
await expect(title).toHaveCSS("line-height", "16px");
await expect(title).toHaveCSS("font-weight", weight);
}
const fit = await preview.evaluate(element => element.scrollWidth <= element.clientWidth);
expect(fit).toBe(true);
}
}
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await widths.getByRole("button", { name: "Narrow", exact: true }).click();
await expect(preview.locator('[data-slot="task-row"]')).toHaveCount(5);
await page.getByRole("button", { name: "Expand Improve connector reliability", exact: true }).click();
await expect(preview.locator('[data-slot="task-row"]')).toHaveCount(9);
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("task-row-playground.png"), fullPage: true });
});
test("summary rows fit narrow containers without changing density or read typography", async ({ page }, testInfo) => {
await openStory(page, "summary");
for (const { label, identifier, timestamp } of [
{ label: "Standard task summary", identifier: true, timestamp: true },
{ label: "Narrow task summary", identifier: false, timestamp: true },
{ label: "Smallest task summary", identifier: false, timestamp: false },
]) {
const list = page.getByRole("list", { name: label, exact: true });
const rows = list.locator('[data-slot="task-row"]');
await expect(rows).toHaveCount(3);
const row = rows.first();
await expect(row).toHaveCSS("height", "44px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("font-size", "14px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("line-height", "16px");
await expect(row.locator('[data-slot="task-row-title"]')).toHaveCSS("font-weight", "400");
await expect(row.locator('[data-slot="task-row-identifier"]')).toBeVisible({ visible: identifier });
await expect(row.locator('[data-slot="task-row-timestamp"]')).toBeVisible({ visible: timestamp });
const fit = await list.evaluate(element => ({ content: element.scrollWidth, available: element.clientWidth }));
expect(fit.content).toBeLessThanOrEqual(fit.available);
await expect(list.locator('[data-testid="issue-row-unread-slot"], [data-slot="avatar"], [role="columnheader"]')).toHaveCount(0);
}
const list = page.getByRole("list", { name: "Narrow task summary", exact: true });
const row = list.locator('[data-slot="task-row"]').first();
const title = row.locator('[data-slot="task-row-title"]');
expect(await titleOverflows(title)).toBe(true);
const originalColor = await title.evaluate(element => getComputedStyle(element).color);
const rect = (await title.boundingBox())!;
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(title).toHaveCSS("font-weight", "400");
expect(await title.evaluate(element => getComputedStyle(element).color)).not.toBe(originalColor);
await page.mouse.move(0, 0);
await expect(title).not.toHaveAttribute("data-revealing");
const link = list.getByRole("link", { name: `Open PAP-431: ${longTitle}`, exact: true });
await link.focus();
await expect(title).toHaveAttribute("data-revealing", "true");
await link.press("Escape");
await expect(title).not.toHaveAttribute("data-revealing");
await page.getByRole("heading", { name: "Task summaries", exact: true }).click();
await page.screenshot({ path: testInfo.outputPath("task-summary-containers.png"), fullPage: true });
// Container queries also apply below the app's global sm breakpoint.
await page.setViewportSize({ width: 390, height: 844 });
await expect(list.locator('[data-slot="task-row-identifier"]').first()).toBeHidden();
await expect(list.locator('[data-slot="task-row-timestamp"]').first()).toBeVisible();
await expect(row).toHaveCSS("height", "44px");
await expect(row.locator('[data-slot="task-row-status"]')).toBeVisible();
});
for (const { story, density, height } of [
{ story: "specimen", density: "comfortable", height: 44 },
{ story: "compact", density: "compact", height: 32 },
]) {
test(`${density} rows retain nesting, aligned metadata, and one group boundary`, async ({ page }, testInfo) => {
await openStory(page, story);
const list = page.locator(`[aria-label="${density} task list"]`);
const rows = list.locator('[data-slot="task-row"]');
await expect(rows).toHaveCount(7);
const geometry = await rows.evaluateAll((elements) => elements.map((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
const left = (slot: string) => element.querySelector(`[data-slot="${slot}"]`)!.getBoundingClientRect().left;
return {
height: rect.height,
paddingLeft: style.paddingLeft,
paddingRight: style.paddingRight,
titleLeft: left("task-row-title"),
metadataLeft: left("task-row-metadata"),
identifierLeft: left("task-row-identifier"),
timestampLeft: left("task-row-timestamp"),
borderTop: style.borderTopWidth,
borderBottom: style.borderBottomWidth,
};
}));
for (const row of geometry) {
expect(row.height).toBe(height);
expect(row.paddingLeft).toBe("16px");
expect(row.paddingRight).toBe("16px");
expect(row.borderTop).toBe("0px");
expect(row.borderBottom).toBe("0px");
expect(row.metadataLeft).toBe(geometry[0].metadataLeft);
expect(row.identifierLeft).toBe(geometry[0].identifierLeft);
expect(row.timestampLeft).toBe(geometry[0].timestampLeft);
}
expect(geometry[1].titleLeft - geometry[0].titleLeft).toBe(24);
expect(geometry[2].titleLeft).toBe(geometry[1].titleLeft);
expect(geometry[3].titleLeft - geometry[2].titleLeft).toBe(24);
expect(geometry[4].titleLeft).toBe(geometry[1].titleLeft);
const groups = list.locator('[data-slot="task-row-group"]');
await expect(groups).toHaveCount(3);
await expect(groups.first()).toHaveCSS("border-top-width", "1px");
await expect(groups.first()).toHaveCSS("border-bottom-width", "1px");
await expect(groups.nth(1)).toHaveCSS("border-top-width", "0px");
expect((await groups.first().boundingBox())!.height).toBe(height * 5 + 2);
// The outer guide stays continuous through child and grandchild rows.
const guides = await Promise.all([429, 430, 431, 432].map(async (number) =>
rowFor(page, number).locator('[data-slot="task-row-tree-connector"]').first().boundingBox(),
));
for (let index = 0; index < guides.length; index += 1) {
expect(guides[index]!.x).toBe(guides[0]!.x);
expect(guides[index]!.height).toBe(height);
if (index > 0) expect(guides[index - 1]!.y + guides[index - 1]!.height).toBe(guides[index]!.y);
}
const depthTwoGuides = await rowFor(page, 431).locator('[data-slot="task-row-tree-connector"]').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().left));
expect(depthTwoGuides[1] - depthTwoGuides[0]).toBe(24);
await expect(rowFor(page, 430).locator('[data-chevron-gap="true"]')).toHaveCount(1);
if (density === "comfortable") {
const path = testInfo.outputPath("task-row-specimen.png");
await page.screenshot({ path, fullPage: true });
await testInfo.attach("Task row specimen", { path, contentType: "image/png" });
}
});
}
test("read and unread preserve their weights through real pointer hover", async ({ page }) => {
await openStory(page);
const foreground = await page.getByRole("heading", { name: "Task rows", exact: true }).evaluate((element) => getComputedStyle(element).color);
const muted = await page.getByText("Read · Regular", { exact: true }).evaluate((element) => getComputedStyle(element).color);
expect(foreground).not.toBe(muted);
for (const { number, weight, color } of [
{ number: 456, weight: "400", color: muted },
{ number: 457, weight: "600", color: foreground },
]) {
const row = rowFor(page, number);
const title = row.locator('[data-slot="task-row-title"]');
await page.mouse.move(0, 0);
await expect(title).toHaveCSS("font-weight", weight);
await expect(title).toHaveCSS("color", color);
const background = await row.evaluate((element) => getComputedStyle(element).backgroundColor);
await row.hover();
await expect(title).toHaveCSS("font-weight", weight);
await expect(title).toHaveCSS("color", foreground);
expect(await row.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(background);
await page.mouse.move(0, 0);
await expect(title).toHaveCSS("color", color);
}
const identifier = rowFor(page, 428).locator('[data-slot="task-row-identifier"]');
expect(await identifier.evaluate((element) => getComputedStyle(element).fontFamily)).toContain("Paper Mono");
expect(await page.evaluate(() => document.fonts.check('12px "Paper Mono"'))).toBe(true);
});
test("title reveal follows truncation on resize and respects reduced motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await openStory(page);
const row = rowFor(page, 430);
const title = row.locator('[data-slot="task-row-title"]');
const link = row.locator('[data-inbox-issue-link]');
const text = title.locator('[data-slot="task-row-title-text"]');
expect(await titleOverflows(title)).toBe(false);
await link.hover();
await expect(title).not.toHaveAttribute("data-revealing");
await expect(page.getByRole("tooltip")).toHaveCount(0);
await page.mouse.move(0, 0);
await link.focus();
await expect(link).toBeFocused();
await expect(title).not.toHaveAttribute("data-revealing");
await expect(page.getByRole("tooltip")).toHaveCount(0);
await page.setViewportSize({ width: 660, height: 862 });
await expect.poll(() => titleOverflows(title)).toBe(true);
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(text).toHaveCSS("transition-duration", "0s");
await expect.poll(async () => Math.abs((await text.boundingBox())!.x + (await text.boundingBox())!.width - (await title.boundingBox())!.x - (await title.boundingBox())!.width)).toBeLessThan(1);
await expect(page.getByRole("tooltip")).toHaveCount(0);
await page.setViewportSize({ width: 1512, height: 862 });
await expect.poll(() => titleOverflows(title)).toBe(false);
await expect(title).not.toHaveAttribute("data-revealing");
await expect(text).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
await expect(link).toBeFocused();
});
test("all nine columns use sm text and align through nesting and horizontal scrolling", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1200, height: 862 });
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
await expect(header.locator('[data-column]')).toHaveText([
"Task", "Task ID", "Created by", "Assignee", "Project", "Parent Task", "Tags", "Worktree", "Last updated",
]);
await expect(header).toHaveCSS("height", "44px");
const columns = ["taskId", "createdBy", "assignee", "project", "parentTask", "tags", "lastUpdated", "worktree"];
const rows = page.locator('[data-slot="task-row"]');
await expect(rows).toHaveCount(7);
await expect(header.getByRole("button", { name: /^Status:/ })).toHaveCount(0);
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-colspan", "2");
const typeSizes = await page.locator('[data-slot="task-row-title"], [data-slot="task-row-columns"] [data-column], [data-slot="task-row-columns"] [data-slot="badge"], .task-list-sort-button > span').evaluateAll(elements => elements.map(element => ({ size: getComputedStyle(element).fontSize, leading: getComputedStyle(element).lineHeight })));
for (const type of typeSizes) expect(type).toEqual({ size: "14px", leading: "16px" });
for (const column of columns) {
const heading = await header.locator(`[data-column="${column}"]`).boundingBox();
const cells = await rows.locator(`[data-column="${column}"]`).evaluateAll(elements => elements.map(element => element.getBoundingClientRect().toJSON()));
expect(cells).toHaveLength(7);
for (const cell of cells) {
expect(cell.x).toBe(heading!.x);
expect(cell.width).toBe(heading!.width);
}
}
const rootTitle = await rowFor(page, 428).locator('[data-slot="task-row-title"]').boundingBox();
expect(rootTitle!.x).toBe((await header.locator('[data-column="task"]').getByRole("button").locator("span").boundingBox())!.x);
for (const number of [428, 429, 431]) {
const row = rowFor(page, number);
const status = await row.locator('[data-slot="task-row-status"]').boundingBox();
const title = await row.locator('[data-slot="task-row-title"]').boundingBox();
expect(title!.x - status!.x - status!.width).toBe(8);
}
expect((await rowFor(page, 431).locator('[data-slot="task-row-title"]').boundingBox())!.x - rootTitle!.x).toBe(48);
await expect(rowFor(page, 455).locator('[data-column="assignee"]')).toHaveText("Unassigned");
await expect(rowFor(page, 455).locator('[data-column="worktree"]')).toHaveText("—");
await expect(rowFor(page, 431).locator('[data-column="parentTask"]')).toContainText("PAP-430");
await expect(rowFor(page, 428).locator('[data-column="worktree"]')).toHaveText("codex/pap-428");
const scroller = page.getByRole("region", { name: "Task list columns" });
const viewport = page.locator('[data-slot="task-list-viewport"]');
expect(await scroller.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true);
await expect(viewport).toHaveAttribute("data-overflow-left", "false");
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
await scroller.focus();
await expect(scroller).toBeFocused();
await scroller.evaluate(element => element.scrollTo({ left: (element.scrollWidth - element.clientWidth) / 2 }));
await expect(viewport).toHaveAttribute("data-overflow-left", "true");
await expect(viewport).toHaveAttribute("data-overflow-right", "true");
await header.locator('[data-column="worktree"]').scrollIntoViewIfNeeded();
expect(await scroller.evaluate(element => element.scrollLeft)).toBeGreaterThan(0);
await scroller.evaluate(element => element.scrollTo({ left: element.scrollWidth }));
await expect(viewport).toHaveAttribute("data-overflow-left", "true");
await expect(viewport).toHaveAttribute("data-overflow-right", "false");
await expect(header.locator('[data-column]')).toHaveCount(9);
await page.setViewportSize({ width: 1700, height: 862 });
await expect.poll(() => scroller.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(false);
await expect(viewport).toHaveAttribute("data-overflow-left", "false");
await expect(viewport).toHaveAttribute("data-overflow-right", "false");
await page.getByRole("heading", { name: "Task-list columns", exact: true }).click();
const path = testInfo.outputPath("task-list-all-columns.png");
await page.locator('.task-list-columns-content').screenshot({ path });
await testInfo.attach("Complete column specimen", { path, contentType: "image/png" });
});
test("headers sort both ways without separating subtasks from their parents", async ({ page }, testInfo) => {
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
await expect(header.getByRole("button", { name: /^Status:/ })).toHaveCount(0);
const ids = page.locator('[data-slot="task-row"] [data-column="taskId"]');
const taskId = header.locator('[data-column="taskId"]');
await expect(taskId).toHaveAttribute("aria-sort", "none");
await expect(header.locator('[data-active="true"]')).toHaveCount(0);
await expect(header.locator('[data-slot="task-list-sort-direction"]')).toHaveCount(0);
await taskId.getByRole("button").click();
await expect(taskId).toHaveAttribute("aria-sort", "ascending");
for (const button of await header.getByRole("button").all()) {
await expect(button).toHaveCSS("padding-left", "8px");
await expect(button).toHaveCSS("padding-right", "8px");
}
await expect(taskId.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("width", "12px");
await expect(taskId.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("height", "12px");
await taskId.getByRole("button").click();
await expect(taskId).toHaveAttribute("aria-sort", "descending");
await expect(ids).toHaveText(["PAP-455", "PAP-454", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429"]);
await expect(rowFor(page, 431).locator('[data-slot="task-row-tree-guide"]')).toHaveCount(2);
const collapse = page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true });
await collapse.click();
await expect(ids).toHaveText(["PAP-455", "PAP-454", "PAP-428"]);
await page.getByRole("button", { name: "Expand Improve connector reliability", exact: true }).click();
await expect(ids).toHaveText(["PAP-455", "PAP-454", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429"]);
await taskId.getByRole("button").focus();
await page.keyboard.press("Enter");
await expect(taskId).toHaveAttribute("aria-sort", "ascending");
await expect(ids).toHaveText(["PAP-428", "PAP-429", "PAP-430", "PAP-431", "PAP-432", "PAP-454", "PAP-455"]);
const title = header.locator('[data-column="task"]');
const titleLabel = title.getByRole("button").locator("span");
const labelBefore = await titleLabel.boundingBox();
await title.getByRole("button").hover();
await expect(title.getByRole("button")).toHaveCSS("padding-left", "8px");
await expect(title.getByRole("button")).toHaveCSS("padding-right", "8px");
expect(await titleLabel.boundingBox()).toEqual(labelBefore);
await title.getByRole("button").click();
await expect(title).toHaveAttribute("aria-sort", "ascending");
await expect(taskId).toHaveAttribute("aria-sort", "none");
await expect(ids).toHaveText(["PAP-454", "PAP-428", "PAP-429", "PAP-430", "PAP-431", "PAP-432", "PAP-455"]);
await title.getByRole("button").click();
await expect(title).toHaveAttribute("aria-sort", "descending");
await expect(ids).toHaveText(["PAP-455", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429", "PAP-454"]);
await page.mouse.move(0, 0);
await expect(title.getByRole("button")).toHaveCSS("background-color", "rgb(51, 51, 51)");
const path = testInfo.outputPath("task-list-task-header.png");
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path });
await testInfo.attach("Combined Task header and sm table typography", { path, contentType: "image/png" });
for (const column of ["createdBy", "assignee", "project", "parentTask", "tags", "lastUpdated", "worktree"]) {
const cell = header.locator(`[data-column="${column}"]`);
await cell.getByRole("button").click();
await expect(cell).toHaveAttribute("aria-sort", "ascending");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("width", "12px");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("height", "12px");
await cell.getByRole("button").click();
await expect(cell).toHaveAttribute("aria-sort", "descending");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("width", "12px");
await expect(cell.locator('[data-slot="task-list-sort-direction"]')).toHaveCSS("height", "12px");
await expect(header.locator('[aria-sort="descending"]')).toHaveCount(1);
}
// A missing worktree stays last even when sorting descending.
await expect(ids).toHaveText(["PAP-454", "PAP-428", "PAP-432", "PAP-430", "PAP-431", "PAP-429", "PAP-455"]);
});
test("truncated cells reveal independently and preserve row navigation", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1800, height: 900 });
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page, "all-columns");
const row = rowFor(page, 431);
const title = row.locator('[data-slot="task-row-title"]');
const parent = row.locator('[data-column="parentTask"] [data-cell-marquee]');
const tags = rowFor(page, 429).locator('[data-column="tags"] [data-cell-marquee]');
const parentText = parent.locator('.cell-marquee-content');
const revealing = page.locator('[data-cell-marquee][data-revealing="true"]');
const moveToCell = async (cell: Locator) => {
const rect = (await cell.boundingBox())!;
await page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
};
const overflows = (cell: Locator) => cell.evaluate(element => element.querySelector('.cell-marquee-content')!.scrollWidth > element.clientWidth);
expect(await overflows(title)).toBe(true);
expect(await overflows(parent)).toBe(true);
expect(await overflows(tags)).toBe(true);
for (const cell of [title, parent, tags]) {
await expect(cell).toHaveAttribute("data-truncated", "true");
await expect(cell.locator('.cell-marquee-viewport')).not.toHaveCSS("mask-image", "none");
}
const fitting = row.locator('[data-column="taskId"] [data-cell-marquee]');
await expect(fitting).not.toHaveAttribute("data-truncated");
await expect(fitting.locator('.cell-marquee-viewport')).toHaveCSS("mask-image", "none");
const mask = await parent.locator('.cell-marquee-viewport').evaluate(element => getComputedStyle(element).maskImage);
const background = await row.evaluate(element => getComputedStyle(element).backgroundColor);
await moveToCell(fitting);
expect(await row.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe(background);
await expect(parent.locator('.cell-marquee-viewport')).toHaveCSS("mask-image", mask);
await expect(parent).not.toHaveAttribute("data-revealing");
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path: testInfo.outputPath("task-list-truncation-fade-hover.png") });
await page.mouse.move(0, 0);
await row.locator('[data-inbox-issue-link]').focus();
await expect(title).toHaveAttribute("data-revealing", "true");
await moveToCell(parent);
await expect(parent).toHaveAttribute("data-revealing", "true");
await expect(title).not.toHaveAttribute("data-revealing");
await expect(revealing).toHaveCount(1);
await expect.poll(() => parentText.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).m41)).toBeLessThan(-1);
await parentText.evaluate(element => element.getAnimations().forEach(animation => animation.finish()));
await expect(parent).toHaveAttribute("data-reveal-complete", "true");
const endMask = await parent.locator('.cell-marquee-viewport').evaluate(element => getComputedStyle(element).maskImage);
expect(endMask).not.toBe(mask);
expect(endMask).not.toMatch(/transparent\)$|rgba\(0, 0, 0, 0\)\)$/);
const parentEnd = await parentText.boundingBox();
const parentViewport = await parent.boundingBox();
expect(Math.abs(parentEnd!.x + parentEnd!.width - parentViewport!.x - parentViewport!.width)).toBeLessThan(1);
await moveToCell(tags);
await expect(tags).toHaveAttribute("data-revealing", "true");
await expect(parent).not.toHaveAttribute("data-revealing");
await expect(parentText).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
await expect(parentText).toHaveCSS("transition-duration", "0s");
await expect(revealing).toHaveCount(1);
await expect(page.getByRole("tooltip")).toHaveCount(0);
await moveToCell(rowFor(page, 429).locator('[data-column="taskId"]'));
await expect(revealing).toHaveCount(0);
await parent.focus();
await expect(parent).toHaveAttribute("data-revealing", "true");
await page.keyboard.press("Escape");
await expect(parent).not.toHaveAttribute("data-revealing");
await page.emulateMedia({ reducedMotion: "reduce" });
await moveToCell(parent);
await expect(parent).toHaveAttribute("data-revealing", "true");
await expect(parentText).toHaveCSS("transition-duration", "0s");
const cell = (await row.locator('[data-column="parentTask"]').boundingBox())!;
await page.mouse.click(cell.x + cell.width / 2, cell.y + cell.height / 2);
await expect(page.getByTestId("task-row-design-route")).toHaveText("/PAP/issues/PAP-431");
await page.mouse.move(0, 0);
await page.screenshot({ path: testInfo.outputPath("task-list-cell-marquees.png") });
});
test("column headers drag in either direction, preserve sorting and nesting, and reset their order", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1800, height: 900 });
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
const initialOrder = ["Task", "Task ID", "Created by", "Assignee", "Project", "Parent Task", "Tags", "Worktree", "Last updated"];
await expect(header.locator('[data-column]')).toHaveText(initialOrder);
const dragHeader = async (from: string, to: string) => {
const button = (await header.locator(`[data-column="${from}"] button`).boundingBox())!;
const target = (await header.locator(`[data-column="${to}"]`).boundingBox())!;
await page.mouse.move(button.x + button.width / 2, button.y + button.height / 2);
await page.mouse.down();
await page.mouse.move(button.x + button.width / 2 + 8, button.y + button.height / 2);
await page.mouse.move(target.x + target.width / 2, target.y + target.height / 2, { steps: 12 });
await page.mouse.up();
};
await dragHeader("taskId", "assignee");
await expect(header.locator('[data-column]')).toHaveText(["Task", "Created by", "Assignee", "Task ID", "Project", "Parent Task", "Tags", "Worktree", "Last updated"]);
await expect(header.locator('[data-column="taskId"]')).toHaveAttribute("aria-sort", "none");
await dragHeader("task", "project");
await expect(header.locator('[data-column]')).toHaveText(["Created by", "Assignee", "Task ID", "Project", "Task", "Parent Task", "Tags", "Worktree", "Last updated"]);
const task = header.locator('[data-column="task"] button');
await task.focus();
await page.keyboard.press("Alt+ArrowLeft");
const movedOrder = ["Created by", "Assignee", "Task ID", "Task", "Project", "Parent Task", "Tags", "Worktree", "Last updated"];
await expect(header.locator('[data-column]')).toHaveText(movedOrder);
await expect(task).toBeFocused();
for (const id of ["createdBy", "assignee", "taskId", "project", "parentTask", "tags", "worktree", "lastUpdated"]) {
const rect = (await header.locator(`[data-column="${id}"]`).boundingBox())!;
for (const cell of await page.locator(`[data-slot="task-row"] [data-column="${id}"]`).all()) {
expect((await cell.boundingBox())!.x).toBe(rect.x);
}
}
const root = rowFor(page, 428);
const child = rowFor(page, 431);
expect((await child.locator('[data-slot="task-row-title"]').boundingBox())!.x - (await root.locator('[data-slot="task-row-title"]').boundingBox())!.x).toBe(48);
for (const row of [root, child]) {
const status = (await row.locator('[data-slot="task-row-status"]').boundingBox())!;
expect((await row.locator('[data-slot="task-row-title"]').boundingBox())!.x - status.x - status.width).toBe(8);
}
await page.keyboard.press("Enter");
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-sort", "ascending");
await page.mouse.move(0, 0);
await expect(task).toHaveCSS("background-color", "rgb(51, 51, 51)");
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path: testInfo.outputPath("task-list-reordered-columns.png") });
const settings = page.getByRole("button", { name: "Columns", exact: true });
await settings.click();
const menu = page.getByRole("menu", { name: "Columns", exact: true });
await expect(menu.getByRole("menuitemcheckbox")).toHaveText(movedOrder);
await menu.getByRole("menuitemcheckbox", { name: "Created by", exact: true }).click();
await menu.getByRole("menuitemcheckbox", { name: "Created by", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(movedOrder);
await menu.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(menu.getByRole("menuitemcheckbox")).toHaveText(initialOrder);
await expect(header.locator('[data-active="true"]')).toHaveCount(0);
await expect(header.locator('[data-slot="task-list-sort-direction"]')).toHaveCount(0);
await menu.getByRole("menuitemcheckbox", { name: "Worktree", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Worktree", "Last updated"]);
await page.keyboard.press("Escape");
// Drag cancellation must not change the order or toggle sorting.
const button = (await task.boundingBox())!;
await page.mouse.move(button.x + button.width / 2, button.y + button.height / 2);
await page.mouse.down();
await page.mouse.move(button.x + button.width / 2 + 100, button.y + button.height / 2);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Worktree", "Last updated"]);
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-sort", "none");
});
test("status labels appear on hover and focus while the icon still opens the task", async ({ page }, testInfo) => {
await openStory(page, "all-columns");
const status = rowFor(page, 428).locator('[data-slot="task-row-status"]');
const tooltip = page.getByRole("tooltip");
await status.hover();
await expect(tooltip).toHaveText("In Progress");
await page.mouse.move(0, 0, { steps: 3 });
await expect(tooltip).toHaveCount(0);
await status.focus();
await expect(tooltip).toHaveText("In Progress");
await page.keyboard.press("Escape");
await expect(tooltip).toHaveCount(0);
await expect(status).toBeFocused();
await rowFor(page, 429).locator('[data-slot="task-row-status"]').hover();
await expect(tooltip).toHaveText("In Review");
const path = testInfo.outputPath("task-status-tooltip.png");
await page.screenshot({ path });
await testInfo.attach("Task status label", { path, contentType: "image/png" });
await status.click();
await expect(page.getByTestId("task-row-design-route")).toHaveText("/PAP/issues/PAP-428");
});
test("pinned Columns menu hides columns, retains alignment, and resets defaults", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1200, height: 862 });
await openStory(page, "all-columns");
const header = page.locator('[data-slot="task-list-column-header"]');
const settings = page.getByRole("button", { name: "Columns", exact: true });
const settingsBefore = await settings.boundingBox();
await page.getByRole("region", { name: "Task list columns" }).evaluate(element => element.scrollTo({ left: element.scrollWidth }));
expect(await settings.boundingBox()).toEqual(settingsBefore);
await settings.click();
const menu = page.getByRole("menu", { name: "Columns", exact: true });
await expect(menu).toBeVisible();
// Match the Paper flyout while retaining the current combined Task contract.
await expect(menu).toHaveCSS("width", "200px");
await expect(menu).toHaveCSS("border-radius", "8px");
await expect(menu).toHaveCSS("border-width", "1px");
await expect(menu).toHaveCSS("border-style", "solid");
await expect(menu).toHaveCSS("background-color", "rgb(26, 26, 26)");
const borderColor = await menu.evaluate(element => getComputedStyle(element.querySelector(".task-list-options-heading")!).borderBottomColor);
await expect(menu).toHaveCSS("border-color", borderColor);
for (const surface of [page.locator("html"), page.locator("body"), page.locator("#storybook-root"), page.getByTestId("task-row-design-preview"), page.locator('[data-slot="task-list-viewport"]'), page.locator(".task-list-header-controls"), page.locator('[data-slot="task-row-group"]').first()]) {
await expect(surface).toHaveCSS("background-color", "rgb(26, 26, 26)");
}
const heading = menu.locator(".task-list-options-heading");
await expect(heading).toHaveCSS("padding", "12px 24px");
await expect(heading).toHaveCSS("font-weight", "600");
const options = menu.getByRole("menuitemcheckbox");
const menuGeometry = await options.evaluateAll(elements => elements.map(element => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
const check = element.querySelector("svg")!.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, size: style.fontSize, leading: style.lineHeight, weight: style.fontWeight, radius: style.borderRadius, checkRight: rect.right - check.right, checkWidth: check.width };
}));
for (const [index, item] of menuGeometry.entries()) {
expect(item).toMatchObject({ width: 174, height: 32, size: "14px", leading: "20px", weight: "400", radius: "4px", checkRight: 12, checkWidth: 14 });
if (index > 0) expect(item.y - menuGeometry[index - 1].y).toBe(36);
}
const reset = menu.getByRole("menuitem", { name: "Reset defaults", exact: true });
await expect(reset).toHaveCSS("height", "32px");
await expect(reset).toHaveCSS("font-weight", "600");
const taskIdOption = menu.getByRole("menuitemcheckbox", { name: "Task ID", exact: true });
await taskIdOption.hover();
await expect(taskIdOption).toHaveCSS("background-color", "rgb(51, 51, 51)");
await expect(taskIdOption).toHaveCSS("color", await heading.evaluate(element => getComputedStyle(element).color));
await expect(menu.getByRole("menuitemcheckbox")).toHaveCount(9);
await expect(menu.getByRole("menuitemcheckbox", { name: "Status", exact: true })).toHaveCount(0);
await expect(menu.getByRole("menuitemcheckbox", { name: "Task", exact: true })).toBeChecked();
for (const name of ["Created by", "Tags", "Task ID"]) {
const item = menu.getByRole("menuitemcheckbox", { name, exact: true });
await expect(item).toBeChecked();
await item.click();
await expect(item).not.toBeChecked();
await expect(menu).toBeVisible();
}
await expect(header.locator('[data-column]')).toHaveText(["Task", "Assignee", "Project", "Parent Task", "Worktree", "Last updated"]);
await expect(page.locator('[data-slot="task-row"] [data-column="tags"]')).toHaveCount(0);
await expect(header.locator('[data-column="task"]')).toHaveAttribute("aria-sort", "none");
for (const column of ["assignee", "project", "parentTask", "lastUpdated", "worktree"]) {
const x = (await header.locator(`[data-column="${column}"]`).boundingBox())!.x;
const positions = await page.locator(`[data-slot="task-row"] [data-column="${column}"]`).evaluateAll(elements => elements.map(element => element.getBoundingClientRect().x));
expect(positions).toEqual(Array(7).fill(x));
}
// Hiding Task hides both status and title; hierarchy controls and links remain.
await menu.getByRole("menuitemcheckbox", { name: "Task", exact: true }).click();
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(rowFor(page, 428).locator('[data-slot="task-row-content"]')).toBeHidden();
await expect(rowFor(page, 428).getByRole("img", { name: "In Progress", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Collapse Improve connector reliability", exact: true }).click();
await expect(rowFor(page, 431)).toHaveCount(0);
await page.getByRole("button", { name: "Expand Improve connector reliability", exact: true }).click();
await expect(rowFor(page, 431)).toHaveCount(1);
await settings.click();
await menu.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(header.locator('[data-column="taskId"]')).toHaveAttribute("aria-sort", "none");
await expect(menu.getByRole("menuitemcheckbox", { checked: true })).toHaveText(["Task", "Task ID", "Last updated"]);
for (const row of await page.locator('[data-slot="task-row"]').all()) {
expect(await row.locator('[data-column]').evaluateAll(elements => elements.map(element => element.getAttribute("data-column")))).toEqual(["taskId", "lastUpdated"]);
}
// Optional columns remain available after resetting to the smaller default.
const tags = menu.getByRole("menuitemcheckbox", { name: "Tags", exact: true });
await expect(tags).not.toBeChecked();
await tags.click();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Tags", "Last updated"]);
await expect(page.locator('[data-slot="task-row"] [data-column="tags"] [data-slot="badge"]').first()).toHaveCSS("background-color", "rgb(51, 51, 51)");
await tags.click();
// Leave only one column: the final choice is disabled until another is enabled.
await menu.getByRole("menuitemcheckbox", { name: "Worktree", exact: true }).click();
for (const name of ["Task", "Task ID", "Last updated"]) {
await menu.getByRole("menuitemcheckbox", { name, exact: true }).click();
}
await expect(menu.getByRole("menuitemcheckbox", { name: "Worktree", exact: true })).toBeDisabled();
await menu.getByRole("menuitem", { name: "Reset defaults", exact: true }).click();
await taskIdOption.hover();
const path = testInfo.outputPath("task-list-options.png");
await page.screenshot({ path });
await testInfo.attach("Columns flyout", { path, contentType: "image/png" });
await menu.screenshot({ path: testInfo.outputPath("task-list-columns-flyout.png") });
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(settings).toBeFocused();
await expect(rowFor(page, 428).locator('[data-slot="task-row-content"]')).toBeVisible();
await expect(header.locator('[data-column]')).toHaveText(["Task", "Task ID", "Last updated"]);
await expect(rowFor(page, 428).locator('[data-column="tags"]')).toHaveCount(0);
await page.locator('[data-slot="task-list-viewport"]').screenshot({ path: testInfo.outputPath("task-list-default-columns.png") });
});
test("truncated titles scroll once, hold their end, and reset immediately; row actions stay independent", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await openStory(page);
const route = page.getByTestId("task-row-design-route");
await expect(route).toHaveText("/PAP/storybook");
const longRow = rowFor(page, 431);
const title = longRow.locator('[data-slot="task-row-title"]');
await expect(title).toHaveCSS("text-overflow", "ellipsis");
await expect(title).toHaveCSS("white-space", "nowrap");
const text = title.locator('[data-slot="task-row-title-text"]');
expect(await titleOverflows(title)).toBe(true);
const start = (await text.boundingBox())!.x;
const identifierBefore = await longRow.locator('[data-slot="task-row-identifier"]').boundingBox();
const link = longRow.locator('[data-inbox-issue-link]');
await link.hover();
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(page.getByRole("tooltip")).toHaveCount(0);
await expect.poll(async () => (await text.boundingBox())!.x).toBeLessThan(start - 1);
expect(await longRow.locator('[data-slot="task-row-identifier"]').boundingBox()).toEqual(identifierBefore);
await text.evaluate(element => element.getAnimations().forEach(animation => animation.finish()));
const viewport = await title.boundingBox();
const end = await text.boundingBox();
expect(Math.abs(end!.x + end!.width - viewport!.x - viewport!.width)).toBeLessThan(1);
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
expect(await text.boundingBox()).toEqual(end);
await page.mouse.move(0, 0);
await expect(title).not.toHaveAttribute("data-revealing");
await expect(text).toHaveCSS("transition-duration", "0s");
expect((await text.boundingBox())!.x).toBe(start);
await expect(title).toHaveCSS("text-overflow", "ellipsis");
// Reach the long-title link using the keyboard from the preceding disclosure.
const childDisclosure = page.getByRole("button", { name: "Collapse Design reconnect and recovery flows" });
await childDisclosure.focus();
await page.keyboard.press("Tab");
await expect(rowFor(page, 430).locator('[data-slot="task-row-status"]')).toBeFocused();
await expect(page.getByRole("tooltip")).toHaveText("Todo");
await page.keyboard.press("Tab");
await expect(link).toBeFocused();
await expect(title).toHaveAttribute("data-revealing", "true");
await expect(link).toHaveCSS("outline-style", "solid");
await expect(link).toHaveCSS("outline-width", "1px");
await page.keyboard.press("Escape");
await expect(title).not.toHaveAttribute("data-revealing");
await childDisclosure.click();
await expect(rowFor(page, 431)).toHaveCount(0);
await expect(rowFor(page, 432)).toBeVisible();
await expect(route).toHaveText("/PAP/storybook");
await page.getByRole("button", { name: "Expand Design reconnect and recovery flows" }).click();
await expect(rowFor(page, 431)).toBeVisible();
await page.getByRole("button", { name: "Collapse Improve connector reliability" }).click();
await expect(rowFor(page, 429)).toHaveCount(0);
await expect(rowFor(page, 431)).toHaveCount(0);
await expect(route).toHaveText("/PAP/storybook");
const rootDisclosure = page.getByRole("button", { name: "Expand Improve connector reliability" });
await rootDisclosure.focus();
await page.keyboard.press("Space");
await expect(rowFor(page, 431)).toBeVisible();
await expect(route).toHaveText("/PAP/storybook");
await rowFor(page, 428).locator('[data-inbox-issue-link]').focus();
await page.keyboard.press("Enter");
await expect(route).toHaveText("/PAP/issues/PAP-428");
});