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:
scotttongandPaperclip committed 2026-07-14 12:11:47 -07:00
1 parent 54276280fb
commit e95bc433c8
5 files changed
+136 -47

No files matched your search

+4
View File
@@ -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).
+64 -3
View File
@@ -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();
});
});
+49 -33
View File
@@ -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>
)}
+2
View File
@@ -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
View File
@@ -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" };
}
/**