diff --git a/app/create/types.ts b/app/create/types.ts
index 3f1e705..7315e1d 100644
--- a/app/create/types.ts
+++ b/app/create/types.ts
@@ -14,8 +14,7 @@ export type CreateFlowStep =
| "select"
| "upload"
| "review"
- | "compact-cards"
- | "expanded-cards"
+ | "cards"
| "right-rail"
| "final-review"
| "completed";
diff --git a/app/tailwind.css b/app/tailwind.css
index 7d7b680..84ce2d9 100644
--- a/app/tailwind.css
+++ b/app/tailwind.css
@@ -15,6 +15,36 @@
display: none; /* Safari and Chrome */
}
+/* Design system scrollbar (Figma node 20612-36521): dark track + thumb with states */
+.scrollbar-design {
+ scrollbar-width: thin; /* Firefox: narrow scrollbar */
+ scrollbar-color: #545B64 #292D32; /* Firefox: thumb track */
+}
+.scrollbar-design::-webkit-scrollbar {
+ width: 16px;
+ height: 16px;
+}
+.scrollbar-design::-webkit-scrollbar-track {
+ background: #292D32;
+}
+.scrollbar-design::-webkit-scrollbar-thumb {
+ background: #545B64;
+ border-radius: 4px;
+ border: 4px solid #292D32; /* visual padding: thumb appears 8px within 16px track */
+ background-clip: padding-box;
+}
+.scrollbar-design::-webkit-scrollbar-thumb:hover {
+ background: #787F8A;
+ border-width: 2px; /* hover: thumb expands to 12px */
+}
+.scrollbar-design::-webkit-scrollbar-thumb:active {
+ background: #3F434C;
+ border-width: 2px;
+}
+.scrollbar-design::-webkit-scrollbar-corner {
+ background: #292D32;
+}
+
@theme inline {
/* Custom breakpoints */
--breakpoint-xsm: 429px;
diff --git a/lib/propNormalization.ts b/lib/propNormalization.ts
index 8febae9..79ae1ce 100644
--- a/lib/propNormalization.ts
+++ b/lib/propNormalization.ts
@@ -502,6 +502,18 @@ export function normalizeLabelVariant(
return defaultValue;
}
+/**
+ * Normalize TextArea appearance prop (default/embedded; Figma: Default/Embedded).
+ */
+export function normalizeTextAreaAppearance(
+ value: string | undefined,
+ defaultValue: "default" = "default"
+): "default" | "embedded" {
+ if (!value) return defaultValue;
+ const n = value.toLowerCase();
+ return n === "embedded" ? "embedded" : "default";
+}
+
/**
* Normalize small/medium/large size prop values (for SelectInput, TextArea, etc.)
*/
diff --git a/messages/en/create/communication.json b/messages/en/create/communication.json
new file mode 100644
index 0000000..99a2976
--- /dev/null
+++ b/messages/en/create/communication.json
@@ -0,0 +1,82 @@
+{
+ "_comment": "Create flow – communication step: page, cards, and add-platform modals",
+ "page": {
+ "compactTitle": "How should this community communicate with each-other?",
+ "compactDescription": "You can select multiple methods for different needs or add your own",
+ "expandedTitle": "What method should this community use to communicate with eachother?",
+ "expandedDescription": "You can select multiple methods for different needs or add your own",
+ "seeAllLink": "See all communication approaches"
+ },
+ "confirmModal": {
+ "title": "Confirm selection",
+ "description": "Confirm to select this option.",
+ "nextButtonText": "Confirm"
+ },
+ "addPlatform": {
+ "nextButtonText": "Add Platform"
+ },
+ "sectionHeadings": {
+ "corePrinciple": "Core Principle & Scope",
+ "logisticsAdmin": "Logistics, Admin & Norms",
+ "codeOfConduct": "Code of Conduct"
+ },
+ "cards": {
+ "in-person-meetings": {
+ "label": "In-Person Meetings",
+ "supportText": "Physical gatherings for high-bandwidth communication and relationship building."
+ },
+ "signal": {
+ "label": "Signal",
+ "supportText": "Encrypted messaging for high-security, private coordination."
+ },
+ "video-meetings": {
+ "label": "Video Meetings",
+ "supportText": "Synchronous video calls for remote face-to-face interaction."
+ },
+ "4": {
+ "label": "Label",
+ "supportText": "Collaborative work to reach a resolution that all parties can agree upon."
+ },
+ "5": {
+ "label": "Label",
+ "supportText": "Structured sessions where parties collaboratively resolve disputes."
+ },
+ "6": {
+ "label": "Label",
+ "supportText": "Members vote to resolve a dispute democratically."
+ },
+ "7": {
+ "label": "Label",
+ "supportText": "Invite-only"
+ }
+ },
+ "modals": {
+ "in-person-meetings": {
+ "title": "In-Person Meetings",
+ "description": "Physical gatherings for high-bandwidth communication and relationship building.",
+ "sections": {
+ "corePrinciple": "We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.",
+ "logisticsAdmin": "Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.",
+ "codeOfConduct": "We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry."
+ }
+ },
+ "signal": {
+ "title": "Signal",
+ "description": "End-to-end encrypted messaging ideal for small, security-minded groups",
+ "sections": {
+ "corePrinciple": "We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.",
+ "logisticsAdmin": "We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use \"Emoji Reactions\" (👍, ♥️) to acknowledge messages rather than typing \"thanks,\" which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.",
+ "codeOfConduct": "This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse."
+ }
+ },
+ "video-meetings": {
+ "title": "Video Meetings",
+ "description": "Synchronous video calls for remote face-to-face interaction.",
+ "sections": {
+ "corePrinciple": "We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.",
+ "logisticsAdmin": "The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the \"Raise Hand\" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.",
+ "codeOfConduct": "We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. \"Zoom-bombing\" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk."
+ }
+ }
+ }
+}
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 0d102ce..3521430 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -15,6 +15,7 @@ import home from "./pages/home.json";
import learn from "./pages/learn.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
+import communication from "./create/communication.json";
export default {
common,
@@ -34,6 +35,9 @@ export default {
home,
learn,
},
+ create: {
+ communication,
+ },
navigation,
metadata,
};
diff --git a/stories/cards/Card.stories.js b/stories/cards/Card.stories.js
new file mode 100644
index 0000000..1c4af0a
--- /dev/null
+++ b/stories/cards/Card.stories.js
@@ -0,0 +1,165 @@
+import Card from "../../app/components/cards/Card";
+
+export default {
+ title: "Components/Cards/Card",
+ component: Card,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Create flow selection card with support text, recommended/selected states, and horizontal or vertical orientation. Use for communication approaches and similar choices.",
+ },
+ },
+ },
+ argTypes: {
+ label: {
+ control: { type: "text" },
+ description: "Primary label text",
+ },
+ supportText: {
+ control: { type: "text" },
+ description: "Supporting description below the label",
+ },
+ recommended: {
+ control: { type: "boolean" },
+ description: "Show yellow RECOMMENDED pill",
+ },
+ selected: {
+ control: { type: "boolean" },
+ description: "Show black SELECTED pill and dotted border",
+ },
+ orientation: {
+ control: { type: "select" },
+ options: ["horizontal", "vertical"],
+ description: "Layout orientation",
+ },
+ showInfoIcon: {
+ control: { type: "boolean" },
+ description: "Show info icon next to label (typically in vertical)",
+ },
+ onClick: { action: "clicked" },
+ },
+ tags: ["autodocs"],
+};
+
+export const Default = {
+ args: {
+ label: "Label",
+ supportText:
+ "Members vote to resolve a dispute democratically.",
+ recommended: true,
+ selected: false,
+ orientation: "horizontal",
+ showInfoIcon: false,
+ },
+};
+
+export const HorizontalRecommended = {
+ args: {
+ label: "Label",
+ supportText:
+ "Collaborative work to reach a resolution that all parties can agree upon.",
+ recommended: true,
+ selected: false,
+ orientation: "horizontal",
+ },
+};
+
+export const HorizontalSelected = {
+ args: {
+ label: "Label",
+ supportText:
+ "Members vote to resolve a dispute democratically.",
+ recommended: false,
+ selected: true,
+ orientation: "horizontal",
+ },
+};
+
+export const VerticalRecommended = {
+ args: {
+ label: "Label",
+ supportText: "Invite-only",
+ recommended: true,
+ selected: false,
+ orientation: "vertical",
+ showInfoIcon: true,
+ },
+};
+
+export const VerticalSelected = {
+ args: {
+ label: "Label",
+ supportText: "Invite-only",
+ recommended: false,
+ selected: true,
+ orientation: "vertical",
+ showInfoIcon: true,
+ },
+};
+
+export const AllVariants = {
+ render: () => (
+
+
+
+ Horizontal + Recommended
+
+
+
+
+
+ Horizontal + Selected
+
+
+
+
+
+ Vertical + Recommended
+
+
+
+
+
+ Vertical + Selected
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "All four variants: horizontal/vertical × recommended/selected.",
+ },
+ },
+ },
+};
diff --git a/stories/controls/TextArea.stories.js b/stories/controls/TextArea.stories.js
index b16fa32..b52b474 100644
--- a/stories/controls/TextArea.stories.js
+++ b/stories/controls/TextArea.stories.js
@@ -70,6 +70,16 @@ Large.args = {
value: "",
};
+export const Embedded = Template.bind({});
+Embedded.args = {
+ label: "Section content",
+ placeholder: "Enter text...",
+ value: "Embedded appearance used in create-flow modals: borderless, darker grey block.",
+ appearance: "embedded",
+ size: "large",
+ rows: 4,
+};
+
export const HorizontalLabel = Template.bind({});
HorizontalLabel.args = {
labelVariant: "horizontal",
diff --git a/stories/create-flow/CardStack.stories.js b/stories/create-flow/CardStack.stories.js
new file mode 100644
index 0000000..3b583aa
--- /dev/null
+++ b/stories/create-flow/CardStack.stories.js
@@ -0,0 +1,139 @@
+import CardStack from "../../app/components/utility/CardStack";
+
+const SAMPLE_CARDS = [
+ {
+ id: "1",
+ label: "Label",
+ supportText:
+ "Collaborative work to reach a resolution that all parties can agree upon.",
+ recommended: true,
+ },
+ {
+ id: "2",
+ label: "Label",
+ supportText:
+ "Structured sessions where parties collaboratively resolve disputes.",
+ recommended: true,
+ },
+ {
+ id: "3",
+ label: "Label",
+ supportText: "Members vote to resolve a dispute democratically.",
+ recommended: true,
+ },
+ {
+ id: "4",
+ label: "Label",
+ supportText: "Arbitrators are chosen specifically for a particular case.",
+ recommended: true,
+ },
+ {
+ id: "5",
+ label: "Label",
+ supportText:
+ "Encouraging direct, respectful dialogue between those involved.",
+ recommended: true,
+ },
+ {
+ id: "6",
+ label: "Label",
+ supportText: "Invite-only",
+ recommended: true,
+ },
+];
+
+export default {
+ title: "Create Flow/CardStack",
+ component: CardStack,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Card stack for the create flow: compact grid or expanded list with toggle. Uses Card components; toggle visible only when hasMore is true.",
+ },
+ },
+ },
+ argTypes: {
+ expanded: {
+ control: { type: "boolean" },
+ description: "Expanded (list) vs compact (grid) mode",
+ },
+ hasMore: {
+ control: { type: "boolean" },
+ description: "Whether to show the See all / Show less toggle",
+ },
+ },
+ tags: ["autodocs"],
+};
+
+export const Default = {
+ args: {
+ cards: SAMPLE_CARDS,
+ hasMore: true,
+ title: "How should this community communicate with each-other?",
+ description:
+ "You can select multiple methods for different needs or add your own",
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Compact grid with sample cards and See all toggle.",
+ },
+ },
+ },
+};
+
+export const Expanded = {
+ args: {
+ cards: SAMPLE_CARDS,
+ expanded: true,
+ hasMore: true,
+ title:
+ "What method should this community use to communicate with eachother?",
+ description:
+ "You can select multiple methods for different needs or add your own",
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Expanded list layout with vertical cards and Show less toggle.",
+ },
+ },
+ },
+};
+
+export const WithSelection = {
+ args: {
+ cards: SAMPLE_CARDS,
+ selectedId: "2",
+ hasMore: true,
+ title: "How should this community communicate with each-other?",
+ description:
+ "You can select multiple methods for different needs or add your own",
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Second card is selected; click cards to change selection.",
+ },
+ },
+ },
+};
+
+export const NoToggle = {
+ args: {
+ cards: SAMPLE_CARDS.slice(0, 3),
+ hasMore: false,
+ title: "How should this community communicate with each-other?",
+ description:
+ "You can select multiple methods for different needs or add your own",
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "When hasMore is false, the See all toggle is hidden.",
+ },
+ },
+ },
+};
diff --git a/stories/modals/Create.stories.js b/stories/modals/Create.stories.js
index 3777099..8b1a039 100644
--- a/stories/modals/Create.stories.js
+++ b/stories/modals/Create.stories.js
@@ -132,6 +132,22 @@ Step3.args = {
totalSteps: 3,
};
+export const WithCustomHeader = Template.bind({});
+WithCustomHeader.args = {
+ isOpen: true,
+ headerContent:
Custom header
,
+ children: (
+
+
+ When headerContent is provided, the default title and description are not shown.
+
+
+ ),
+ showBackButton: false,
+ showNextButton: true,
+ nextButtonText: "Continue",
+};
+
export const WithoutFooter = Template.bind({});
WithoutFooter.args = {
isOpen: true,
diff --git a/stories/utility/Scrollbar.stories.js b/stories/utility/Scrollbar.stories.js
new file mode 100644
index 0000000..8fe55fe
--- /dev/null
+++ b/stories/utility/Scrollbar.stories.js
@@ -0,0 +1,95 @@
+import Scrollbar from "../../app/components/utility/Scrollbar";
+
+const tallContent = (
+
+
Line 1
+
Line 2
+
Line 3
+
Line 4
+
Line 5
+
Line 6
+
Line 7
+
Line 8
+
Line 9
+
Line 10
+
+);
+
+const wideContent = (
+
+ Item A
+ Item B
+ Item C
+ Item D
+ Item E
+
+);
+
+export default {
+ title: "Components/Utility/Scrollbar",
+ component: Scrollbar,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "A scrollable container that applies the design system scrollbar styling. Supports vertical, horizontal, or both overflow.",
+ },
+ },
+ },
+ argTypes: {
+ orientation: {
+ control: { type: "select" },
+ options: ["vertical", "horizontal", "both"],
+ description: "Scroll direction",
+ },
+ },
+};
+
+export const Default = {
+ args: {
+ children: tallContent,
+ orientation: "vertical",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export const Horizontal = {
+ args: {
+ children: wideContent,
+ orientation: "horizontal",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export const Both = {
+ args: {
+ children: (
+
+
+ Scroll both directions. Content is larger than the container.
+
+
+ ),
+ orientation: "both",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
diff --git a/stories/utility/Tag.stories.js b/stories/utility/Tag.stories.js
new file mode 100644
index 0000000..d8e59e2
--- /dev/null
+++ b/stories/utility/Tag.stories.js
@@ -0,0 +1,45 @@
+import Tag from "../../app/components/utility/Tag";
+
+export default {
+ title: "Components/Utility/Tag",
+ component: Tag,
+ parameters: {
+ layout: "centered",
+ docs: {
+ description: {
+ component:
+ "Small status tag with recommended (yellow) or selected (dark) variant. Default labels are RECOMMENDED and SELECTED; pass children for custom text.",
+ },
+ },
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["recommended", "selected"],
+ description: "Visual variant",
+ },
+ children: {
+ control: { type: "text" },
+ description: "Custom label (omit to use default RECOMMENDED/SELECTED)",
+ },
+ },
+};
+
+export const Recommended = {
+ args: {
+ variant: "recommended",
+ },
+};
+
+export const Selected = {
+ args: {
+ variant: "selected",
+ },
+};
+
+export const CustomLabel = {
+ args: {
+ variant: "recommended",
+ children: "Custom label",
+ },
+};
diff --git a/tests/components/Create.test.tsx b/tests/components/Create.test.tsx
index 6841a96..3dcad37 100644
--- a/tests/components/Create.test.tsx
+++ b/tests/components/Create.test.tsx
@@ -133,6 +133,20 @@ describe("Create", () => {
expect(stepper).toHaveAttribute("aria-valuemax", "5");
});
+ it("renders custom header when headerContent is provided", () => {
+ renderWithProviders(
+
Custom header}
+ />,
+ );
+ expect(screen.getByText("Custom header")).toBeInTheDocument();
+ expect(screen.queryByText("Default Title")).not.toBeInTheDocument();
+ expect(screen.queryByText("Default description")).not.toBeInTheDocument();
+ });
+
it("renders custom footer content", () => {
renderWithProviders(
;
@@ -28,3 +32,14 @@ componentTestSuite({
errorProps: { error: true },
},
});
+
+describe("TextArea appearance", () => {
+ it("renders with appearance embedded and applies borderless styling", () => {
+ renderWithProviders(
+ ,
+ );
+ const textarea = screen.getByRole("textbox", { name: /notes/i });
+ expect(textarea).toBeInTheDocument();
+ expect(textarea).toHaveClass("border-0");
+ });
+});
diff --git a/tests/pages/cards.test.jsx b/tests/pages/cards.test.jsx
new file mode 100644
index 0000000..995dbd9
--- /dev/null
+++ b/tests/pages/cards.test.jsx
@@ -0,0 +1,68 @@
+import {
+ renderWithProviders as render,
+ screen,
+ cleanup,
+ within,
+} from "../utils/test-utils";
+import userEvent from "@testing-library/user-event";
+import { describe, test, expect, afterEach } from "vitest";
+import CardsPage from "../../app/create/cards/page";
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("Create flow cards page", () => {
+ test("clicking a card opens the Create modal", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const signalCards = screen.getAllByRole("button", {
+ name: /Signal: Encrypted messaging/,
+ });
+ await user.click(signalCards[0]);
+
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toBeInTheDocument();
+ expect(within(dialog).getByText("Signal")).toBeInTheDocument();
+ expect(within(dialog).getByText("Add Platform")).toBeInTheDocument();
+ });
+
+ test("renders without error", () => {
+ render();
+
+ expect(
+ screen.getByText("How should this community communicate with each-other?"),
+ ).toBeInTheDocument();
+ });
+
+ test("renders HeaderLockup and CardStack content", () => {
+ render();
+
+ expect(
+ screen.getByText(
+ "You can select multiple methods for different needs or add your own",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "See all communication approaches" }),
+ ).toBeInTheDocument();
+ });
+
+ test("toggle expands and shows Show less", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const toggle = screen.getByRole("button", {
+ name: "See all communication approaches",
+ });
+ await user.click(toggle);
+
+ expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "What method should this community use to communicate with eachother?",
+ ),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/tests/unit/Card.test.jsx b/tests/unit/Card.test.jsx
new file mode 100644
index 0000000..ed7fb30
--- /dev/null
+++ b/tests/unit/Card.test.jsx
@@ -0,0 +1,105 @@
+import {
+ renderWithProviders as render,
+ screen,
+ fireEvent,
+} from "../utils/test-utils";
+import { describe, it, expect, vi } from "vitest";
+import Card from "../../app/components/cards/Card";
+
+describe("Card Component", () => {
+ const defaultProps = {
+ label: "Label",
+ supportText: "Support text here",
+ orientation: "horizontal",
+ };
+
+ it("renders label and supportText", () => {
+ render();
+
+ expect(screen.getByText("Label")).toBeInTheDocument();
+ expect(screen.getByText("Support text here")).toBeInTheDocument();
+ });
+
+ it("renders RECOMMENDED pill when recommended is true", () => {
+ render();
+
+ expect(screen.getByText("RECOMMENDED")).toBeInTheDocument();
+ });
+
+ it("does not render RECOMMENDED pill when recommended is false", () => {
+ render();
+
+ expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
+ });
+
+ it("renders SELECTED pill and inset dashed outline when selected is true", () => {
+ render();
+
+ expect(screen.getByText("SELECTED")).toBeInTheDocument();
+ const card = screen.getByRole("button");
+ expect(card).toHaveClass("outline-dashed");
+ });
+
+ it("applies horizontal layout by default", () => {
+ render();
+
+ expect(screen.getByText("Label")).toBeInTheDocument();
+ expect(screen.getByText("Support text here")).toBeInTheDocument();
+ });
+
+ it("applies vertical layout when orientation is vertical", () => {
+ render();
+
+ const card = screen.getByRole("button");
+ expect(card).toHaveClass("flex-row");
+ });
+
+ it("handles click events", () => {
+ const handleClick = vi.fn();
+ render();
+
+ const card = screen.getByRole("button");
+ fireEvent.click(card);
+
+ expect(handleClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("handles keyboard events", () => {
+ const handleClick = vi.fn();
+ render();
+
+ const card = screen.getByRole("button");
+
+ fireEvent.keyDown(card, { key: "Enter" });
+ expect(handleClick).toHaveBeenCalledTimes(1);
+
+ fireEvent.keyDown(card, { key: " " });
+ expect(handleClick).toHaveBeenCalledTimes(2);
+ });
+
+ it("renders with custom className", () => {
+ const customClass = "custom-card";
+ render();
+
+ const card = screen.getByRole("button");
+ expect(card).toHaveClass(customClass);
+ });
+
+ it("renders with proper accessibility attributes", () => {
+ render();
+
+ const card = screen.getByRole("button");
+ expect(card).toHaveAttribute(
+ "aria-label",
+ "Label: Support text here",
+ );
+ expect(card).toHaveAttribute("tabIndex", "0");
+ });
+
+ it("renders without supportText", () => {
+ render();
+
+ expect(screen.getByText("Label only")).toBeInTheDocument();
+ expect(screen.getByRole("button")).toHaveAttribute("aria-label", "Label only");
+ });
+});
diff --git a/tests/unit/CardStack.test.jsx b/tests/unit/CardStack.test.jsx
new file mode 100644
index 0000000..f992f53
--- /dev/null
+++ b/tests/unit/CardStack.test.jsx
@@ -0,0 +1,101 @@
+import {
+ renderWithProviders as render,
+ screen,
+ cleanup,
+ fireEvent,
+} from "../utils/test-utils";
+import userEvent from "@testing-library/user-event";
+import { vi, describe, test, expect, afterEach } from "vitest";
+import CardStack from "../../app/components/utility/CardStack";
+
+const SAMPLE_CARDS = [
+ { id: "1", label: "Option A", supportText: "Description A", recommended: true },
+ { id: "2", label: "Option B", supportText: "Description B", recommended: false },
+ { id: "3", label: "Option C", supportText: "Description C", recommended: true },
+];
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("CardStack Component", () => {
+ test("renders header when title is provided", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("How should this community communicate?"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Pick one or more.")).toBeInTheDocument();
+ });
+
+ test("renders up to 5 recommended cards in compact (grid) mode", () => {
+ render();
+
+ expect(screen.getAllByText("Option A").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("Option C").length).toBeGreaterThanOrEqual(1);
+ expect(screen.queryByText("Option B")).not.toBeInTheDocument();
+ });
+
+ test("renders all cards in expanded (list) mode", () => {
+ render();
+
+ expect(screen.getByText("Option A")).toBeInTheDocument();
+ expect(screen.getByText("Option B")).toBeInTheDocument();
+ expect(screen.getByText("Option C")).toBeInTheDocument();
+ });
+
+ test("shows See all toggle when hasMore is true", () => {
+ render();
+
+ expect(
+ screen.getByRole("button", { name: "See all communication approaches" }),
+ ).toBeInTheDocument();
+ });
+
+ test("does not show toggle when hasMore is false", () => {
+ render();
+
+ expect(
+ screen.queryByRole("button", { name: "See all communication approaches" }),
+ ).not.toBeInTheDocument();
+ });
+
+ test("toggle expands when clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ const toggle = screen.getByRole("button", {
+ name: "See all communication approaches",
+ });
+ await user.click(toggle);
+
+ expect(
+ screen.getByRole("button", { name: "Show less" }),
+ ).toBeInTheDocument();
+ });
+
+ test("calls onCardSelect when a card is clicked", () => {
+ const onCardSelect = vi.fn();
+ render(
+ ,
+ );
+
+ const cardButtons = screen.getAllByRole("button", {
+ name: "Option A: Description A",
+ });
+ fireEvent.click(cardButtons[0]);
+ expect(onCardSelect).toHaveBeenCalledWith("1");
+ });
+
+ test("renders with selectedId", () => {
+ render();
+
+ expect(screen.getAllByText("SELECTED").length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/tests/unit/Scrollbar.test.jsx b/tests/unit/Scrollbar.test.jsx
new file mode 100644
index 0000000..719444d
--- /dev/null
+++ b/tests/unit/Scrollbar.test.jsx
@@ -0,0 +1,49 @@
+import { describe, test, expect } from "vitest";
+import { screen } from "@testing-library/react";
+import "@testing-library/jest-dom/vitest";
+import { renderWithProviders } from "../utils/test-utils";
+import Scrollbar from "../../app/components/utility/Scrollbar";
+
+describe("Scrollbar", () => {
+ test("renders children", () => {
+ renderWithProviders(
+
+ Scrollable content
+ ,
+ );
+ expect(screen.getByText("Scrollable content")).toBeInTheDocument();
+ });
+
+ test("wrapper has scrollbar-design class and overflow-y-auto for default orientation", () => {
+ const { container } = renderWithProviders(
+
+ Content
+ ,
+ );
+ const wrapper = container.firstChild;
+ expect(wrapper).toHaveClass("scrollbar-design");
+ expect(wrapper).toHaveClass("overflow-y-auto");
+ });
+
+ test("applies horizontal overflow when orientation is horizontal", () => {
+ const { container } = renderWithProviders(
+
+ Content
+ ,
+ );
+ const wrapper = container.firstChild;
+ expect(wrapper).toHaveClass("scrollbar-design");
+ expect(wrapper).toHaveClass("overflow-x-auto");
+ });
+
+ test("applies overflow-auto when orientation is both", () => {
+ const { container } = renderWithProviders(
+
+ Content
+ ,
+ );
+ const wrapper = container.firstChild;
+ expect(wrapper).toHaveClass("scrollbar-design");
+ expect(wrapper).toHaveClass("overflow-auto");
+ });
+});
diff --git a/tests/unit/Tag.test.jsx b/tests/unit/Tag.test.jsx
new file mode 100644
index 0000000..f33e1c8
--- /dev/null
+++ b/tests/unit/Tag.test.jsx
@@ -0,0 +1,23 @@
+import { describe, test, expect } from "vitest";
+import { screen } from "@testing-library/react";
+import "@testing-library/jest-dom/vitest";
+import { renderWithProviders } from "../utils/test-utils";
+import Tag from "../../app/components/utility/Tag";
+
+describe("Tag", () => {
+ test("renders with variant recommended and shows default label RECOMMENDED", () => {
+ renderWithProviders();
+ expect(screen.getByText("RECOMMENDED")).toBeInTheDocument();
+ });
+
+ test("renders with variant selected and shows default label SELECTED", () => {
+ renderWithProviders();
+ expect(screen.getByText("SELECTED")).toBeInTheDocument();
+ });
+
+ test("renders custom children when provided", () => {
+ renderWithProviders(Custom label);
+ expect(screen.getByText("Custom label")).toBeInTheDocument();
+ expect(screen.queryByText("RECOMMENDED")).not.toBeInTheDocument();
+ });
+});