Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 56 additions & 21 deletions src/browser/features/RightSidebar/GoalBoardSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,49 @@ function SectionShell(props: SectionShellProps) {
);
}

/**
* Bordered chip-style button for per-row actions (Edit / Promote / Remove /
* Archive / Revive). Matches the visual weight of the Cancel / Queue-goal
* controls below so each row reads as a real button row, not a strip of
* text links β€” and so the affordance is obvious on first glance without
* relying on hover state. Tone tints the hover color/background:
* β€’ neutral β€” Edit / Archive / Revive (Inbox-style moves)
* β€’ positive β€” Promote (upcoming β†’ active)
* β€’ destructive β€” Remove (drops the goal from the board)
*
* Exported so the active-goal card in `GoalTab.tsx` can render the
* "Archive this goal" / "Clear goal" affordance with the same chip
* styling (the de-emphasized text-link variant was visually inconsistent
* with every other Archive control on the same surface).
*/
export type RowActionTone = "neutral" | "positive" | "destructive";

export interface RowActionButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
tone?: RowActionTone;
}

const ROW_ACTION_TONE_CLASS: Record<RowActionTone, string> = {
neutral: "text-muted hover:text-foreground hover:bg-surface-tertiary",
positive: "text-muted hover:text-success hover:bg-success/10 hover:border-success/40",
destructive:
"text-muted hover:text-danger-soft hover:bg-danger-soft/10 hover:border-danger-soft/40",
};

export function RowActionButton(props: RowActionButtonProps) {
const { tone = "neutral", className, type, ...rest } = props;
return (
<button
type={type ?? "button"}
className={cn(
"border-border-light inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs transition-colors",
ROW_ACTION_TONE_CLASS[tone],
className
)}
{...rest}
/>
);
}

interface UpcomingSectionProps {
workspaceId: string;
entries: GoalBoardEntry[];
Expand Down Expand Up @@ -317,32 +360,28 @@ function UpcomingRow(props: UpcomingRowProps) {
<span className="text-muted counter-nums shrink-0 text-xs">
{props.goal.budgetCents == null ? "no budget" : formatGoalCents(props.goal.budgetCents)}
</span>
<div className="flex items-center gap-1">
<button
type="button"
className="text-muted hover:text-foreground inline-flex items-center gap-0.5 text-xs"
<div className="flex items-center gap-1.5">
<RowActionButton
aria-label={`Edit ${props.goal.objective}`}
onClick={() => setIsEditing(true)}
>
<Pencil className="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
className="text-muted hover:text-success inline-flex items-center gap-0.5 text-xs"
</RowActionButton>
<RowActionButton
tone="positive"
aria-label={`Promote ${props.goal.objective}`}
onClick={() => void props.onPromote()}
>
<Play className="h-3 w-3" aria-hidden="true" />
Promote
</button>
<button
type="button"
className="text-muted hover:text-danger-soft inline-flex items-center gap-0.5 text-xs"
</RowActionButton>
<RowActionButton
tone="destructive"
aria-label={`Remove ${props.goal.objective}`}
onClick={() => void props.onArchive()}
>
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</RowActionButton>
</div>
</div>
);
Expand Down Expand Up @@ -510,15 +549,13 @@ function CompletedSection(props: CompletedSectionProps) {
<span className="text-muted counter-nums shrink-0 text-xs">
{formatGoalCents(entry.goal.costCents)}
</span>
<button
type="button"
className="text-muted hover:text-foreground inline-flex items-center gap-0.5 text-xs"
<RowActionButton
aria-label={`Archive ${entry.goal.objective}`}
onClick={() => void archive(entry.goal.goalId)}
>
<Inbox className="h-3 w-3" aria-hidden="true" />
Archive
</button>
</RowActionButton>
</div>
))}
{error && (
Expand Down Expand Up @@ -562,15 +599,13 @@ function ArchivedSection(props: ArchivedSectionProps) {
className="border-border-light bg-surface-primary flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm"
>
<span className="text-foreground line-clamp-1 flex-1">{entry.goal.objective}</span>
<button
type="button"
className="text-muted hover:text-foreground inline-flex items-center gap-0.5 text-xs"
<RowActionButton
aria-label={`Revive ${entry.goal.objective}`}
onClick={() => void revive(entry.goal.goalId)}
>
<ArchiveRestore className="h-3 w-3" aria-hidden="true" />
Revive
</button>
</RowActionButton>
</div>
))}
{error && (
Expand Down
135 changes: 135 additions & 0 deletions src/browser/features/RightSidebar/GoalTab.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";

import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
import { APIProvider } from "@/browser/contexts/API";
import { CHROMATIC_SMOKE_MODES } from "@/browser/stories/meta";
import { createMockORPCClient } from "@/browser/stories/mocks/orpc";
import type { GoalBoardEntry, GoalBoardSnapshot, GoalRecordV1 } from "@/common/types/goal";

import { GoalTab } from "./GoalTab";

const meta: Meta<typeof GoalTab> = {
Expand Down Expand Up @@ -120,3 +127,131 @@ export const ActiveWithBudget: Story = {
},
},
};

// ─────────────────────────────────────────────────────────────────────────
// CompleteWithBoard β€” full tab populated with Upcoming / Completed /
// Archived sections so the Archive row button (Completed β†’ Archive),
// Revive (Archived), Promote / Remove / Edit (Upcoming), and the
// de-emphasized "Archive this goal" link on the active complete card all
// render in one screenshot. Required visual coverage for the row-action
// button restyle, which `GoalTab.test.tsx` cannot see (it mocks
// `GoalBoardSections` away).
//
// `GoalBoardSections` only mounts when `workspaceId` is provided, and
// the populated board reaches it through `useGoalBoard β†’ api.workspace
// .getGoalBoard`. The decorator below mounts the story under an
// `APIProvider` backed by the mock client, with a seeded snapshot keyed
// by the story's workspaceId.
// ─────────────────────────────────────────────────────────────────────────

const STORY_WORKSPACE_ID = "ws-story-goaltab";
const NOW = Date.UTC(2026, 4, 20, 12, 0, 0);

function makeBoardGoal(
overrides: Partial<GoalRecordV1> & Pick<GoalRecordV1, "goalId">
): GoalRecordV1 {
return {
version: 1,
goalId: overrides.goalId,
objective: overrides.objective ?? "Untitled goal",
status: overrides.status ?? "paused",
budgetCents: overrides.budgetCents ?? null,
turnCap: overrides.turnCap ?? null,
costCents: overrides.costCents ?? 0,
turnsUsed: overrides.turnsUsed ?? 0,
attributedChildren: overrides.attributedChildren ?? [],
budgetLimitInjectedForGoalId: overrides.budgetLimitInjectedForGoalId ?? null,
requireUserAcknowledgmentSinceMs: overrides.requireUserAcknowledgmentSinceMs ?? null,
createdAtMs: overrides.createdAtMs ?? NOW,
updatedAtMs: overrides.updatedAtMs ?? NOW,
...(overrides.completionSummary != null
? { completionSummary: overrides.completionSummary }
: {}),
};
}

function boardEntry(
section: GoalBoardEntry["section"],
goal: GoalRecordV1,
endedAtMs?: number
): GoalBoardEntry {
return endedAtMs != null ? { section, goal, endedAtMs } : { section, goal };
}

const FULL_BOARD: GoalBoardSnapshot = {
entries: [
boardEntry(
"upcoming",
makeBoardGoal({
goalId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
objective: "Wire goal-board reorder to the keyboard",
budgetCents: 500,
})
),
boardEntry(
"upcoming",
makeBoardGoal({
goalId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
objective: "Audit goal continuation telemetry",
})
),
boardEntry(
"complete",
makeBoardGoal({
goalId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
objective: "Ship the goal primitive vertical slice",
status: "complete",
budgetCents: 500,
costCents: 412,
turnsUsed: 8,
completionSummary: "Lifecycle controls shipped with persistence and tests.",
}),
NOW - 60_000
),
boardEntry(
"archived",
makeBoardGoal({
goalId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
objective: "Sketch goal-board mobile layout",
}),
NOW - 3_600_000
),
],
};

export const CompleteWithBoard: Story = {
args: {
workspaceId: STORY_WORKSPACE_ID,
goal: {
goalId: "33333333-3333-4333-8333-333333333333",
status: "complete",
objective: "Ship the goal primitive vertical slice",
budgetCents: null,
costCents: 250,
turnsUsed: 5,
turnCap: null,
completionSummary: "The lifecycle controls shipped with persistence and tests.",
startedAtMs: NOW,
},
},
// Dual-theme smoke coverage: the row-action button restyle should not
// regress in either light or dark mode.
parameters: {
chromatic: { modes: CHROMATIC_SMOKE_MODES },
},
decorators: [
(Story) => (
<APIProvider
client={createMockORPCClient({
goalBoardSnapshots: new Map([[STORY_WORKSPACE_ID, FULL_BOARD]]),
})}
>
<TooltipProvider>
<div className="max-w-md p-3">
<Story />
</div>
</TooltipProvider>
</APIProvider>
),
],
};
29 changes: 18 additions & 11 deletions src/browser/features/RightSidebar/GoalTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -495,16 +495,17 @@ describe("GoalTab", () => {
expect(getByLabelText("Edit goal objective")).toBeTruthy();
});

test("clear control is de-prominent and relabels for completed goals", () => {
test("active goals expose a de-prominent Clear; completed goals promote Archive as primary", () => {
const { getByLabelText, getByText, rerender, queryByText } = render(
<GoalTab goal={goal()} onSetStatus={mock()} onClear={mock()} />
);

// Active goal: the clear control exists but is rendered as a small text
// link β€” no primary-button background classes are applied.
// Lifecycle-active goals: Clear is rendered as a small chip-style
// row-action button β€” explicitly NOT the primary accent button used
// by Save / Set goal / Archive-on-complete. Keeps a destructive
// option visible without competing with Pause / Mark complete.
const clearButton = getByLabelText("Clear goal");
expect(clearButton.className).not.toContain("bg-accent");
expect(clearButton.className).toContain("underline");
expect(getByText("Clear goal")).toBeTruthy();

rerender(
Expand All @@ -514,13 +515,19 @@ describe("GoalTab", () => {
onClear={mock()}
/>
);
// Completed goals: the action is "archive" (moves the goal into the
// board's Archived section via `workspace.archiveGoal`, not into
// history under `endReason: "completed"`). The visible label,
// aria-label, and the absence of the "Clear goal" wording are all
// part of the user-visible UX contract.
expect(getByLabelText("Archive goal")).toBeTruthy();
expect(getByText("Archive this goal")).toBeTruthy();
// Completed goals: Archive sits next to Reopen as the accent-colored
// primary action ("file this away" is the obvious next step for a
// finished goal). Reopen stays available as the secondary recovery
// path with a neutral border style β€” the inverse of the previous
// green Reopen / underlined Archive layout. The "Clear goal" wording
// disappears entirely for completed goals; Archive routes through
// the goal-board endpoint instead.
const archiveButton = getByLabelText("Archive goal");
expect(archiveButton.className).toContain("bg-accent");
expect(getByText("Archive")).toBeTruthy();
const reopenButton = getByLabelText("Reopen goal");
expect(reopenButton.className).not.toContain("bg-accent");
expect(getByText("Reopen")).toBeTruthy();
expect(queryByText("Clear goal")).toBeNull();
});

Expand Down
Loading
Loading