fix(ui): make mobile decision rows readable (#9472)
## Thinking Path > - Paperclip is the open source control plane people use to coordinate AI-agent companies > - Human operators use the Decisions attention queue to review and resolve work that needs them > - Decision rows were composed as a fixed content column plus a right-side controls column > - At phone widths, timestamps, actions, menus, and evidence thumbnails compressed the decision headline until it was barely readable > - This pull request makes each row respond to its own container width and stacks metadata, content, evidence, and actions on narrow surfaces while preserving the dense desktop layout > - The benefit is a useful, thumb-reachable Decisions workflow on phones and narrow side panels without regressing wide-screen density or scrolling performance ## Linked Issues or Issue Description No public GitHub issue exactly matches this bug, so it is described here using the bug-report fields. **What happened** Decision rows used a fixed two-column layout. On narrow screens, the right-hand timestamp, overflow menu, decision buttons, and optional thumbnails squeezed the headline into a truncated sliver. **Expected behavior** Decision headlines should remain readable on mobile, supporting context should flow below the headline, and primary actions should remain easy to tap. Wide rows should retain the compact desktop presentation. **Steps to reproduce** 1. Open the Decisions / What needs me surface with populated attention items. 2. Reduce the row container to a phone-width layout (approximately 390px). 3. Observe rows with multiple actions or evidence thumbnails. **Paperclip version / deployment mode** Current `master`, board UI in local or hosted deployments. **Related public work found during dedup search** - Refs: #9311 — original What needs me attention queue work. - Refs: #9468 — recent Decisions scrolling performance work preserved by this change. ## What Changed - Reworked `AttentionQueueRow` into a container-query-driven vertical stack on narrow surfaces, with the existing compact layout restored at wide row widths. - Made decision titles wrap to two lines, moved project/evidence context below the headline, and promoted actions to full-width mobile tap targets. - Preserved upstream row memoization and `content-visibility` scrolling optimizations while rebasing onto current `master`. - Added three 390px Storybook scenarios covering populated rows, type/detail variants, and snoozed/dismissed curtains. - Updated the focused row test to assert the new thumbnail/context alignment. ## Verification - `pnpm exec vitest run ui/src/components/AttentionQueueRow.test.tsx` — 1 file passed, 16 tests passed. - `pnpm check:token-gates` — all token gates clean. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/ui build-storybook` — completed successfully. - `git diff --check public/master...HEAD` — passed. ## Risks - Low risk: the behavior is isolated to the Decisions row presentation and its Storybook coverage. - Container-query breakpoints could need future visual tuning for unusual embedded widths, but the wide layout remains available at the row-level breakpoint. - The mobile layout increases row height by design in exchange for readable content and usable actions. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex CLI coding agent. The exact model ID and context-window size are not exposed to this runtime; reasoning, repository editing, shell execution, and test execution capabilities were enabled. ## 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) - [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 - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run 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 any 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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
8a0db228a6
commit
c36f1a4afd
|
|
@ -203,6 +203,25 @@ describe("AttentionQueueRow", () => {
|
|||
expect(onToggleExpand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("exposes the visible expand chevron as an accessible button", () => {
|
||||
const onToggleExpand = vi.fn();
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem()}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const chevronButton = container?.querySelector('button[aria-label="Expand decision"]');
|
||||
expect(chevronButton).toBeTruthy();
|
||||
expect(chevronButton?.getAttribute("aria-expanded")).toBe("false");
|
||||
act(() => chevronButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onToggleExpand).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" }));
|
||||
});
|
||||
|
||||
it("does not navigate on title click — the title is plain text, not a link", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
|
|
@ -274,7 +293,7 @@ describe("AttentionQueueRow", () => {
|
|||
expect(row?.getAttribute("class")).toContain("ring-ring");
|
||||
});
|
||||
|
||||
it("renders collapsed inline decision verbs in the right-side action area with semantic variants", () => {
|
||||
it("renders collapsed inline decision verbs in a dedicated action bar with semantic variants", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
|
|
@ -298,12 +317,11 @@ describe("AttentionQueueRow", () => {
|
|||
expect(decisionActions?.textContent).toContain("Approve");
|
||||
expect(decisionActions?.textContent).toContain("Reject");
|
||||
|
||||
// The action bar is its own full-width band (mobile-first) that collapses to
|
||||
// a right-aligned pill row once the row's container is wide (container query)
|
||||
// — no longer a stretched right column.
|
||||
const actionArea = decisionActions?.closest('[data-attention-actions="true"]');
|
||||
expect(actionArea?.getAttribute("class")).toContain("mt-auto");
|
||||
|
||||
const controls = decisionActions?.closest('[data-attention-controls="true"]');
|
||||
expect(controls?.getAttribute("class")).toContain("self-stretch");
|
||||
expect(controls?.getAttribute("class")).toContain("justify-between");
|
||||
expect(actionArea?.getAttribute("class")).toContain("@xl:justify-end");
|
||||
|
||||
const rowMenu = container?.querySelector('[aria-label="Row actions"]');
|
||||
expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy();
|
||||
|
|
@ -423,7 +441,7 @@ describe("AttentionQueueRow", () => {
|
|||
expect(issuesApi.rejectInteraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("centers thumbnails beside the full card text stack", () => {
|
||||
it("renders evidence thumbnails in a centered context row below the text stack", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ import { ProjectTile } from "./ProjectTile";
|
|||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
// Decision-action buttons: a comfortable tap target when the row is narrow
|
||||
// (h-9 / text-sm), shrinking back to the dense pill (h-6 / text-xs) once the
|
||||
// row's own container is wide enough (`@xl` ≈ 576px). Container-query driven so
|
||||
// the row also reflows correctly inside narrow side panels, not just on phones.
|
||||
const ACTION_BTN = "h-9 gap-1.5 px-3 text-sm @xl:h-6 @xl:gap-1 @xl:px-2 @xl:text-xs";
|
||||
|
||||
/** Tomorrow at 9am local time. */
|
||||
function tomorrowMorningIso(): string {
|
||||
const d = new Date();
|
||||
|
|
@ -126,10 +132,21 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
}
|
||||
};
|
||||
|
||||
// Which rows contribute an action bar. Inline rows carry compact decision
|
||||
// verbs; deep-link rows carry an Open button; curtain rows carry Restore.
|
||||
const compactActions = !isHidden ? collectCompactActions(item) : [];
|
||||
const showCompact = !expanded && compactActions.length > 0;
|
||||
const showOpen = !inline && !!href;
|
||||
const showRestore = isHidden && !!onRestore;
|
||||
const showActionBar = showCompact || showOpen || showRestore;
|
||||
// Left gutter width (chevron + gap) so the stacked content aligns under the
|
||||
// headline in the wide layout; when narrow, everything runs full-bleed.
|
||||
const gutterIndent = "@xl:pl-6";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-hidden border border-border bg-card",
|
||||
"@container relative flex flex-col overflow-hidden border border-border bg-card",
|
||||
// The feed is uncapped, so off-screen rows must not cost layout/paint
|
||||
// while scrolling. The intrinsic-size estimate only matters before a
|
||||
// row's first paint; `auto` keeps the real measured height afterwards.
|
||||
|
|
@ -147,147 +164,169 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
|||
{/* Type accent bar (canonical color map — never severity). */}
|
||||
<span className={cn("absolute inset-y-0 left-0 w-1", tone.accent)} aria-hidden />
|
||||
|
||||
<div className="flex items-start gap-3 py-3 pl-4 pr-3">
|
||||
{/* Clickable header region: toggles expand for inline rows (plan §2/§5). */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-start gap-3 rounded-md",
|
||||
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none",
|
||||
)}
|
||||
{...(expandable
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-expanded": expanded,
|
||||
"aria-label": expanded ? "Collapse decision" : "Expand decision",
|
||||
onClick: activate,
|
||||
onKeyDown: onHeaderKeyDown,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{/* Expand affordance / source icon */}
|
||||
{expandable ? (
|
||||
<span className="mt-0.5 shrink-0 p-0.5 text-muted-foreground" aria-hidden>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mt-0.5 shrink-0 p-0.5" aria-hidden>
|
||||
<Icon className={cn("h-4 w-4", tone.icon)} />
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-start gap-2 py-3 pl-4 pr-3">
|
||||
{/* Expand affordance / spacer gutter — keeps headlines aligned across the list. */}
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 shrink-0 rounded-sm p-0.5 text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none"
|
||||
aria-label={expanded ? "Collapse decision" : "Expand decision"}
|
||||
aria-expanded={expanded}
|
||||
onClick={activate}
|
||||
>
|
||||
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-0.5 hidden h-4 w-4 shrink-0 @xl:block" aria-hidden />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<Icon className={cn("h-3.5 w-3.5", tone.icon)} />
|
||||
{meta.label}
|
||||
{/* Content column: a single vertical stack that fills the full width on
|
||||
mobile (no competing right-hand controls) and reads top-to-bottom. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{/* Meta band: identity on the left, recency + overflow on the right.
|
||||
Not part of the clickable headline, so the menu never toggles it. */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
<Icon className={cn("h-3.5 w-3.5", tone.icon)} />
|
||||
{meta.label}
|
||||
</span>
|
||||
{sevBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm border px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
|
||||
sevBadge.className,
|
||||
)}
|
||||
>
|
||||
{sevBadge.label}
|
||||
</span>
|
||||
{sevBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-sm border px-1.5 py-px text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow)",
|
||||
sevBadge.className,
|
||||
)}
|
||||
>
|
||||
{sevBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{item.relatedIssue?.identifier && (
|
||||
<Link
|
||||
to={item.relatedIssue.href ?? "#"}
|
||||
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{item.relatedIssue.identifier}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground" title={item.subject.title ?? undefined}>
|
||||
{item.subject.title ?? meta.label}
|
||||
</span>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
|
||||
|
||||
{item.project && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<ProjectMeta project={item.project} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{item.relatedIssue?.identifier && (
|
||||
<Link
|
||||
to={item.relatedIssue.href ?? "#"}
|
||||
className="font-mono text-(length:--text-nano) text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{item.relatedIssue.identifier}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && <ThumbnailStack images={images} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls: kept as siblings (not inside the clickable header) so they
|
||||
never toggle expand and stay valid interactive targets. */}
|
||||
<div className="flex shrink-0 self-stretch flex-col items-end justify-between gap-2" data-attention-controls="true">
|
||||
<div className="flex items-center justify-end gap-1" data-attention-menu="true">
|
||||
{isHidden && snoozedUntil ? (
|
||||
<span
|
||||
className="text-(length:--text-nano) text-muted-foreground"
|
||||
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
|
||||
>
|
||||
Reappears {reappearLabel(snoozedUntil)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
|
||||
)}
|
||||
{!isHidden && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Row actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
|
||||
<DropdownMenuItem onClick={() => onDismiss(item)}>
|
||||
<X className="h-4 w-4" />
|
||||
Dismiss
|
||||
</DropdownMenuItem>
|
||||
{href && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Open source</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-1" data-attention-menu="true">
|
||||
{isHidden && snoozedUntil ? (
|
||||
<span
|
||||
className="text-(length:--text-nano) text-muted-foreground"
|
||||
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
|
||||
>
|
||||
Reappears {reappearLabel(snoozedUntil)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
|
||||
)}
|
||||
{!isHidden && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground"
|
||||
aria-label="Row actions"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
|
||||
<DropdownMenuItem onClick={() => onDismiss(item)}>
|
||||
<X className="h-4 w-4" />
|
||||
Dismiss
|
||||
</DropdownMenuItem>
|
||||
{href && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={href}>Open source</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col items-end gap-1" data-attention-actions="true">
|
||||
{!expanded && <CompactDecisionActions item={item} companyId={companyId} onOpen={() => onToggleExpand(item)} />}
|
||||
{/* Headline — the primary expand target for inline rows. Title now wraps
|
||||
to two lines instead of truncating to a sliver on narrow screens. */}
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-md",
|
||||
expandable && "cursor-pointer focus-visible:ring-ring focus-visible:ring-(length:--rad-3) focus-visible:outline-none",
|
||||
)}
|
||||
{...(expandable
|
||||
? {
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
"aria-expanded": expanded,
|
||||
"aria-label": expanded ? "Collapse decision" : "Expand decision",
|
||||
onClick: activate,
|
||||
onKeyDown: onHeaderKeyDown,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<span className="line-clamp-2 text-sm font-medium text-foreground" title={item.subject.title ?? undefined}>
|
||||
{item.subject.title ?? meta.label}
|
||||
</span>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-end gap-1">
|
||||
{!inline && href && (
|
||||
<Button asChild variant="outline" size="xs">
|
||||
<Link to={href}>
|
||||
{/* Context row: project identity and evidence thumbnails move below the
|
||||
text so they never squeeze the headline on mobile. */}
|
||||
{(item.project || images.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{item.project && <ProjectMeta project={item.project} />}
|
||||
{images.length > 0 && <ThumbnailStack images={images} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action bar: full-width, thumb-reachable buttons on mobile;
|
||||
right-aligned dense pills on desktop. Sibling of the headline so
|
||||
taps never toggle expand. */}
|
||||
{showActionBar && (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2 @xl:justify-end", gutterIndent)}
|
||||
data-attention-actions="true"
|
||||
>
|
||||
{showCompact && (
|
||||
<CompactDecisionActions
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
onOpen={() => onToggleExpand(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOpen && (
|
||||
<Button asChild variant="outline" size="xs" className={cn(ACTION_BTN, "w-full @xl:w-auto")}>
|
||||
<Link to={href!}>
|
||||
Open
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isHidden && onRestore && (
|
||||
<Button type="button" variant="outline" size="xs" onClick={() => onRestore(item)}>
|
||||
{showRestore && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className={cn(ACTION_BTN, "w-full @xl:w-auto")}
|
||||
onClick={() => onRestore(item)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -325,6 +364,14 @@ function compactDecisionAction(item: AttentionItem, verbId: string): CompactDeci
|
|||
return null;
|
||||
}
|
||||
|
||||
/** The compact accept/reject verbs a collapsed row can resolve in place. */
|
||||
function collectCompactActions(item: AttentionItem): Array<{ action: CompactDecisionAction; label: string; id: string }> {
|
||||
return item.decisionVerbs.slice(0, 3).flatMap((verb) => {
|
||||
const action = compactDecisionAction(item, verb.id);
|
||||
return action ? [{ action, label: verb.label, id: verb.id }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function CompactDecisionActions({
|
||||
item,
|
||||
companyId,
|
||||
|
|
@ -336,12 +383,7 @@ function CompactDecisionActions({
|
|||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const actions = item.decisionVerbs
|
||||
.slice(0, 3)
|
||||
.flatMap((verb) => {
|
||||
const action = compactDecisionAction(item, verb.id);
|
||||
return action ? [{ action, label: verb.label, id: verb.id }] : [];
|
||||
});
|
||||
const actions = collectCompactActions(item);
|
||||
|
||||
const decision = useMutation<unknown, Error, CompactDecisionAction>({
|
||||
mutationFn: (action: CompactDecisionAction) => {
|
||||
|
|
@ -387,13 +429,14 @@ function CompactDecisionActions({
|
|||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-end gap-1" aria-label="Decision actions">
|
||||
<div className="flex w-full flex-wrap items-center gap-2 @xl:w-auto @xl:justify-end @xl:gap-1" aria-label="Decision actions">
|
||||
{actions.map(({ action, id, label }) => (
|
||||
<Button
|
||||
key={id}
|
||||
type="button"
|
||||
variant={decisionVerbVariant({ id, label, description: "" })}
|
||||
size="xs"
|
||||
className={cn(ACTION_BTN, "min-w-0 flex-1 @xl:flex-none")}
|
||||
disabled={decision.isPending}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ArrowUpDown, CheckCircle2, Inbox, Layers, ListFilter } from "lucide-react";
|
||||
import type { AttentionItem, AttentionSourceKind, AttentionSeverity, InboxDismissalKind } from "@paperclipai/shared";
|
||||
|
|
@ -504,3 +504,54 @@ export const DismissUndoToast: StoryObj = {
|
|||
export const ZeroState: Story = {
|
||||
args: { items: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
* A 390×844 phone frame. Rows use container queries, so the stacked mobile
|
||||
* layout renders here at any Storybook viewport — the row reflows off its own
|
||||
* column width, not the browser width.
|
||||
*/
|
||||
function PhoneFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-center bg-background p-4">
|
||||
<div className="w-[390px] overflow-hidden rounded-xl border border-border bg-background shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile: the populated queue at phone width — full-width headlines + actions. */
|
||||
export const MobilePopulated: StoryObj = {
|
||||
name: "Mobile · Populated",
|
||||
render: () => (
|
||||
<PhoneFrame>
|
||||
<Queue items={POPULATED_DATED} groupBy="date" />
|
||||
</PhoneFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** Mobile: the type-color + detail + thumbnail showcase at phone width. */
|
||||
export const MobileShowcase: StoryObj = {
|
||||
name: "Mobile · Type colors & detail",
|
||||
render: () => (
|
||||
<PhoneFrame>
|
||||
<Queue items={SHOWCASE} groupBy="type" />
|
||||
</PhoneFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** Mobile: snoozed / dismissed curtains and the restore affordance at phone width. */
|
||||
export const MobileCurtains: StoryObj = {
|
||||
name: "Mobile · Curtains",
|
||||
render: () => (
|
||||
<PhoneFrame>
|
||||
<Queue
|
||||
items={POPULATED_DATED.slice(0, 2)}
|
||||
groupBy="date"
|
||||
snoozed={SNOOZED}
|
||||
dismissed={DISMISSED}
|
||||
openCurtains
|
||||
/>
|
||||
</PhoneFrame>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue