Skip to content

Commit f309cc8

Browse files
refactor(webapp): compose practice adoption from shared design-system parts
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 11bf52b commit f309cc8

28 files changed

Lines changed: 1335 additions & 758 deletions

.changeset/adopt-without-leaving-practice-setup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"hephaestus": patch
33
---
44

5-
Adding practices from the library no longer takes you away from Practice setup. A practice or an area opens in a panel over the page you were already on, and opening a practice from inside an area stacks a second panel on top of the first — step back one at a time with Escape, the browser's Back button, or a swipe. Adding a practice now returns you to the library with the row marked Added, instead of dropping you into an edit form, so a run of five practices is five clicks rather than five round trips. Every panel is addressable: the URL you are looking at is the URL you can share, and it reopens exactly the panels you had open.
5+
Adding practices from the library no longer takes you away from Practice setup. A practice or an area opens in a panel over the page you were already on, and opening a practice from inside an area stacks a second panel on top of the first — step back one at a time with Escape, a press on the page beside the panel, the browser's Back button, or a swipe. The page behind stays readable rather than blurred, and each panel leaves a column of the one beneath it showing, so you can see where you are in the stack. Adding a practice now returns you to the library with the row marked Added, instead of dropping you into an edit form, so a run of five practices is five clicks rather than five round trips. Every panel is addressable: the URL you are looking at is the URL you can share, and it reopens exactly the panels you had open.

webapp/src/components/admin/curated-catalog/CuratedCatalogTree.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Link } from "@tanstack/react-router";
22
import { GripVertical, MoreHorizontal } from "lucide-react";
33
import type { CuratedArea, CuratedPracticeSummary } from "@/api/types.gen";
4-
import { getAreaVisual } from "@/components/admin/practice-catalog/area-visuals";
4+
import { AreaPill } from "@/components/admin/practice-catalog/AreaPill";
55
import { automatedReviewLimitationLabel } from "@/components/admin/practice-catalog/evidence-presentation";
66
import {
77
type CatalogEntryMoveActions,
@@ -26,7 +26,6 @@ import { Item, ItemContent, ItemDescription, ItemTitle } from "@/components/ui/i
2626
import { Spinner } from "@/components/ui/spinner";
2727
import { Switch } from "@/components/ui/switch";
2828
import { artifactKindLabel } from "@/lib/artifact-kinds";
29-
import { cn } from "@/lib/utils";
3029
import { CuratedEntryBadges } from "./CuratedEntryBadges";
3130

3231
type TreeArea = CuratedArea & { displayOrder: number; name: string };
@@ -139,19 +138,13 @@ export function CuratedCatalogTree({
139138
}
140139

141140
function AreaIcon({ area }: { area: TreeArea }) {
142-
const { Icon, pill } = getAreaVisual(
143-
area.slug,
144-
area.definition.name,
145-
area.definition.icon,
146-
area.definition.color,
147-
);
148141
return (
149-
<span
150-
className={cn("flex size-8 shrink-0 items-center justify-center rounded-md", pill)}
151-
aria-hidden
152-
>
153-
<Icon className="size-4" />
154-
</span>
142+
<AreaPill
143+
slug={area.slug}
144+
name={area.definition.name}
145+
icon={area.definition.icon}
146+
color={area.definition.color}
147+
/>
155148
);
156149
}
157150

webapp/src/components/admin/practice-adoption/AreaAdoptionPanel.stories.tsx

Lines changed: 53 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
mockPullRequestBinding,
88
mockPullRequestPolicy,
99
} from "@/mocks/fixtures/practice";
10+
import { withPageBehind } from "@/stories/decorators";
11+
import { Stateful } from "@/stories/stateful";
1012
import { expectGenuinelyDisabled } from "@/test/controls";
1113
import { expectSettledVisible } from "@/test/overlay";
12-
import { AreaAdoptionPanel } from "./AreaAdoptionPanel";
14+
import { AreaAdoptionPanel, type AreaAdoptionState } from "./AreaAdoptionPanel";
1315

1416
const practice = {
1517
slug: "describe-what-and-why",
@@ -59,28 +61,36 @@ const preview: CatalogAreaAdoptionPreview = {
5961
],
6062
};
6163

64+
const ready = (over: Partial<CatalogAreaAdoptionPreview> = {}, adding = false) =>
65+
({ status: "ready", preview: { ...preview, ...over }, adding }) as const;
66+
6267
/**
6368
* An area states each practice's outcome on the practice's own row, so the panel needs no prose
64-
* explaining which of four lists a name ended up in — and no embedded copy of the definition,
65-
* which opens as its own level on top instead.
69+
* explaining which of four lists a name ended up in — and no embedded copy of the definition, which
70+
* opens as its own level on top instead.
6671
*/
6772
const meta = {
6873
title: "Workspace admin/Practice adoption/Area panel",
6974
component: AreaAdoptionPanel,
70-
parameters: { layout: "fullscreen", chromatic: { viewports: [320, 1440] } },
75+
parameters: { layout: "fullscreen" },
76+
decorators: [withPageBehind],
7177
args: {
72-
preview,
73-
isLoading: false,
74-
isError: false,
75-
isPending: false,
76-
onRetry: fn(),
78+
state: ready(),
7779
onConfirm: fn(),
7880
onOpenPractice: fn(),
7981
},
82+
argTypes: {
83+
// A discriminated union renders as a free-text box, which cannot produce a valid value.
84+
state: { control: false },
85+
},
8086
render: (args) => (
81-
<DetailDrawerStack stack={[{ kind: "area", id: "review-ready-work" }]} onClose={() => {}}>
82-
{() => <AreaAdoptionPanel {...args} />}
83-
</DetailDrawerStack>
87+
<Stateful initial={[{ kind: "area", id: preview.slug }]}>
88+
{(stack, setStack) => (
89+
<DetailDrawerStack stack={stack} onClose={(depth) => setStack(stack.slice(0, depth))}>
90+
{(_entry, level) => <AreaAdoptionPanel {...args} nested={level.nested} />}
91+
</DetailDrawerStack>
92+
)}
93+
</Stateful>
8494
),
8595
tags: ["autodocs"],
8696
} satisfies Meta<typeof AreaAdoptionPanel>;
@@ -96,24 +106,29 @@ export const MixedOutcomes: Story = {
96106
await expect(screen.getByText("Adds")).toBeVisible();
97107
await expect(screen.getByText("Already here")).toBeVisible();
98108
await expect(screen.getByText("Blocked")).toBeVisible();
99-
await userEvent.click(
100-
screen.getByRole("button", { name: "Describe what changed and why, adds" }),
101-
);
109+
await userEvent.click(screen.getByRole("button", { name: /Describe what changed and why/ }));
102110
await expect(args.onOpenPractice).toHaveBeenCalledWith("describe-what-and-why");
103111
},
104112
};
105113

114+
export const DismissReturnsToThePage: Story = {
115+
play: async () => {
116+
await expectSettledVisible(await screen.findByRole("button", { name: "Add 1 practice" }));
117+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
118+
await expect(await screen.findByRole("heading", { name: "Practice setup" })).toBeVisible();
119+
},
120+
};
121+
106122
export const RestoreDeletedArea: Story = {
107123
args: {
108-
preview: {
109-
...preview,
124+
state: ready({
110125
disposition: "REUSE_EXISTING_AREA",
111126
actions: [
112127
{ slug: "describe-what-and-why", action: "MOVE_TO_AREA" },
113128
{ slug: "focused-changes", action: "MOVE_TO_AREA" },
114129
{ slug: "clear-context", action: "KEEP" },
115130
],
116-
},
131+
}),
117132
},
118133
play: async () => {
119134
// Nothing is created, so the action is a restore rather than an add.
@@ -124,51 +139,59 @@ export const RestoreDeletedArea: Story = {
124139

125140
export const NothingToChange: Story = {
126141
args: {
127-
preview: {
128-
...preview,
142+
state: ready({
129143
actions: preview.actions.map(({ slug }) => ({ slug, action: "KEEP" as const })),
130-
},
144+
}),
131145
},
132146
play: async () => {
133147
await expectGenuinelyDisabled(await screen.findByRole("button", { name: "Add 0 practices" }));
134148
},
135149
};
136150

137151
export const Loading: Story = {
138-
args: { preview: undefined, isLoading: true },
152+
args: { state: { status: "loading" } },
139153
play: async () => {
140154
await expectSettledVisible(await screen.findByText("Loading area preview"));
155+
// The header cannot invent an area colour for a slug that has not loaded.
156+
await expect(screen.getByRole("heading", { name: "Practice area" })).toBeVisible();
141157
},
142158
};
143159

144160
export const FailedToLoad: Story = {
145-
args: { preview: undefined, isLoading: false, isError: true, error: new Error("offline") },
161+
args: { state: { status: "error", error: new Error("offline"), onRetry: fn() } },
146162
play: async ({ args }) => {
147163
const retry = await screen.findByRole("button", { name: "Retry" });
148164
await expectSettledVisible(retry);
149165
await userEvent.click(retry);
150-
await expect(args.onRetry).toHaveBeenCalledOnce();
166+
await expect(
167+
(args.state as Extract<AreaAdoptionState, { status: "error" }>).onRetry,
168+
).toHaveBeenCalledOnce();
151169
},
152170
};
153171

154172
export const Adding: Story = {
155-
args: { isPending: true },
173+
args: { state: ready({}, true) },
156174
play: async () => {
157175
await expectGenuinelyDisabled(await screen.findByRole("button", { name: "Adding…" }));
158176
},
159177
};
160178

161-
export const LongContentInDarkMode: Story = {
179+
export const LongContent: Story = {
162180
args: {
163-
preview: {
164-
...preview,
181+
state: ready({
165182
definition: {
166183
name: "Decisions, documentation, and long-lived operational knowledge",
167184
description:
168185
"Practices covering how a team records the reasoning behind a change, keeps operational runbooks current, and makes the resulting knowledge findable long after the original authors have moved on.",
169186
},
170-
},
187+
}),
171188
},
172-
globals: { theme: "dark" },
189+
};
190+
191+
export const NarrowViewport: Story = {
173192
parameters: { viewport: { defaultViewport: "reflow" }, chromatic: { viewports: [320] } },
174193
};
194+
195+
export const DarkMode: Story = {
196+
globals: { theme: "dark" },
197+
};

0 commit comments

Comments
 (0)