)}
diff --git a/app/components/type/ContentLockup/ContentLockup.container.tsx b/app/components/type/ContentLockup/ContentLockup.container.tsx
index 9fbe198..9a2d21e 100644
--- a/app/components/type/ContentLockup/ContentLockup.container.tsx
+++ b/app/components/type/ContentLockup/ContentLockup.container.tsx
@@ -10,6 +10,7 @@ const ContentLockupContainer = memo(
subtitle,
description,
ctaText,
+ ctaHref,
buttonClassName = "",
variant: variantProp = "hero",
linkText,
@@ -166,6 +167,7 @@ const ContentLockupContainer = memo(
subtitle={subtitle}
description={description}
ctaText={ctaText}
+ ctaHref={ctaHref}
buttonClassName={buttonClassName}
variant={variant}
linkText={linkText}
diff --git a/app/components/type/ContentLockup/ContentLockup.view.tsx b/app/components/type/ContentLockup/ContentLockup.view.tsx
index 0a6ed33..614fd33 100644
--- a/app/components/type/ContentLockup/ContentLockup.view.tsx
+++ b/app/components/type/ContentLockup/ContentLockup.view.tsx
@@ -10,6 +10,7 @@ function ContentLockupView({
subtitle,
description,
ctaText,
+ ctaHref,
buttonClassName,
variant,
linkText,
@@ -111,6 +112,7 @@ function ContentLockupView({
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
size="small"
+ href={ctaHref}
>
{ctaText}
@@ -122,6 +124,7 @@ function ContentLockupView({
palette={variant === "hero" ? "default" : "inverse"}
size="large"
className={buttonClassName}
+ href={ctaHref}
>
{ctaText}
@@ -132,6 +135,7 @@ function ContentLockupView({
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
size="xlarge"
+ href={ctaHref}
>
{ctaText}
diff --git a/lib/assetUtils.ts b/lib/assetUtils.ts
index ba7391d..7140d8b 100644
--- a/lib/assetUtils.ts
+++ b/lib/assetUtils.ts
@@ -49,6 +49,25 @@ export function quoteStatementShapePath(): string {
return "assets/shapes/shape-qoute.svg";
}
+/**
+ * How-it-works body ornaments (`public/assets/shapes/how-shape-*.svg`,
+ * Figma **22078:791901**).
+ */
+export function howItWorksOrnamentRightPath(): string {
+ return "assets/shapes/how-shape-2.svg";
+}
+
+export function howItWorksOrnamentLeftPath(): string {
+ return "assets/shapes/how-shape-1.svg";
+}
+
+/**
+ * Guide ContentBanner logo mark (Figma **22078:806960**).
+ */
+export function guideBannerLogoArrowPath(): string {
+ return "assets/shapes/guide-banner-logo-arrow.svg";
+}
+
/**
* Asset paths for common components
*/
diff --git a/lib/howItWorksSyntheticPost.ts b/lib/howItWorksSyntheticPost.ts
new file mode 100644
index 0000000..43208a3
--- /dev/null
+++ b/lib/howItWorksSyntheticPost.ts
@@ -0,0 +1,31 @@
+import type { BlogPost } from "./content";
+import { markdownToHtml } from "./content";
+import type howItWorksPage from "../messages/en/pages/howItWorks.json";
+
+export const HOW_IT_WORKS_SENTINEL_SLUG = "__how-it-works__";
+
+type HowItWorksMessages = typeof howItWorksPage;
+
+/**
+ * Builds a {@link BlogPost}-shaped object for static marketing pages that reuse
+ * blog article chrome (`ContentBanner`, `.post-body`) without a markdown file.
+ */
+export function buildHowItWorksSyntheticPost(
+ page: HowItWorksMessages,
+): BlogPost {
+ const { banner, bodyMarkdown } = page;
+
+ return {
+ slug: HOW_IT_WORKS_SENTINEL_SLUG,
+ frontmatter: {
+ title: banner.title,
+ description: banner.description,
+ author: banner.author,
+ date: banner.date,
+ },
+ content: bodyMarkdown,
+ htmlContent: markdownToHtml(bodyMarkdown),
+ filePath: "messages/en/pages/howItWorks.json",
+ lastModified: new Date(banner.date),
+ };
+}
diff --git a/messages/en/components/cardSteps.json b/messages/en/components/cardSteps.json
index a5c2508..b3eaaec 100644
--- a/messages/en/components/cardSteps.json
+++ b/messages/en/components/cardSteps.json
@@ -2,6 +2,7 @@
"_comment": "CardSteps section defaults (shared across pages)",
"titleLg": "How CommunityRule helps",
"buttons": {
- "seeHowItWorks": "See how it works"
+ "seeHowItWorks": "See how it works",
+ "seeHowItWorksHref": "/how-it-works"
}
}
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 31bcbf0..46c14ea 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -17,6 +17,7 @@ import templates from "./pages/templates.json";
import learn from "./pages/learn.json";
import about from "./pages/about.json";
import useCases from "./pages/useCases.json";
+import howItWorks from "./pages/howItWorks.json";
import monitor from "./pages/monitor.json";
import login from "./pages/login.json";
import profile from "./pages/profile.json";
@@ -80,6 +81,7 @@ export default {
learn,
about,
useCases,
+ howItWorks,
monitor,
login,
profile,
diff --git a/messages/en/metadata.json b/messages/en/metadata.json
index 9869294..2c5eaa3 100644
--- a/messages/en/metadata.json
+++ b/messages/en/metadata.json
@@ -19,5 +19,15 @@
"community governance",
"operating manual"
]
+ },
+ "howItWorks": {
+ "title": "A Guide to CommunityRule — CommunityRule",
+ "description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
+ "keywords": [
+ "how it works",
+ "community governance",
+ "operating manual",
+ "decision-making"
+ ]
}
}
diff --git a/messages/en/pages/home.json b/messages/en/pages/home.json
index 5a60d6c..6799e59 100644
--- a/messages/en/pages/home.json
+++ b/messages/en/pages/home.json
@@ -5,7 +5,7 @@
"subtitle": "with clarity",
"description": "Help your community make important decisions in a way that reflects its unique values.",
"ctaText": "Learn how CommunityRule works",
- "ctaHref": "#"
+ "ctaHref": "/how-it-works"
},
"cardSteps": {
"title": "How CommunityRule works",
diff --git a/messages/en/pages/howItWorks.json b/messages/en/pages/howItWorks.json
new file mode 100644
index 0000000..c975286
--- /dev/null
+++ b/messages/en/pages/howItWorks.json
@@ -0,0 +1,12 @@
+{
+ "banner": {
+ "title": "A Guide to CommunityRule",
+ "description": "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
+ "author": "CommunityRule",
+ "date": "2026-01-15"
+ },
+ "relatedArticles": {
+ "title": "Related articles"
+ },
+ "bodyMarkdown": "Whether you organize a mutual aid network, manage an open-source project, or run a worker-owned cooperative, unwritten rules are a liability. Implicit structures cause volunteer burnout, endless meetings, and shadow hierarchies where the loudest voices quietly hold the most power.\n\nCommunityRule makes the implicit explicit. It is a modular governance toolkit providing a library of proven patterns that you can mix, match, and export to keep your community safe, equitable, and highly functional.\n\n**How the Platform Works: From Chaos to Clarity**\n\nThe CommunityRule experience is a guided drafting process that takes you from a blank page to a fully functioning governance document.\n\nYou begin by selecting an organizational archetype that matches your mission. The tool then recommends a baseline set of governance patterns. For instance, a mutual aid group receives recommendations prioritized for rapid physical safety, while an open-source project sees patterns optimized for asynchronous code review and protecting maintainer bandwidth.\n\nNext, you customize. You are never locked into a template. You can explore the pattern library to swap out tools, adjust how much authority is granted to different roles, and define your specific safety protocols.\n\nFinally, you export your completed Operating Manual. The platform generates a clean, structured document that you can host on your website, embed in your GitHub repository, or print as a welcome packet for new members. Your group now has a single source of truth.\n\n**The Four Pillars of Governance**\n\nTo build a resilient Operating Manual, you configure settings across four load-bearing pillars. When these are clearly defined, your members are freed from procedural debates and can focus entirely on your shared mission.\n\n**Membership Patterns**\n\nThis pillar defines the boundaries of your community: who is allowed inside, how they join, and what is required to maintain standing.\n\nYou select patterns that align onboarding with your culture. A massive online network might choose an \"Open Access\" pattern to maximize growth, while an activist coalition handling sensitive work might implement \"Exclusionary Vouching,\" requiring new entrants to be personally verified by existing members. Just as importantly, this pillar helps you define clear exit paths. By establishing how members can safely take a leave of absence or transition to an alumni role, you protect the health of the individual and prevent vital institutional knowledge from vanishing when someone burns out.\n\n**Decision-Making Patterns**\n\nThis acts as your organizational engine, defining exactly how proposals turn into executed actions without trapping your group in endless deliberation.\n\nThe key is matching the weight of the decision to the right process. Requiring everyone to vote on daily operations paralyzes a group, but allowing one person to unilaterally change core values destroys trust. You solve this by assigning different patterns to different scopes of work. You might use a \"Do-ocracy\" pattern for administrative tasks, empowering whoever is actually doing the work to make the call without asking permission. For strategic choices, you might implement a \"Supermajority\" pattern, ensuring significant changes have overwhelming buy-in before enactment. This distribution of authority lets your group move fast on the small things and move together on the big things.\n\n**Conflict Management**\n\nConflict is inevitable; toxicity and organizational collapse are preventable. This pillar helps you design repair pathways before a dispute escalates into a crisis.\n\nHealthy communities rely on repair and accountability rather than corporate punishment. You select conflict strategies based on how much external authority is required to solve a problem. For interpersonal friction, \"Restorative Practices\" or \"Peer Mediation\" provide a structured environment and a neutral facilitator, but leave the final resolution to the disputing parties so they co-create their own repair plan. For severe Code of Conduct violations, a \"Conflict Resolution Council\" introduces trusted elders who investigate and issue formal recommendations. Having these patterns pre-selected removes panic and bias from the resolution process.\n\n**Communication Methods**\n\nWhere you talk dictates how you govern. This pillar helps you map your digital and physical infrastructure to ensure you use the right tool for the right conversation.\n\nMixing social banter, secure logistics, and formal voting in one chaotic group chat is a recipe for disaster. You will define the logistics and behavioral norms for each platform your group uses. You might designate Signal strictly for tactical coordination where disappearing messages are mandatory, use Discord for casual onboarding, and reserve a forum like Discourse exclusively for deep, asynchronous policy deliberation.\n\nCrucially, you attach a specific Code of Conduct to each platform to separate minor etiquette from actual harm prevention. Your Code of Conduct establishes zero-tolerance policies for racism, sexism, and bigotry. It explicitly prohibits doxxing, physical intimidation, and the sharing of content that attracts unwanted legal scrutiny, ensuring your communication spaces remain secure and mission-focused."
+}
diff --git a/public/assets/shapes/guide-banner-logo-arrow.svg b/public/assets/shapes/guide-banner-logo-arrow.svg
new file mode 100644
index 0000000..54a26cf
--- /dev/null
+++ b/public/assets/shapes/guide-banner-logo-arrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/assets/shapes/how-shape-1.svg b/public/assets/shapes/how-shape-1.svg
new file mode 100644
index 0000000..0a68535
--- /dev/null
+++ b/public/assets/shapes/how-shape-1.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/assets/shapes/how-shape-2.svg b/public/assets/shapes/how-shape-2.svg
new file mode 100644
index 0000000..f516def
--- /dev/null
+++ b/public/assets/shapes/how-shape-2.svg
@@ -0,0 +1,9 @@
+
diff --git a/stories/sections/ContentBanner.stories.js b/stories/sections/ContentBanner.stories.js
index 4b92b8d..62952d3 100644
--- a/stories/sections/ContentBanner.stories.js
+++ b/stories/sections/ContentBanner.stories.js
@@ -1,3 +1,4 @@
+import React from "react";
import ContentBanner from "../../app/components/sections/ContentBanner";
const mockBlogPost = {
@@ -20,6 +21,21 @@ const mockBlogPost = {
"
This is the main content of the sample article.
It has multiple paragraphs.
",
};
+const guidePost = {
+ slug: "__how-it-works__",
+ frontmatter: {
+ title: "A Guide to CommunityRule",
+ description:
+ "CommunityRule is a modular governance toolkit designed to help democratic groups build, customize, and publish their own Operating Manual.",
+ author: "CommunityRule",
+ date: "2026-01-15",
+ },
+ content: "",
+ htmlContent: "",
+ filePath: "messages/en/pages/howItWorks.json",
+ lastModified: new Date("2026-01-15"),
+};
+
export default {
title: "Components/Sections/ContentBanner",
component: ContentBanner,
@@ -27,24 +43,44 @@ export default {
docs: {
description: {
component:
- "The ContentBanner component displays the header information for blog articles, including title, description, author, and date.\n\nImages: sm uses thumbnail.horizontal; md+ uses banner.horizontal when provided, otherwise falls back to thumbnail.horizontal; final fallback is assets/Content_Banner_2.svg.\n\nNote: page background colors are applied at the blog page level using a hex color from frontmatter (background.color), not inside this component. Thumbnail and banner images should be uploaded via the content pipeline to public/content/blog/ and referenced in frontmatter.",
+ "Section / ContentBanner — `article` variant for blog posts (thumbnail/banner imagery, icon, author, date); `guide` variant for static content pages (left: title + description, right: logo mark — Figma 22078:791901 + 22078:806960).",
},
},
+ layout: "fullscreen",
},
argTypes: {
post: {
control: "object",
description: "Blog post object with frontmatter and content",
},
+ variant: {
+ control: "select",
+ options: ["article", "guide"],
+ },
},
};
-export const Default = {
+export const Article = {
args: {
post: mockBlogPost,
+ variant: "article",
},
};
+export const Guide = {
+ args: {
+ post: guidePost,
+ variant: "guide",
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
export const NoBannerFallbackToThumbnail = {
args: {
post: {
diff --git a/tests/components/ContentBanner.test.tsx b/tests/components/ContentBanner.test.tsx
index c4c7321..d1d1907 100644
--- a/tests/components/ContentBanner.test.tsx
+++ b/tests/components/ContentBanner.test.tsx
@@ -60,4 +60,26 @@ describe("ContentBanner", () => {
render();
expect(screen.getByText("Test description")).toBeInTheDocument();
});
+
+ it("renders guide variant with left-aligned copy and logo mark", () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { name: "Test Article" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Test description")).toBeInTheDocument();
+ expect(screen.queryByText("Test Author")).not.toBeInTheDocument();
+
+ const bannerRow = container.querySelector('[data-node-id="19189:9358"]');
+ expect(bannerRow).toHaveClass("md:flex-row");
+
+ const logoMark = container.querySelector(
+ '[data-node-id="22078:806960"] img',
+ );
+ expect(logoMark).toHaveAttribute(
+ "src",
+ expect.stringContaining("guide-banner-logo-arrow.svg"),
+ );
+ });
});
diff --git a/tests/e2e/critical-journeys.spec.ts b/tests/e2e/critical-journeys.spec.ts
index 5e2d58b..73a349f 100644
--- a/tests/e2e/critical-journeys.spec.ts
+++ b/tests/e2e/critical-journeys.spec.ts
@@ -15,11 +15,13 @@ test.describe("Critical User Journeys", () => {
).toBeVisible();
// 3. User clicks CTA to learn more
- const learnButton = page
- .locator('button:has-text("Learn how CommunityRule works")')
+ const learnCta = page
+ .getByRole("link", { name: /Learn how CommunityRule works/i })
+ .or(page.getByRole("button", { name: /Learn how CommunityRule works/i }))
.first();
- if ((await learnButton.count()) > 0 && (await learnButton.isVisible())) {
- await learnButton.click();
+ if ((await learnCta.count()) > 0 && (await learnCta.isVisible())) {
+ await learnCta.click();
+ await expect(page).toHaveURL(/\/how-it-works/);
}
// 4. User scrolls to CardSteps section (home)
diff --git a/tests/e2e/edge-cases.spec.ts b/tests/e2e/edge-cases.spec.ts
index 7708aa3..2e30098 100644
--- a/tests/e2e/edge-cases.spec.ts
+++ b/tests/e2e/edge-cases.spec.ts
@@ -61,27 +61,28 @@ test.describe("Edge Cases and Error Scenarios", () => {
// Page should continue to function
await expect(page.locator("text=Collaborate")).toBeVisible();
- const learnButtons = page.locator(
- 'button:has-text("Learn how CommunityRule works")',
- );
- const buttonCount = await learnButtons.count();
- let visibleButton = null;
+ const learnCta = page
+ .getByRole("link", { name: /Learn how CommunityRule works/i })
+ .or(page.getByRole("button", { name: /Learn how CommunityRule works/i }));
+ const ctaCount = await learnCta.count();
+ let visibleCta = null;
- for (let i = 0; i < buttonCount; i++) {
- const button = learnButtons.nth(i);
- if (await button.isVisible()) {
- visibleButton = button;
+ for (let i = 0; i < ctaCount; i++) {
+ const cta = learnCta.nth(i);
+ if (await cta.isVisible()) {
+ visibleCta = cta;
break;
}
}
- if (!visibleButton) {
+ if (!visibleCta) {
throw new Error(
- 'No visible "Learn how CommunityRule works" button found',
+ 'No visible "Learn how CommunityRule works" CTA found',
);
}
- await visibleButton.click();
+ await visibleCta.click();
+ await expect(page).toHaveURL(/\/how-it-works/);
});
test("handles missing images gracefully", async ({ page }) => {
@@ -97,26 +98,27 @@ test.describe("Edge Cases and Error Scenarios", () => {
// Page should still function without images
await expect(page.locator("text=Collaborate")).toBeVisible();
- const learnButtons = page.locator(
- 'button:has-text("Learn how CommunityRule works")',
- );
- const buttonCount = await learnButtons.count();
- let visibleButton = null;
+ const learnCta = page
+ .getByRole("link", { name: /Learn how CommunityRule works/i })
+ .or(page.getByRole("button", { name: /Learn how CommunityRule works/i }));
+ const ctaCount = await learnCta.count();
+ let visibleCta = null;
- for (let i = 0; i < buttonCount; i++) {
- const button = learnButtons.nth(i);
- if (await button.isVisible()) {
- visibleButton = button;
+ for (let i = 0; i < ctaCount; i++) {
+ const cta = learnCta.nth(i);
+ if (await cta.isVisible()) {
+ visibleCta = cta;
break;
}
}
- if (!visibleButton) {
+ if (!visibleCta) {
throw new Error(
- 'No visible "Learn how CommunityRule works" button found',
+ 'No visible "Learn how CommunityRule works" CTA found',
);
}
- await visibleButton.click();
+ await visibleCta.click();
+ await expect(page).toHaveURL(/\/how-it-works/);
});
});
diff --git a/tests/pages/home.test.jsx b/tests/pages/home.test.jsx
index 0aae429..76ccad6 100644
--- a/tests/pages/home.test.jsx
+++ b/tests/pages/home.test.jsx
@@ -68,6 +68,10 @@ describe("Page", () => {
expect(
screen.getAllByText("Learn how CommunityRule works").length,
).toBeGreaterThan(0);
+ const learnLinks = screen.getAllByRole("link", {
+ name: "Learn how CommunityRule works",
+ });
+ expect(learnLinks[0]).toHaveAttribute("href", "/how-it-works");
});
test("renders CardSteps section with correct data", async () => {
diff --git a/tests/pages/how-it-works.test.jsx b/tests/pages/how-it-works.test.jsx
new file mode 100644
index 0000000..03cddf5
--- /dev/null
+++ b/tests/pages/how-it-works.test.jsx
@@ -0,0 +1,66 @@
+import { describe, test, expect, vi } from "vitest";
+import { screen, waitFor } from "@testing-library/react";
+import { renderWithProviders as render } from "../utils/test-utils";
+import HowItWorksPage from "../../app/(marketing)/how-it-works/page";
+import messages from "../../messages/en/index";
+
+vi.mock("next/dynamic", () => ({
+ default: (importFn) => {
+ const Component = vi.fn(() => (
+ Related articles
+ ));
+ return Component;
+ },
+}));
+
+vi.mock("../../app/components/sections/ContentBanner", () => ({
+ default: ({ post, variant }) => (
+
+