mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
design(decisions): footer-left "View …" context link, overflow slimmed (4a)
Promote the context pathway out of the overflow menu into an
always-visible footer-left ghost link on every card — active, expanded,
and curtain rows alike. The overflow menu keeps only Snooze/Dismiss.
- SOURCE_META gains a per-source contextLabel named for where
subject.href actually lands ("View thread", "View request", "View
run", "View issue", …) so the link never over-promises.
- The footer band splits context-left / CTAs-right (justify-between);
the 3a justify-end contract moves to the CTA group. Two tests that
encoded the pre-4a shape (first-anchor-is-CTA in the review deep-link
test, justify-end on the outer band) update accordingly, plus new
coverage for link presence, footer-left position, expanded
persistence, and curtain rows.
Verified: token gates 3/3 CLEAN, pnpm typecheck green, ui vitest
2537/2537, light+dark before/after screenshots reviewed.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
54276280fb
commit
e95bc433c8
5 files changed
+136
-47
No files matched your search
@@ -39,6 +39,10 @@ Every open question from TOKEN-AUDIT.md §8 + batch logs and COMPONENT-INVENTORY
|
||||
| C10 | FeatureGate wrapper pattern (3 near-identical gates) | Nice-to-have shared primitive; backlog, not a run | APPROVED — backlog nice-to-have, not a run |
|
||||
| C11 | Sidebar agents section: hand-rolled rows (own spacing/icon colors, palette-blue liveness dot) instead of `SidebarNavItem`; only section that is collapsible | Run 3 item: unify rows onto `SidebarNavItem`, settle collapsibility policy across sections, liveness dot → canonical status blue. Wholesale shadcn Sidebar adoption REJECTED for now (app already has equivalent machinery incl. icon-rail height trick; highest-regression chrome) — re-evaluate as a dedicated item after Run 3 only if its behaviors (kbd shortcut, persisted state, mobile sheet) are wanted | DONE (Run 3, Jul 7) — agent rows render via `SidebarNavItem` (new additive props: `iconNode`, `active`, `dense`, `trailing`, `liveAccessory`); all live dots (agents + nav rows, rail + expanded) now use `--status-agent-running` (#2563eb, was palette blue-500 #3b82f6 — small hue-darkening delta, per-C11 mandate; "N live" text keeps its palette classes for Run 4). **Collapsibility policy** (superseded Jul 8 by user review): EVERY labeled section (Work, Company, Projects, Agents) is collapsible, default-open, session-scoped state — one affordance everywhere. "See all agents" stays a plain muted Link on purpose (must not adopt active-route highlighting). 48 sidebar vitest assertions pass unchanged. |
|
||||
|
||||
## Decisions-page context pathway → footer-left "View … →" link, overflow slimmed (PAP-317 / PAP-313 anatomy 4a, Jul 14 2026)
|
||||
|
||||
Every Decisions card with a source href now carries an always-visible footer-left ghost link (muted text + `→`, hover foreground/underline) in the same footer band as the 3a CTAs; the overflow menu keeps only Snooze/Dismiss ("Open source" is gone — the menu no longer hides navigation). Judgment calls in the same commit: (1) **labels name the href's real destination**, via a per-source `contextLabel` in `SOURCE_META` — "View thread" (interactions), "View request" (approvals), "View members" (join requests), "View run" (failed runs), "View budget" (budget alerts), "View agent" (agent errors), "View issue" (recovery/productivity/blocker/review) — rather than echoing the concept artifact's content-specific labels ("View PR"), so the link never over-promises (e.g. recovery cards link to the issue, not a run page); (2) **the link stays in the card body while the resolver is expanded** instead of moving into the resolver's button row as the concept sketches — resolver internals are out of scope per the plan, and a stable position beats a migrating one; (3) **curtain rows get the link too** (beside Restore), matching the approved concept; (4) deep-link cards show both the ghost link and the solid Open/Review CTA even though they share an href — anatomy consistency (every card: context left, verbs right) is the point of 4a.
|
||||
|
||||
## Decisions-page type signal → mono icon + label, accent bar removed (PAP-315 / PAP-313 anatomy 1a, Jul 14 2026)
|
||||
|
||||
The five-tone type→color system (sky/violet/rose/amber/neutral accent bar + tinted source icon) is retired from the Decisions feed: the left accent bar is deleted and the source icon renders in `text-muted-foreground`, so a row's type reads through icon shape + text label alone and color is freed up for severity (Critical `destructive`, per 2a) and CTAs. Judgment call in the same commit: `attentionTone`/`attentionToneStyle`/`TONE_STYLE` (lib/attention.ts) were deleted rather than kept as an unused map — a repo-wide grep found exactly one consumer (AttentionQueueRow itself), so no other surface loses the palette and no debt remains; the tone unit tests went with them (type identity is still covered by the `sourceMeta` label/icon tests).
|
||||
|
||||
@@ -160,8 +160,10 @@ describe("AttentionQueueRow", () => {
|
||||
/>,
|
||||
);
|
||||
// Deep-link cards carry one solid advance verb — "Review" for review rows.
|
||||
// Selected via data-variant: the footer's first anchor is now the 4a
|
||||
// context link, which is a plain link rather than a Button.
|
||||
const actionArea = container?.querySelector('[data-attention-actions="true"]');
|
||||
const cta = actionArea?.querySelector("a");
|
||||
const cta = actionArea?.querySelector("a[data-variant]");
|
||||
expect(cta?.textContent).toBe("Review");
|
||||
expect(cta?.getAttribute("href")).toBe("/PAP/issues/PAP-1");
|
||||
expect(cta?.getAttribute("data-variant")).toBe("default");
|
||||
@@ -325,9 +327,11 @@ describe("AttentionQueueRow", () => {
|
||||
// The third verb stays in the expanded resolver — a card carries at most two CTAs.
|
||||
expect(decisionActions?.textContent).not.toContain("Request revision");
|
||||
|
||||
// The action bar is a persistent footer-right band at every width (3a).
|
||||
// The footer band now splits context-left / CTAs-right (4a), so the
|
||||
// justify-end contract moved from the outer bar to the CTA group.
|
||||
const actionArea = decisionActions?.closest('[data-attention-actions="true"]');
|
||||
expect(actionArea?.getAttribute("class")).toContain("justify-end");
|
||||
expect(actionArea?.getAttribute("class")).toContain("justify-between");
|
||||
expect(decisionActions?.parentElement?.getAttribute("class")).toContain("justify-end");
|
||||
|
||||
const rowMenu = container?.querySelector('[aria-label="Row actions"]');
|
||||
expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy();
|
||||
@@ -585,4 +589,61 @@ describe("AttentionQueueRow", () => {
|
||||
);
|
||||
expect(container?.querySelector('[role="button"][aria-expanded]')).toBeNull();
|
||||
});
|
||||
|
||||
it("always shows a footer-left context link named for the source (4a)", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem()}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = container?.querySelector('[data-attention-context-link="true"]');
|
||||
expect(link?.textContent).toBe("View request→");
|
||||
expect(link?.getAttribute("href")).toBe("/PAP/approvals/approval-1");
|
||||
// Footer-left: the link leads the footer band, ahead of the CTA group.
|
||||
expect(link?.closest('[data-attention-actions="true"]')?.firstElementChild).toBe(link);
|
||||
});
|
||||
|
||||
it("keeps the context link visible while the resolver is expanded", () => {
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem()}
|
||||
companyId="c1"
|
||||
expanded
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = container?.querySelector('[data-attention-context-link="true"]');
|
||||
expect(link?.textContent).toContain("View request");
|
||||
});
|
||||
|
||||
it("gives curtain rows the context link beside Restore", () => {
|
||||
const onRestore = vi.fn();
|
||||
render(
|
||||
<AttentionQueueRow
|
||||
item={buildItem({
|
||||
dismissal: { kind: "dismiss", dismissedAt: "2026-07-12T12:00:00Z" } as AttentionItem["dismissal"],
|
||||
})}
|
||||
companyId="c1"
|
||||
expanded={false}
|
||||
onToggleExpand={noop}
|
||||
onDismiss={noop}
|
||||
onRestore={onRestore}
|
||||
variant="hidden"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = container?.querySelector('[data-attention-context-link="true"]');
|
||||
expect(link?.getAttribute("href")).toBe("/PAP/approvals/approval-1");
|
||||
const restore = Array.from(container?.querySelectorAll("button") ?? []).find(
|
||||
(button) => button.textContent === "Restore",
|
||||
);
|
||||
expect(restore).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -134,7 +134,11 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
||||
const showExpandVerb = !expanded && !decisionCtas && inline;
|
||||
const showOpen = !isHidden && !decisionCtas && !inline && !!href;
|
||||
const showRestore = isHidden && !!onRestore;
|
||||
const showActionBar = showDecision || showExpandVerb || showOpen || showRestore;
|
||||
const showCtas = showDecision || showExpandVerb || showOpen || showRestore;
|
||||
// Context pathway (4a): every card with a source href carries a footer-left
|
||||
// "View …" link — active, expanded, and curtain rows alike — so context is
|
||||
// never buried in the overflow menu.
|
||||
const showActionBar = showCtas || !!href;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -225,20 +229,15 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* Deferral verbs only (4a): the context pathway moved to the
|
||||
always-visible footer-left link, so the menu no longer
|
||||
hides navigation. */}
|
||||
<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>
|
||||
)}
|
||||
@@ -278,37 +277,54 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Persistent footer bar (3a): CTAs sit footer-right at one size on
|
||||
every viewport. Sibling of the headline so taps never toggle
|
||||
expand. */}
|
||||
{/* Persistent footer bar: context link left (4a), CTAs right at one
|
||||
size (3a). Sibling of the headline so taps never toggle expand. */}
|
||||
{showActionBar && (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-end gap-2"
|
||||
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2"
|
||||
data-attention-actions="true"
|
||||
>
|
||||
{(showDecision || showExpandVerb) && (
|
||||
<CompactDecisionActions
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
ctas={decisionCtas}
|
||||
onOpen={() => onToggleExpand(item)}
|
||||
/>
|
||||
{href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
data-attention-context-link="true"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{meta.contextLabel}
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
|
||||
{showOpen && (
|
||||
<Button asChild size="sm">
|
||||
<Link to={href!}>
|
||||
{item.sourceKind === "review" ? "Review" : "Open"}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{showCtas && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{(showDecision || showExpandVerb) && (
|
||||
<CompactDecisionActions
|
||||
item={item}
|
||||
companyId={companyId}
|
||||
ctas={decisionCtas}
|
||||
onOpen={() => onToggleExpand(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRestore && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onRestore(item)}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Restore
|
||||
</Button>
|
||||
{showOpen && (
|
||||
<Button asChild size="sm">
|
||||
<Link to={href!}>
|
||||
{item.sourceKind === "review" ? "Review" : "Open"}
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showRestore && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onRestore(item)}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -124,6 +124,8 @@ describe("sourceMeta", () => {
|
||||
for (const kind of kinds) {
|
||||
expect(sourceMeta(kind).label.length).toBeGreaterThan(0);
|
||||
expect(sourceMeta(kind).icon).toBeTruthy();
|
||||
// 4a footer-left context link: every kind names its destination.
|
||||
expect(sourceMeta(kind).contextLabel).toMatch(/^View /);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+17
-11
@@ -41,23 +41,29 @@ export function isInlineResolvable(item: AttentionItem): boolean {
|
||||
interface SourceMeta {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
/**
|
||||
* Footer-left context-link label (plan 4a), named for where `subject.href`
|
||||
* actually lands (issue thread, run page, /costs…) so the link never
|
||||
* over-promises relative to its destination.
|
||||
*/
|
||||
contextLabel: string;
|
||||
}
|
||||
|
||||
const SOURCE_META: Record<AttentionSourceKind, SourceMeta> = {
|
||||
approval: { label: "Approval", icon: ShieldCheck },
|
||||
issue_thread_interaction: { label: "Decision requested", icon: MessageSquareQuote },
|
||||
join_request: { label: "Join request", icon: UserPlus },
|
||||
recovery_action: { label: "Recovery", icon: LifeBuoy },
|
||||
productivity_review: { label: "Productivity review", icon: Zap },
|
||||
blocker_attention: { label: "Blocked dependency", icon: Ban },
|
||||
review: { label: "Review", icon: Eye },
|
||||
failed_run: { label: "Failed run", icon: RefreshCw },
|
||||
budget_alert: { label: "Budget", icon: DollarSign },
|
||||
agent_error_alert: { label: "Agent error", icon: AlertTriangle },
|
||||
approval: { label: "Approval", icon: ShieldCheck, contextLabel: "View request" },
|
||||
issue_thread_interaction: { label: "Decision requested", icon: MessageSquareQuote, contextLabel: "View thread" },
|
||||
join_request: { label: "Join request", icon: UserPlus, contextLabel: "View members" },
|
||||
recovery_action: { label: "Recovery", icon: LifeBuoy, contextLabel: "View issue" },
|
||||
productivity_review: { label: "Productivity review", icon: Zap, contextLabel: "View issue" },
|
||||
blocker_attention: { label: "Blocked dependency", icon: Ban, contextLabel: "View issue" },
|
||||
review: { label: "Review", icon: Eye, contextLabel: "View issue" },
|
||||
failed_run: { label: "Failed run", icon: RefreshCw, contextLabel: "View run" },
|
||||
budget_alert: { label: "Budget", icon: DollarSign, contextLabel: "View budget" },
|
||||
agent_error_alert: { label: "Agent error", icon: AlertTriangle, contextLabel: "View agent" },
|
||||
};
|
||||
|
||||
export function sourceMeta(kind: AttentionSourceKind): SourceMeta {
|
||||
return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " "), icon: AlertTriangle };
|
||||
return SOURCE_META[kind] ?? { label: kind.replaceAll("_", " "), icon: AlertTriangle, contextLabel: "View source" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user