From 9ea3f9fd0a69b960fddc1ee372f8539b16fad578 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:28:04 -0700 Subject: [PATCH 1/2] Move the use-case index onto Learn so the pilot stories stay reachable, and make share images resolve on the host that serves the page. Co-authored-by: Cursor --- app/(marketing)/learn/page.tsx | 198 ++++++++++++++-- app/(marketing)/use-cases/[slug]/page.tsx | 11 +- app/(marketing)/use-cases/page.tsx | 213 +----------------- .../ContentContainer.container.tsx | 3 + .../ContentContainer.types.ts | 3 + .../ContentContainer.view.tsx | 19 +- .../ContentThumbnailTemplate.container.tsx | 8 + .../ContentThumbnailTemplate.types.ts | 12 + .../ContentThumbnailTemplate.view.tsx | 68 ++++-- .../sections/Accordion/Accordion.types.ts | 2 + .../sections/Accordion/Accordion.view.tsx | 3 + app/layout.tsx | 140 ++++++------ lib/publicSitemap.ts | 1 - lib/siteMetadata.ts | 27 ++- messages/en/metadata.json | 53 ++++- messages/en/pages/learn.json | 140 +++++++++++- messages/en/pages/useCases.json | 4 +- messages/en/pages/useCasesCompletedRules.json | 14 +- messages/en/pages/useCasesDetail.json | 10 +- next.config.mjs | 5 + .../how-communityrule-works-horizontal.svg | 3 + .../blog/how-communityrule-works-vertical.svg | 3 + tests/pages/learn.test.tsx | 58 ++++- tests/pages/use-cases-completed-rule.test.jsx | 8 + tests/pages/use-cases.test.jsx | 34 +-- tests/unit/publicSitemap.test.ts | 2 +- tests/unit/siteMetadata.test.ts | 33 ++- 27 files changed, 656 insertions(+), 419 deletions(-) create mode 100644 public/content/blog/how-communityrule-works-horizontal.svg create mode 100644 public/content/blog/how-communityrule-works-vertical.svg diff --git a/app/(marketing)/learn/page.tsx b/app/(marketing)/learn/page.tsx index 770b2c7..04a63dd 100644 --- a/app/(marketing)/learn/page.tsx +++ b/app/(marketing)/learn/page.tsx @@ -1,59 +1,207 @@ import type { Metadata } from "next"; +import Link from "next/link"; import messages from "../../../messages/en/index"; -import { getTranslation } from "../../../lib/i18n/getTranslation"; +import { getAllBlogPosts, type BlogPost } from "../../../lib/content"; import { routeMetadata } from "../../../lib/siteMetadata"; -import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; +import PageHeader from "../../components/type/PageHeader"; +import CaseStudy from "../../components/cards/CaseStudy"; +import UseCasesOrgs from "../../components/sections/UseCasesOrgs"; +import Groups from "../../components/sections/Groups"; +import type { GroupsItem } from "../../components/sections/Groups"; +import TripleStep from "../../components/type/TripleStep"; import ContentLockup from "../../components/type/ContentLockup"; +import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; +import TripleTextBlock from "../../components/type/TripleTextBlock"; +import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock"; +import FaqAccordion from "../../components/sections/Accordion"; import AskOrganizer from "../../components/sections/AskOrganizer"; -import { getAllBlogPosts } from "../../../lib/content"; +import WorkerCoopMark from "../../../public/assets/vector/worker-coop.svg"; +import MutualAidMark from "../../../public/assets/vector/mutual-aid.svg"; +import OpenSourceMark from "../../../public/assets/vector/open-source.svg"; +import DaoMark from "../../../public/assets/vector/dao.svg"; + +/** + * Figma: "Learn" (22083:854797). Use-cases index sections, the guides, and + * the FAQ share this page. Pilot stories stay at /use-cases/[slug]. + */ export const metadata: Metadata = routeMetadata("/learn", { title: messages.metadata.learn.title, + description: messages.metadata.learn.description, }); +const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]"; + +const CASE_STUDY_LINK_CLASS = [ + CASE_STUDY_TILE_RADIUS_CLASS, + "block shrink-0 cursor-pointer outline-none transition-[opacity,transform] duration-200", + "hover:opacity-95", + "focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]", + "active:scale-[0.98]", +].join(" "); + +const GROUP_MARKS = [ + WorkerCoopMark, + MutualAidMark, + OpenSourceMark, + DaoMark, +] as const; + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? value : []; +} + export default function LearnPage() { - const allPosts = getAllBlogPosts(); + const page = messages.pages.learn; + const summaries = page.articleSummaries as Record; - const t = (key: string) => getTranslation(messages, key); + const groupItems: GroupsItem[] = page.groups.items.map((item, index) => { + const Mark = GROUP_MARKS[index] ?? GROUP_MARKS[0]; + return { + ...item, + icon: ( + + ), + }; + }); - const contentLockupData = { - title: t("pages.learn.contentLockup.title"), - subtitle: t("pages.learn.contentLockup.subtitle"), - variant: "learn" as const, - alignment: "left" as const, + const postsBySlug = new Map( + getAllBlogPosts().map((post) => [post.slug, post]), + ); + const guidePost: BlogPost = { + slug: "how-communityrule-works", + frontmatter: { + title: page.guideCard.title, + description: page.guideCard.summary, + author: "", + date: "2026-11-01", + thumbnail: { + vertical: "how-communityrule-works-vertical.svg", + horizontal: "how-communityrule-works-horizontal.svg", + }, + }, + content: "", + htmlContent: "", + filePath: "messages/en/pages/learn.json#guideCard", + lastModified: new Date("2026-11-01"), }; + const articlePosts = page.articleOrder.flatMap((slug) => { + const post = postsBySlug.get(slug); + if (!post) return []; + const summary = summaries[slug]; + if (!summary || summary === post.frontmatter.description) return [post]; + return [ + { + ...post, + frontmatter: { ...post.frontmatter, description: summary }, + }, + ]; + }); - const askOrganizerData = { - title: t("pages.learn.askOrganizer.title"), - subtitle: t("pages.learn.askOrganizer.subtitle"), - description: t("pages.learn.askOrganizer.description"), - buttonText: t("pages.learn.askOrganizer.buttonText"), - variant: "centered" as const, - }; + const caseStudyLinks = page.caseStudyTiles.links; + const caseStudySurfaces = ["lavender", "neutral", "rose"] as const; + const caseStudyAlts = [ + page.caseStudyTiles.mutualAidColoradoAlt, + page.caseStudyTiles.foodNotBombsAlt, + page.caseStudyTiles.boulderStreetMedicsAlt, + ]; return (
- + + + + {caseStudySurfaces.map((surface, index) => { + const link = caseStudyLinks[index]; + const card = ( + + ); + if (!link?.href) { + return
{card}
; + } + return ( + + {card} + + ); + })} +
+ + + + (page.tripleStep.steps)} + ctaText={page.tripleStep.ctaText} + ctaHref={page.tripleStep.ctaHref} + /> + + - {/* - * Single responsive render: ContentThumbnailTemplate variant="responsive" - * uses to swap horizontal/vertical art at smd (530px). Wrap and - * center leftover cards so a short last row is not left-aligned. - */}
- {allPosts.map((post) => ( + + {articlePosts.map((post) => ( ))}
- + (page.tripleTextBlock.columns)} + /> + + + +
+ +
); } diff --git a/app/(marketing)/use-cases/[slug]/page.tsx b/app/(marketing)/use-cases/[slug]/page.tsx index 645f81f..7233bca 100644 --- a/app/(marketing)/use-cases/[slug]/page.tsx +++ b/app/(marketing)/use-cases/[slug]/page.tsx @@ -26,7 +26,9 @@ export function generateStaticParams() { return USE_CASE_DETAIL_SLUGS.map((slug) => ({ slug })); } -export async function generateMetadata({ params }: PageProps): Promise { +export async function generateMetadata({ + params, +}: PageProps): Promise { const { slug } = await params; if (!isUseCaseDetailSlug(slug)) { return {}; @@ -59,7 +61,8 @@ export default async function UseCaseDetailPage({ params }: PageProps) { const syntheticPost = buildUseCaseSyntheticPost(slug, detail); const { ruleCard, askOrganizer } = entry; - const askVariant = (askOrganizer.variant ?? "use-case-detail") as AskOrganizerVariant; + const askVariant = (askOrganizer.variant ?? + "use-case-detail") as AskOrganizerVariant; const structuredData = { "@context": "https://schema.org", @@ -87,8 +90,8 @@ export default async function UseCaseDetailPage({ params }: PageProps) { { "@type": "ListItem", position: 2, - name: "Use cases", - item: "https://communityrule.com/use-cases", + name: "Learn", + item: "https://communityrule.com/learn", }, { "@type": "ListItem", diff --git a/app/(marketing)/use-cases/page.tsx b/app/(marketing)/use-cases/page.tsx index 397f5f5..6f84cab 100644 --- a/app/(marketing)/use-cases/page.tsx +++ b/app/(marketing)/use-cases/page.tsx @@ -1,211 +1,6 @@ -import type { Metadata } from "next"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { Suspense } from "react"; -import messages from "../../../messages/en/index"; -import { CONTENT_CATALOG_SLUG_ORDER } from "../../../lib/assetUtils"; -import { getAllBlogPosts, getRelatedBlogPosts } from "../../../lib/content"; -import { routeMetadata } from "../../../lib/siteMetadata"; -import PageHeader from "../../components/type/PageHeader"; -import CaseStudy from "../../components/cards/CaseStudy"; -import UseCasesOrgs from "../../components/sections/UseCasesOrgs"; -import QuoteBlock from "../../components/sections/QuoteBlock"; -import Groups from "../../components/sections/Groups"; -import type { GroupsItem } from "../../components/sections/Groups"; -import TripleStep from "../../components/type/TripleStep"; -import TripleTextBlock from "../../components/type/TripleTextBlock"; -import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock"; -import AskOrganizer from "../../components/sections/AskOrganizer"; -import { MarketingRuleStackSection } from "../_components/MarketingRuleStackSection"; -import WorkerCoopMark from "../../../public/assets/vector/worker-coop.svg"; -import MutualAidMark from "../../../public/assets/vector/mutual-aid.svg"; -import OpenSourceMark from "../../../public/assets/vector/open-source.svg"; -import DaoMark from "../../../public/assets/vector/dao.svg"; +import { redirect } from "next/navigation"; -const RelatedArticles = dynamic( - () => import("../../components/sections/RelatedArticles"), - { - loading: () => ( -
- ), - ssr: true, - }, -); - -function asArray(value: unknown): T[] { - return Array.isArray(value) ? value : []; -} - -const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]"; - -const CASE_STUDY_LINK_CLASS = [ - CASE_STUDY_TILE_RADIUS_CLASS, - "block shrink-0 cursor-pointer outline-none transition-[opacity,transform] duration-200", - "hover:opacity-95", - "focus-visible:ring-2 focus-visible:ring-[var(--color-border-default-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]", - "active:scale-[0.98]", -].join(" "); - -/** Matches `pages.useCases.groups.items` order ↔ inlined vector mark components. */ -const USE_CASES_GROUP_MARKS = [ - WorkerCoopMark, - MutualAidMark, - OpenSourceMark, - DaoMark, -] as const; - -const USE_CASES_RELATED_SENTINEL_SLUG = "__use-cases-page__"; - -export async function generateMetadata(): Promise { - const title = messages.metadata.useCases.title; - const description = messages.metadata.useCases.description; - const keywords = messages.metadata.useCases.keywords; - - return routeMetadata("/use-cases", { - title, - description, - keywords, - openGraph: { - title, - description, - type: "website", - siteName: messages.metadata.siteName, - }, - }); -} - -export default function UseCasesPage() { - const page = messages.pages.useCases; - - const tripleColumns = asArray(page.tripleTextBlock.columns); - const groupItemsRaw = asArray<{ title: string; description: string }>( - page.groups.items, - ); - - const groupItems: GroupsItem[] = groupItemsRaw.map((item, index) => { - const Mark = USE_CASES_GROUP_MARKS[index] ?? USE_CASES_GROUP_MARKS[0]; - return { - ...item, - icon: ( - - ), - }; - }); - - const askOrganizerData = { - title: page.askOrganizer.title, - subtitle: page.askOrganizer.subtitle, - buttonText: page.askOrganizer.buttonText, - }; - - const allPosts = getAllBlogPosts(); - const relatedPosts = getRelatedBlogPosts( - USE_CASES_RELATED_SENTINEL_SLUG, - CONTENT_CATALOG_SLUG_ORDER.slice(0, 3), - ); - const slugOrder = allPosts.map((p) => p.slug); - - const tripleStepSteps = asArray<{ title: string; body: string }>( - page.tripleStep.steps, - ); - - const caseStudyLinks = asArray<{ href: string; ariaLabel: string }>( - page.caseStudyTiles.links, - ); - const caseStudySurfaces = ["lavender", "neutral", "rose"] as const; - const caseStudyAlts = [ - page.caseStudyTiles.mutualAidColoradoAlt, - page.caseStudyTiles.foodNotBombsAlt, - page.caseStudyTiles.boulderCountyStreetMedicsAlt, - ]; - - return ( -
- - - - {caseStudySurfaces.map((surface, index) => { - const link = caseStudyLinks[index]; - const card = ( - - ); - - if (!link?.href) { - return
{card}
; - } - - return ( - - {card} - - ); - })} -
- - - - - - - -
- - } - > - - -
- - - -
- -
- -
- -
-
- ); +/** The use-cases index lives on Learn. Pilot pages stay at /use-cases/[slug]. */ +export default function UseCasesPage(): never { + redirect("/learn"); } diff --git a/app/components/content/ContentContainer/ContentContainer.container.tsx b/app/components/content/ContentContainer/ContentContainer.container.tsx index 3a8d415..a5d36f0 100644 --- a/app/components/content/ContentContainer/ContentContainer.container.tsx +++ b/app/components/content/ContentContainer/ContentContainer.container.tsx @@ -22,10 +22,12 @@ const ContentContainerContainer = memo( leadingImageSrc, leadingImageAlt, showLeadingImage: showLeadingImageProp = true, + showByline: showBylineProp = true, }) => { const size = sizeProp; const tone = toneProp; const showLeadingImage = showLeadingImageProp; + const showByline = showBylineProp; const width = widthProp ?? (size === "useCase" ? "100%" : "200px"); const onLight = tone === "onLight"; @@ -119,6 +121,7 @@ const ContentContainerContainer = memo( iconImage={iconImage} iconAlt={iconAlt} showLeadingImage={showLeadingImage} + showByline={showByline} containerClasses={containerClasses} contentGapClasses={contentGapClasses} textGapClasses={textGapClasses} diff --git a/app/components/content/ContentContainer/ContentContainer.types.ts b/app/components/content/ContentContainer/ContentContainer.types.ts index 1f27f0e..d740e8b 100644 --- a/app/components/content/ContentContainer/ContentContainer.types.ts +++ b/app/components/content/ContentContainer/ContentContainer.types.ts @@ -22,6 +22,8 @@ export interface ContentContainerProps { leadingImageAlt?: string; /** When false, omits the icon row above the title. Default true. */ showLeadingImage?: boolean; + /** When false, omits author and date. Default true. */ + showByline?: boolean; } export interface ContentContainerViewProps { @@ -32,6 +34,7 @@ export interface ContentContainerViewProps { iconImage: string; iconAlt: string; showLeadingImage: boolean; + showByline: boolean; containerClasses: string; contentGapClasses: string; textGapClasses: string; diff --git a/app/components/content/ContentContainer/ContentContainer.view.tsx b/app/components/content/ContentContainer/ContentContainer.view.tsx index 835a83f..d043517 100644 --- a/app/components/content/ContentContainer/ContentContainer.view.tsx +++ b/app/components/content/ContentContainer/ContentContainer.view.tsx @@ -8,6 +8,7 @@ function ContentContainerView({ iconImage, iconAlt, showLeadingImage, + showByline, containerClasses, contentGapClasses, textGapClasses, @@ -18,10 +19,7 @@ function ContentContainerView({ formattedDate, }: ContentContainerViewProps) { return ( -
+
{/* Content Container - gap between icon and text */}
{showLeadingImage ? ( @@ -45,13 +43,12 @@ function ContentContainerView({
-
- {/* Author Name */} - {post.frontmatter.author} - - {/* Date */} - {formattedDate} -
+ {showByline ? ( +
+ {post.frontmatter.author} + {formattedDate} +
+ ) : null}
); } diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx index f99057b..0391318 100644 --- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx +++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx @@ -20,6 +20,10 @@ const ContentThumbnailTemplateContainer = memo( className = "", variant: variantProp = "vertical", sizing: sizingProp = "fluid", + href, + showByline = true, + showLeadingImage = true, + leadingImageAlt, }) => { const variant = variantProp; const sizing = sizingProp; @@ -70,6 +74,10 @@ const ContentThumbnailTemplateContainer = memo( sizing={sizing} backgroundImage={backgroundImage} backgroundImageSmd={backgroundImageSmd} + href={href ?? `/blog/${post.slug}`} + showByline={showByline} + showLeadingImage={showLeadingImage} + leadingImageAlt={leadingImageAlt} /> ); }, diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts index af871b2..b98b52c 100644 --- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts +++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts @@ -20,6 +20,14 @@ export interface ContentThumbnailTemplateProps { */ sizing?: ContentThumbnailTemplateSizingValue; slugOrder?: string[]; + /** Overrides the default `/blog/[slug]` destination. */ + href?: string; + /** Author and date. Learn hides these until byline dates are approved. */ + showByline?: boolean; + /** Tag mark above the title. Decorative on catalog cards. */ + showLeadingImage?: boolean; + /** Alt for the tag mark. Empty when the mark repeats the title. */ + leadingImageAlt?: string; } export interface ContentThumbnailTemplateViewProps { @@ -30,4 +38,8 @@ export interface ContentThumbnailTemplateViewProps { backgroundImage: string; /** Wide-viewport image source for variant="responsive" (≥smd). */ backgroundImageSmd?: string; + href: string; + showByline: boolean; + showLeadingImage: boolean; + leadingImageAlt?: string; } diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx index 664feb9..866574c 100644 --- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx +++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx @@ -10,15 +10,16 @@ function ContentThumbnailTemplateView({ sizing, backgroundImage, backgroundImageSmd, + href, + showByline, + showLeadingImage, + leadingImageAlt, }: ContentThumbnailTemplateViewProps) { if (variant === "responsive") { // Single card; swaps the orientation-specific image at smd // (530px), aspect-ratio and content positioning switch via Tailwind. return ( - +
@@ -37,7 +38,13 @@ function ContentThumbnailTemplateView({
- +
@@ -47,10 +54,7 @@ function ContentThumbnailTemplateView({ if (variant === "vertical") { if (sizing === "fixed") { return ( - +
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -61,7 +65,14 @@ function ContentThumbnailTemplateView({ />
- +
@@ -69,10 +80,7 @@ function ContentThumbnailTemplateView({ } return ( - +
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -83,7 +91,13 @@ function ContentThumbnailTemplateView({ />
- +
@@ -92,7 +106,7 @@ function ContentThumbnailTemplateView({ if (sizing === "fixed") { return ( - +
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -103,7 +117,14 @@ function ContentThumbnailTemplateView({ />
- +
@@ -111,10 +132,7 @@ function ContentThumbnailTemplateView({ } return ( - +
{/* eslint-disable-next-line @next/next/no-img-element */} @@ -125,7 +143,13 @@ function ContentThumbnailTemplateView({ />
- +
diff --git a/app/components/sections/Accordion/Accordion.types.ts b/app/components/sections/Accordion/Accordion.types.ts index 9f9db9d..b1c9ceb 100644 --- a/app/components/sections/Accordion/Accordion.types.ts +++ b/app/components/sections/Accordion/Accordion.types.ts @@ -9,6 +9,8 @@ export interface FaqAccordionItem { export interface FaqAccordionProps { title: string; items: FaqAccordionItem[]; + /** In-page target, such as the Learn FAQ. */ + id?: string; size?: AccordionSizeValue; /** Layout accordion size from `lg` (default **m**, Figma 22135-890258). */ lgSize?: AccordionSizeValue; diff --git a/app/components/sections/Accordion/Accordion.view.tsx b/app/components/sections/Accordion/Accordion.view.tsx index c70800e..21426c5 100644 --- a/app/components/sections/Accordion/Accordion.view.tsx +++ b/app/components/sections/Accordion/Accordion.view.tsx @@ -17,10 +17,12 @@ function FaqAccordionView({ lgSize, xlSize, headingId, + id, className = "", }: FaqAccordionViewProps) { return (
@@ -40,6 +42,7 @@ function FaqAccordionView({ size={size} lgSize={lgSize} xlSize={xlSize} + defaultOpen={index === 0} > {item.answer} diff --git a/app/layout.tsx b/app/layout.tsx index 7fdbd2d..bd07cb3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,13 @@ import { Inter, Bricolage_Grotesque } from "next/font/google"; import type { Metadata, Viewport } from "next"; import type { ReactNode } from "react"; +import { headers } from "next/headers"; import messages from "../messages/en/index"; import { ASSETS, getAssetPath } from "../lib/assetUtils"; import { defaultShareImages, - metadataBaseUrl, + originFromHost, + resolveRequestHost, } from "../lib/siteMetadata"; import "./globals.css"; @@ -46,72 +48,80 @@ export const viewport: Viewport = { initialScale: 1, }; -export const metadata: Metadata = { - title: { - default: homeMeta.title, - template: messages.metadata.titleTemplate, - }, - description: homeMeta.description, - keywords: [...homeMeta.keywords], - authors: [{ name: "Media Economies Design Lab" }], - creator: "Media Economies Design Lab", - publisher: "Media Economies Design Lab", - formatDetection: { - email: false, - address: false, - telephone: false, - }, - metadataBase: metadataBaseUrl(), - icons: { - icon: [ - { url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" }, - { url: "/favicon.ico", sizes: "any" }, - { - url: "/favicon-32x32.png", - sizes: "32x32", - type: "image/png", - }, - { - url: "/favicon-16x16.png", - sizes: "16x16", - type: "image/png", - }, - ], - apple: [ - { - url: "/apple-touch-icon.png", - sizes: "180x180", - type: "image/png", - }, - ], - }, - openGraph: { - title: homeMeta.title, +export async function generateMetadata(): Promise { + const headerStore = await headers(); + const host = resolveRequestHost( + headerStore.get("x-forwarded-host"), + headerStore.get("host"), + ); + + return { + title: { + default: homeMeta.title, + template: messages.metadata.titleTemplate, + }, description: homeMeta.description, - url: "/", - siteName: messages.metadata.siteName, - locale: "en_US", - type: "website", - images: defaultShareImages(), - }, - twitter: { - card: "summary_large_image", - title: homeMeta.title, - description: homeMeta.description, - images: defaultShareImages().map((image) => image.url), - }, - robots: { - index: true, - follow: true, - googleBot: { + keywords: [...homeMeta.keywords], + authors: [{ name: "Media Economies Design Lab" }], + creator: "Media Economies Design Lab", + publisher: "Media Economies Design Lab", + formatDetection: { + email: false, + address: false, + telephone: false, + }, + metadataBase: new URL(originFromHost(host)), + icons: { + icon: [ + { url: getAssetPath(ASSETS.LOGO), type: "image/svg+xml" }, + { url: "/favicon.ico", sizes: "any" }, + { + url: "/favicon-32x32.png", + sizes: "32x32", + type: "image/png", + }, + { + url: "/favicon-16x16.png", + sizes: "16x16", + type: "image/png", + }, + ], + apple: [ + { + url: "/apple-touch-icon.png", + sizes: "180x180", + type: "image/png", + }, + ], + }, + openGraph: { + title: homeMeta.title, + description: homeMeta.description, + url: "/", + siteName: messages.metadata.siteName, + locale: "en_US", + type: "website", + images: defaultShareImages(), + }, + twitter: { + card: "summary_large_image", + title: homeMeta.title, + description: homeMeta.description, + images: defaultShareImages().map((image) => image.url), + }, + robots: { index: true, follow: true, - "max-video-preview": -1, - "max-image-preview": "large", - "max-snippet": -1, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, }, - }, -}; + }; +} export default function RootLayout({ children }: { children: ReactNode }) { // Load messages for the default locale (single locale setup) @@ -138,9 +148,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { type="image/png" /> - +
{children}
diff --git a/lib/publicSitemap.ts b/lib/publicSitemap.ts index 38436a6..a88ac6a 100644 --- a/lib/publicSitemap.ts +++ b/lib/publicSitemap.ts @@ -10,7 +10,6 @@ const STATIC_PUBLIC_PATHS = [ "/learn", "/templates", "/blog", - "/use-cases", "/how-it-works", "/privacy", "/terms", diff --git a/lib/siteMetadata.ts b/lib/siteMetadata.ts index 46ec32c..29220b1 100644 --- a/lib/siteMetadata.ts +++ b/lib/siteMetadata.ts @@ -153,9 +153,7 @@ export function articleShareImages( slug: string, alt: string, ): ShareImage[] | undefined { - if ( - !(CONTENT_CATALOG_SLUG_ORDER as readonly string[]).includes(slug) - ) { + if (!(CONTENT_CATALOG_SLUG_ORDER as readonly string[]).includes(slug)) { return undefined; } return [shareImage(contentBlogOgPath(slug), alt)]; @@ -241,15 +239,30 @@ export function routeMetadata( }; } +/** + * Link unfurlers. Staging stays out of search indexes, and these agents can + * still fetch the page so a shared link can show its image. + */ +const UNFURL_USER_AGENTS = [ + "Twitterbot", + "facebookexternalhit", + "Slackbot", + "Discordbot", + "LinkedInBot", + "TelegramBot", + "WhatsApp", + "Bluesky", +] as const; + export function buildRobots( host: string | null | undefined, ): MetadataRoute.Robots { if (!isIndexableHost(host)) { return { - rules: { - userAgent: "*", - disallow: "/", - }, + rules: [ + { userAgent: "*", disallow: "/" }, + { userAgent: [...UNFURL_USER_AGENTS], allow: "/" }, + ], }; } diff --git a/messages/en/metadata.json b/messages/en/metadata.json index 8c1200b..c80fdb4 100644 --- a/messages/en/metadata.json +++ b/messages/en/metadata.json @@ -28,7 +28,8 @@ "title": "Workshop inquiries" }, "learn": { - "title": "Learn" + "title": "Learn", + "description": "See how groups use CommunityRule, who it is for, and practical guides for organizing together." }, "templates": { "title": "Templates" @@ -69,34 +70,64 @@ "mutualAidColorado": { "title": "Mutual Aid Colorado", "description": "How Mutual Aid Colorado used CommunityRule to clarify resource sharing, volunteer coordination, and decision-making.", - "keywords": ["mutual aid", "use case", "community governance", "operating manual"] + "keywords": [ + "mutual aid", + "use case", + "community governance", + "operating manual" + ] }, "foodNotBombs": { "title": "Food Not Bombs Boulder", "description": "How Food Not Bombs Boulder used CommunityRule to translate implicit organizing norms into explicit democratic processes.", - "keywords": ["food not bombs", "use case", "community governance", "operating manual"] + "keywords": [ + "food not bombs", + "use case", + "community governance", + "operating manual" + ] }, "boulderCountyStreetMedics": { - "title": "Boulder County Street Medics", - "description": "How CommunityRule helped Boulder County Street Medics define operational process on the streets and off.", - "keywords": ["street medics", "use case", "community governance", "operating manual"] + "title": "Boulder Street Medics", + "description": "How CommunityRule helped Boulder Street Medics define operational process on the streets and off.", + "keywords": [ + "street medics", + "use case", + "community governance", + "operating manual" + ] } }, "useCasesCompletedRule": { "mutualAidColorado": { "title": "Mutual Aid Colorado community rule", "description": "Read the completed community rule Mutual Aid Colorado built with CommunityRule.", - "keywords": ["mutual aid", "community rule", "operating manual", "use case"] + "keywords": [ + "mutual aid", + "community rule", + "operating manual", + "use case" + ] }, "foodNotBombs": { "title": "Food Not Bombs Boulder community rule", "description": "Read the completed community rule Food Not Bombs Boulder built with CommunityRule.", - "keywords": ["food not bombs", "community rule", "operating manual", "use case"] + "keywords": [ + "food not bombs", + "community rule", + "operating manual", + "use case" + ] }, "boulderCountyStreetMedics": { - "title": "Boulder County Street Medics community rule", - "description": "Read the completed community rule Boulder County Street Medics built with CommunityRule.", - "keywords": ["street medics", "community rule", "operating manual", "use case"] + "title": "Boulder Street Medics community rule", + "description": "Read the completed community rule Boulder Street Medics built with CommunityRule.", + "keywords": [ + "street medics", + "community rule", + "operating manual", + "use case" + ] } }, "howItWorks": { diff --git a/messages/en/pages/learn.json b/messages/en/pages/learn.json index a721bc3..6bf8c7d 100644 --- a/messages/en/pages/learn.json +++ b/messages/en/pages/learn.json @@ -1,13 +1,147 @@ { - "_comment": "Learn page specific content", + "_comment": "Learn page. The use-cases index redirects here. Pilot pages stay at /use-cases/[slug]. Guide and article bylines stay off until their dates are approved.", + "pageHeader": { + "title": ["See how groups use", "CommunityRule"] + }, + "caseStudyTiles": { + "mutualAidColoradoAlt": "Mutual Aid Colorado logo", + "foodNotBombsAlt": "Food Not Bombs logo", + "boulderStreetMedicsAlt": "Boulder Street Medics logo", + "links": [ + { + "href": "/use-cases/mutual-aid-colorado", + "ariaLabel": "View Mutual Aid Colorado use case" + }, + { + "href": "/use-cases/food-not-bombs", + "ariaLabel": "View Food Not Bombs use case" + }, + { + "href": "/use-cases/boulder-county-street-medics", + "ariaLabel": "View Boulder Street Medics use case" + } + ] + }, + "groups": { + "title": "Who is this for?", + "items": [ + { + "title": "Worker cooperatives", + "description": "Employee-owned businesses often need to clarify how power is shared, decisions are made and how processes operate within their organizations." + }, + { + "title": "Mutual aid communities", + "description": "Mutual aid communities must define how resources are shared, decisions are made and volunteer coordination operates within their organizations." + }, + { + "title": "Open source projects", + "description": "Agree on how contributions are managed, technical decisions are made and community participation operates within their development communities." + }, + { + "title": "DAOs", + "description": "Document token-based voting process, proposal patterns and how community governance operates within their blockchain ecosystems." + } + ] + }, + "tripleStep": { + "heading": "Get recommendations that will make organizing easier", + "ctaText": "Create rule", + "ctaHref": "/create/informational", + "steps": [ + { + "title": "Get your community together", + "body": "If you're just getting started, you might begin with shared values, a decision-making plan and a conflict management process. Keeping it simple ensures your Rule remains practical rather than overwhelming." + }, + { + "title": "Define how your community should operate", + "body": "Involving everyone in shaping these agreements through group discussions, workshops or a tool like CommunityRule can help build collective buy-in." + }, + { + "title": "Have a durable impact", + "body": "Consider treating guidelines as a living document that evolves with your community's needs. A little structure early on helps keep your community effective and sustainable. CommunityRule makes it easy to track and share these changes as your community evolves." + } + ] + }, "contentLockup": { "title": "Organizing is hard", - "subtitle": "Find answers to your questions and see how other groups have solved similar challenges." + "subtitle": "Find answers to your questions and see how other communities have solved similar challenges.", + "ctaText": "Read the guide", + "ctaHref": "/how-it-works" + }, + "guideCard": { + "title": "When your community sits down to write its Rule", + "summary": "Sketch your Rule in CommunityRule, then make it your community's own over five short meetings.", + "href": "/how-it-works" + }, + "articleOrder": [ + "resolving-active-conflicts", + "operational-security-mutual-aid", + "making-decisions-without-hierarchy", + "integrating-new-members-without-dilution", + "avoiding-burnout-sustainability-in-the-ruins", + "how-chaos-concentrates-control", + "digital-mediation-and-the-death-of-nuance", + "knowledge-management-and-institutional-amnesia" + ], + "articleSummaries": { + "resolving-active-conflicts": "How a conflict gets managed, starting with two people in a room", + "operational-security-mutual-aid": "Keeping your community's tools safe starts with who holds the keys", + "making-decisions-without-hierarchy": "No bosses still means deciding who decides", + "integrating-new-members-without-dilution": "Growing without watering down what your community is for", + "avoiding-burnout-sustainability-in-the-ruins": "When the same few people carry everything", + "how-chaos-concentrates-control": "With no leaders, somebody still holds the keys", + "digital-mediation-and-the-death-of-nuance": "What corporate platforms cost a community and what to use instead", + "knowledge-management-and-institutional-amnesia": "Keeping what your community knows when people move on" + }, + "tripleTextBlock": { + "title": "Why horizontal communities need CommunityRule", + "ctaText": "Set up your community", + "ctaHref": "/create/informational", + "columns": [ + { + "title": "Share leadership and prevent burnout", + "description": "Rotating roles and setting clear expectations for contributions can prevent a few people from taking on too much. A living document outlining responsibilities and community values helps distribute labor fairly and avoids misunderstandings.", + "descriptionSecondary": "A transportation assistance community avoided burnout by introducing a weekly shift system, making participation more sustainable." + }, + { + "title": "Transparent, inclusive decisions", + "description": "Figuring out how your team makes decisions right from the start saves a lot of headaches. You can use full consensus, majority rules or let specific people make the call. Being open about this and checking in often keeps everyone involved and prevents secret power struggles. As an example, one disaster relief community sped things up by using a \"consensus-minus-one\" rule. It kept them aligned but stopped a single person from bringing everything to a halt." + }, + { + "title": "Address conflicts before they grow", + "description": "A basic conflict management process can prevent disputes from escalating. Clear guidelines for resource distribution reduce misunderstandings and maintain trust. Assume good faith and use structured discussions to resolve issues.", + "descriptionSecondary": "Members noticed frustration when some folks felt unheard in decisions about new projects. By adding an anonymous feedback box, they created a space to address concerns early and make adjustments before conflicts escalated." + } + ] + }, + "faq": { + "title": "Get answers to your questions", + "items": [ + { + "title": "Do we have to start from a blank page?", + "answer": "No. Your community starts from a deck of hundreds of cards on values, decision-making, membership, communication and conflict, and the deck keeps growing. Each card is one way to answer a question. Your community picks the cards closest to what it does today, combines them and rewrites what does not fit. Or start from one of thirteen templates. They run from consensus to sortition." + }, + { + "title": "Who can see our Rule?", + "answer": "Only the people you invite. Every Rule starts private. It becomes public only if you share a link to it or choose to make it public and featured. Check with your group first. If your work involves people who could be put at risk, leave out rosters, meeting places and legal names anyway." + }, + { + "title": "Do we have to follow it?", + "answer": "You follow it because your group agreed to it. Members can point to the same words when things get hard. Whether any part of a Rule holds up in court depends on where you are and what it says. A Rule sits beside your bylaws if you have them, and any law or regulator that applies to you still applies. Talk to a lawyer before your group relies on a card like binding arbitration." + }, + { + "title": "We are in the middle of a conflict right now. Will this help?", + "answer": "Only partly. A whole Rule is hard to write during a dispute because people write rules to win the fight in front of them. What helps now is agreeing how your group will handle this one dispute, ideally with a facilitator from outside the group. If someone has been harmed, their safety comes first. Write your Rule once things settle and it will help with the next one. The guides on conflict are a good place to start." + }, + { + "title": "Who makes this and what does it cost?", + "answer": "The Media Economies Design Lab at the University of Colorado Boulder makes CommunityRule. The tool is free for every group and its code is open source under GPL-3.0. The guides, the zines, the findings and the rest of the site are under CC BY-SA 4.0. You can copy, adapt and republish them if you give credit and share your version under the same license." + } + ] }, "askOrganizer": { "title": "Still have questions?", "subtitle": "Get answers from an experienced organizer", - "description": "Our community of organizers is here to help you navigate the challenges of building and maintaining effective community organizations.", "buttonText": "Ask an organizer" } } diff --git a/messages/en/pages/useCases.json b/messages/en/pages/useCases.json index 1cc1ea8..3dd04d5 100644 --- a/messages/en/pages/useCases.json +++ b/messages/en/pages/useCases.json @@ -5,7 +5,7 @@ "caseStudyTiles": { "mutualAidColoradoAlt": "Mutual Aid Colorado logo", "foodNotBombsAlt": "Food Not Bombs logo", - "boulderCountyStreetMedicsAlt": "Boulder County Street Medics logo", + "boulderCountyStreetMedicsAlt": "Boulder Street Medics logo", "links": [ { "href": "/use-cases/mutual-aid-colorado", @@ -17,7 +17,7 @@ }, { "href": "/use-cases/boulder-county-street-medics", - "ariaLabel": "View Boulder County Street Medics use case" + "ariaLabel": "View Boulder Street Medics use case" } ] }, diff --git a/messages/en/pages/useCasesCompletedRules.json b/messages/en/pages/useCasesCompletedRules.json index bb65ac6..c3f7ad6 100644 --- a/messages/en/pages/useCasesCompletedRules.json +++ b/messages/en/pages/useCasesCompletedRules.json @@ -10,23 +10,23 @@ "entries": [ { "title": "Mutual Aid, Not Charity", - "body": "We act in political solidarity rather than charity. We are building systems of community care and resilience, recognizing that our liberation is bound together.\n\n“Change needs all of us.”\n\nFood Not Bombs is not a charity. It is a project of solidarity. Charity is vertical. It moves from those who have to those who have not and maintains the hierarchy between them. Solidarity is horizontal. It moves between equals who recognize that our liberation is bound together. We do not help the poor. We share resources among community members because access to food is a human right rather than a privilege of wealth." + "body": "We act in political solidarity rather than charity. We are building systems of community care and resilience, recognizing that our liberation is bound together." }, { "title": "Local Autonomy", - "body": "We fiercely protect the autonomy of individual local chapters. We exist to support and connect local work, not to dictate it, ensuring we do not transform into the very top-down bureaucracies we aim to subvert.\n\n“Everyone contributes, no one controls.”\n\nWe operate without bosses or managers. This does not mean we are disorganized. It means we are self-organized. Authority in this chapter is temporary and task-specific rather than permanent or personal. We believe the people doing the work should make the decisions about that work. By distributing responsibility we prevent burnout and ensure the movement survives beyond any single leader." + "body": "We fiercely protect the autonomy of individual local chapters. We exist to support and connect local work, not to dictate it, ensuring we do not transform into the very top-down bureaucracies we aim to subvert." }, { "title": "Action Over Bureaucracy", - "body": "We prioritize moving physical goods and supporting local groups efficiently over unnecessary administration. We use our explicit agreements to prevent operational bottlenecks and keep the work moving.\n\n“Relationships over algorithms.”\n\nWe use digital tools to coordinate but we build power in the physical world. An algorithm cannot cook a meal and a group chat cannot look someone in the eye. We prioritize face-to-face interaction and physical presence at distributions. We resist the temptation to let digital metrics replace tangible impact." + "body": "We prioritize moving physical goods and supporting local groups efficiently over unnecessary administration. We use our explicit agreements to prevent operational bottlenecks and keep the work moving." }, { "title": "Durable Commons", - "body": "We explicitly document our resource sharing protocols and logistical agreements. This ensures that knowledge regarding supply routes and shared assets is accessible to any participating group, functioning as a true commons rather than being hoarded by a few central organizers.\n\n“Rescued food doesn't add carbon.”\n\nOur logistics are an ecological intervention. Food waste is a major driver of climate change. By intercepting food that would otherwise be discarded and redirecting it to hungry neighbors we close the loop. We view food recovery as stewarding a resource that the industrial food system has abandoned. Every pound of food we rescue is a pound of carbon kept out of the atmosphere." + "body": "We explicitly document our resource sharing protocols and logistical agreements. This ensures that knowledge regarding supply routes and shared assets is accessible to any participating community, functioning as a true commons rather than being hoarded by a few central organizers." }, { "title": "Decentralized Power", - "body": "We are committed to building massive material power without centralizing authority. We engineer our structures to distribute responsibility and prevent the consolidation of control within the network.\n\n“Collective giving builds collective power.”\n\nOur budget is labor rather than capital. We rely on the time and care of our volunteers rather than large grants or corporate sponsorships. This independence gives us the political freedom to operate according to our values. When we pool our small individual capacities we create a collective power that money cannot buy." + "body": "We are committed to building massive material power without centralizing authority. We engineer our structures to distribute responsibility and prevent the consolidation of control within the network." } ] }, @@ -408,8 +408,8 @@ }, "boulderCountyStreetMedics": { "pageBackground": "var(--color-surface-invert-brand-red)", - "title": "BoCo Street Medics", - "summary": "Boulder County Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.", + "title": "Boulder Street Medics", + "summary": "Boulder Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.", "document": { "sections": [ { diff --git a/messages/en/pages/useCasesDetail.json b/messages/en/pages/useCasesDetail.json index 32715b9..e2be271 100644 --- a/messages/en/pages/useCasesDetail.json +++ b/messages/en/pages/useCasesDetail.json @@ -17,7 +17,7 @@ "src": "assets/case-study/case-study-mutual-aid.svg", "alt": "Mutual Aid Colorado logo" }, - "bodyMarkdown": "Coordinating a statewide network of autonomous mutual aid groups introduces complex logistical challenges. When an organization scales to managing regional supply chains and shared physical infrastructure like print shops informal networking is no longer sufficient. Without explicit agreements regional hubs risk transforming into the very top-down bureaucracies they aim to subvert. Mutual Aid Colorado utilized CommunityRule to address this tension directly. The platform provided a modular vocabulary to design a legible governance framework. This allowed the network to establish reliable statewide coordination while fiercely protecting the autonomy of individual local chapters.\n\nA core advantage of this modular approach is the ability to engineer a polycentric system capable of operating at multiple scales. Mutual Aid Colorado used the platform to differentiate the decision-making speeds required for various projects. Managing a statewide logistics operation requires rapid coordination to move physical goods efficiently. Conversely establishing the shared values of the broader network necessitates a slow and deeply participatory consensus model. By explicitly mapping these different arenas the organization ensures that the volunteers running the print shop can make immediate operational choices without waiting for a statewide assembly to approve their paper orders.\n\nUltimately formalizing these structural relationships transforms a loose coalition into a highly resilient piece of civic infrastructure. Regional organizing frequently struggles with operational bottlenecks as the immense labor of coordinating across vast geographies falls onto a few central nodes. By documenting their resource sharing protocols and logistical agreements through CommunityRule the network constructed a durable commons. This explicit architecture ensures that knowledge regarding supply routes and shared assets is accessible to any participating group. It provides a practical blueprint for how distributed networks can build massive material power without centralizing authority.", + "bodyMarkdown": "Coordinating a statewide network of autonomous mutual aid groups introduces complex logistical challenges. When an organization scales to managing regional supply chains and shared physical infrastructure like print shops informal networking is no longer sufficient. Without explicit agreements regional hubs risk transforming into the very top-down bureaucracies they aim to subvert. Mutual Aid Colorado utilized CommunityRule to address this tension directly. The platform provided a modular vocabulary to design a legible governance framework. This allowed the network to establish reliable statewide coordination while fiercely protecting the autonomy of individual local chapters.\n\nA core advantage of this modular approach is the ability to engineer a polycentric system capable of operating at multiple scales. Mutual Aid Colorado used the platform to differentiate the decision-making speeds required for various projects. Managing a statewide logistics operation requires rapid coordination to move physical goods efficiently. Conversely establishing the shared values of the broader network necessitates a slow and deeply participatory consensus model. By explicitly mapping these different arenas the organization ensures that the volunteers running the print shop can make immediate operational choices without waiting for a statewide assembly to approve their paper orders.\n\nUltimately formalizing these structural relationships transforms a loose coalition into a highly resilient piece of civic infrastructure. Regional organizing frequently struggles with operational bottlenecks as the immense labor of coordinating across vast geographies falls onto a few central nodes. By documenting their resource sharing protocols and logistical agreements through CommunityRule the network constructed a durable commons. This explicit architecture ensures that knowledge regarding supply routes and shared assets is accessible to any participating community. It provides a practical blueprint for how distributed networks can build massive material power without centralizing authority.", "bodyFooter": { "title": "Ready to document how your group operates?", "description": "Start from proven mutual aid patterns and customize them for your community." @@ -62,20 +62,20 @@ "boulderCountyStreetMedics": { "pageBackground": "var(--color-content-default-brand-red)", "banner": { - "title": "Boulder County Street Medics", + "title": "Boulder Street Medics", "description": "CommunityRule helped the Street Medics define their operational process both on the streets and off.", "author": "CommunityRule", "date": "2025-04-15" }, "ruleCard": { - "title": "BoCo Street Medics", - "description": "Boulder County Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.", + "title": "Boulder Street Medics", + "description": "Boulder Street Medics is a grassroots, volunteer-run organization focused on providing first aid and medical support to marginalized communities and activists in the Boulder area.", "backgroundColor": "bg-[var(--color-surface-invert-brand-red)]", "iconPath": "assets/case-study/case-study-boulder-county-street-medics.svg" }, "leadingImage": { "src": "assets/case-study/case-study-boulder-county-street-medics.svg", - "alt": "Boulder County Street Medics logo" + "alt": "Boulder Street Medics logo" }, "bodyMarkdown": "When communities like the BoCo Street Medics operate in high-stakes, legally precarious environments, the classic \"tyranny of structurelessness\" isn't just an academic critique. It is a massive operational risk. What these medics recognized is that relying on implicit norms and unspoken hierarchies quickly leads to burnout, fragmented trust, and compromised safety. This is exactly where a tool like CommunityRule becomes vital. By providing a legible, modular framework for democratic design, it allowed the collective to step back from the adrenaline of the streets and intentionally translate their core values of radical solidarity into explicit, accountable processes. They didn't have to reinvent the wheel of governance because they could select and adapt proven patterns to fit their unique reality.\n\nWhat CommunityRule facilitates so beautifully is the understanding that a single community can and often must operate at different speeds of democracy. The BoCo Street Medics utilized this modular approach to map out their distinct operational modes, making it crystal clear to every member when to rely on the slow, deeply participatory \"15-Day Lazy Consensus\" for strategic policy, and when to pivot to a rigid Incident Command System for tactical safety during a deployment. By charting these different pathways, the medics created a constitution that balances the deep, deliberate work of egalitarian community-building with the rapid, authoritative action required to keep people safe on the ground.\n\nUltimately, formalizing these processes through CommunityRule is an act of institutional care. It shifts the burden of conflict management, onboarding, and decision-making away from the exhausted shoulders of a few founders and distributes it into a resilient, shared architecture. By defining clear boundaries around membership access, escalation ladders for conflict, and even mandated leaves of absence, the BoCo Street Medics have built a culture of true stewardship. They've proven that radical, grassroots work doesn't have to be chaotic. When we design our governance with intention, we build a solidarity that can actually outlast us.", "bodyFooter": { diff --git a/next.config.mjs b/next.config.mjs index 0cc418d..9baa5ef 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -173,6 +173,11 @@ const nextConfig = { destination: "/create/decision-approaches", permanent: true, }, + { + source: "/use-cases", + destination: "/learn", + permanent: true, + }, ]; }, }; diff --git a/public/content/blog/how-communityrule-works-horizontal.svg b/public/content/blog/how-communityrule-works-horizontal.svg new file mode 100644 index 0000000..d76a004 --- /dev/null +++ b/public/content/blog/how-communityrule-works-horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/content/blog/how-communityrule-works-vertical.svg b/public/content/blog/how-communityrule-works-vertical.svg new file mode 100644 index 0000000..a3dc954 --- /dev/null +++ b/public/content/blog/how-communityrule-works-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/pages/learn.test.tsx b/tests/pages/learn.test.tsx index 67645d2..4e23ce8 100644 --- a/tests/pages/learn.test.tsx +++ b/tests/pages/learn.test.tsx @@ -69,42 +69,80 @@ describe("LearnPage", () => { vi.mocked(getAllBlogPosts).mockReturnValue(mockPosts); }); - it("renders content lockup and ask organizer copy", () => { + it("renders the learn sections, with the guide first and the FAQ open", () => { render(); + expect( + screen.getByRole("heading", { name: /See how groups use/ }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "View Boulder Street Medics use case" }), + ).toHaveAttribute("href", "/use-cases/boulder-county-street-medics"); + expect( + screen.getByRole("heading", { name: "Who is this for?" }), + ).toBeInTheDocument(); expect(screen.getByText("Organizing is hard")).toBeInTheDocument(); expect( screen.getByText( - /Find answers to your questions and see how other groups/, + /Find answers to your questions and see how other communities/, ), ).toBeInTheDocument(); + const guideLinks = screen.getAllByRole("link", { name: "Read the guide" }); + expect(guideLinks.length).toBeGreaterThan(0); + for (const link of guideLinks) { + expect(link).toHaveAttribute("href", "/how-it-works"); + } + expect( + screen.getByRole("heading", { + name: "Why horizontal communities need CommunityRule", + }), + ).toBeInTheDocument(); + expect( + screen.getByText("Transparent, inclusive decisions"), + ).toBeInTheDocument(); + + const faq = screen.getByRole("region", { + name: "Get answers to your questions", + }); + expect(faq).toHaveAttribute("id", "faq"); + expect( + within(faq).getByRole("button", { + name: "Do we have to start from a blank page?", + }), + ).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByTestId("ask-organizer")).toBeInTheDocument(); expect(screen.getByText("Still have questions?")).toBeInTheDocument(); }); - it("renders one card per post (single responsive grid, no duplication)", () => { + it("renders the guide and one card per post, without a duplicate grid", () => { render(); const grid = screen.getByTestId("learn-article-grid"); expect(grid).toHaveClass("smd:justify-center", "smd:flex-wrap"); const links = within(grid).getAllByRole("link"); - expect(links).toHaveLength(mockPosts.length); + expect(links).toHaveLength(mockPosts.length + 1); - expect(links[0]).toHaveAttribute( + expect(links[0]).toHaveAttribute("href", "/how-it-works"); + expect(links[1]).toHaveAttribute( "href", "/blog/resolving-active-conflicts", ); - expect(links[1]).toHaveAttribute( + expect(links[2]).toHaveAttribute( "href", "/blog/operational-security-mutual-aid", ); + expect(within(grid).queryByText("April 15, 2025")).not.toBeInTheDocument(); - // with a smd source provides the orientation swap without a - // duplicate card per breakpoint. - const sources = grid?.querySelectorAll( + const sources = grid.querySelectorAll( "picture source[media='(min-width: 530px)']", ); - expect(sources?.length).toBe(mockPosts.length); + expect(sources.length).toBe(mockPosts.length + 1); + + const icons = grid.querySelectorAll("img"); + for (const icon of icons) { + expect(icon).toHaveAttribute("alt", ""); + } }); }); diff --git a/tests/pages/use-cases-completed-rule.test.jsx b/tests/pages/use-cases-completed-rule.test.jsx index cb0ccb2..fc1cf1b 100644 --- a/tests/pages/use-cases-completed-rule.test.jsx +++ b/tests/pages/use-cases-completed-rule.test.jsx @@ -69,6 +69,14 @@ describe("UseCaseCompletedRulePage", () => { screen.getByRole("heading", { name: fixture.title }), ).toBeInTheDocument(); if (slug === "mutual-aid-colorado") { + expect( + screen.getByText(/political solidarity rather than charity/), + ).toBeInTheDocument(); + expect( + screen.queryByText(/Food Not Bombs is not a charity/), + ).not.toBeInTheDocument(); + } + if (slug === "food-not-bombs") { expect( screen.getByText(/Food Not Bombs is not a charity/), ).toBeInTheDocument(); diff --git a/tests/pages/use-cases.test.jsx b/tests/pages/use-cases.test.jsx index 7c8b01f..0e441ca 100644 --- a/tests/pages/use-cases.test.jsx +++ b/tests/pages/use-cases.test.jsx @@ -1,35 +1,19 @@ import { describe, test, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import { renderWithProviders as render } from "../utils/test-utils"; import UseCasesPage from "../../app/(marketing)/use-cases/page"; -import messages from "../../messages/en/index"; -vi.mock("next/dynamic", () => ({ - default: () => { - const Component = vi.fn(() => ( -
Related articles
- )); - return Component; - }, +const { redirect } = vi.hoisted(() => ({ + redirect: vi.fn((url) => { + throw new Error(`NEXT_REDIRECT:${url}`); + }), })); -vi.mock("next/link", () => ({ - default: ({ children, href, ...props }) => ( - - {children} - - ), +vi.mock("next/navigation", () => ({ + redirect, })); describe("UseCasesPage", () => { - const links = messages.pages.useCases.caseStudyTiles.links; - - test("renders case study tiles as links to detail pages", () => { - render(); - - for (const link of links) { - const anchor = screen.getByRole("link", { name: link.ariaLabel }); - expect(anchor).toHaveAttribute("href", link.href); - } + test("sends the use cases index to Learn", () => { + expect(() => UseCasesPage()).toThrow("NEXT_REDIRECT:/learn"); + expect(redirect).toHaveBeenCalledWith("/learn"); }); }); diff --git a/tests/unit/publicSitemap.test.ts b/tests/unit/publicSitemap.test.ts index 5ac7e00..a591afb 100644 --- a/tests/unit/publicSitemap.test.ts +++ b/tests/unit/publicSitemap.test.ts @@ -14,7 +14,7 @@ describe("buildPublicSitemap", () => { expect(urls).toContain(sitemapUrl("/learn")); expect(urls).toContain(sitemapUrl("/templates")); expect(urls).toContain(sitemapUrl("/blog")); - expect(urls).toContain(sitemapUrl("/use-cases")); + expect(urls).not.toContain(sitemapUrl("/use-cases")); expect(urls).toContain(sitemapUrl("/how-it-works")); expect(urls).toContain(sitemapUrl("/privacy")); expect(urls).toContain(sitemapUrl("/terms")); diff --git a/tests/unit/siteMetadata.test.ts b/tests/unit/siteMetadata.test.ts index 750723c..0b7b069 100644 --- a/tests/unit/siteMetadata.test.ts +++ b/tests/unit/siteMetadata.test.ts @@ -146,13 +146,27 @@ describe("originFromHost", () => { }); describe("buildRobots", () => { - it("disallows crawlers on staging and unknown hosts", () => { - expect(buildRobots("staging.communityrule.info")).toEqual({ - rules: { userAgent: "*", disallow: "/" }, - }); - expect(buildRobots("localhost:3000")).toEqual({ - rules: { userAgent: "*", disallow: "/" }, + it("disallows crawlers on staging and unknown hosts, and still lets link unfurlers fetch the page", () => { + const staging = buildRobots("staging.communityrule.info"); + expect(staging).toEqual({ + rules: [ + { userAgent: "*", disallow: "/" }, + { + userAgent: [ + "Twitterbot", + "facebookexternalhit", + "Slackbot", + "Discordbot", + "LinkedInBot", + "TelegramBot", + "WhatsApp", + "Bluesky", + ], + allow: "/", + }, + ], }); + expect(buildRobots("localhost:3000")).toEqual(staging); }); it("allows production hosts and advertises the sitemap", () => { @@ -168,10 +182,9 @@ describe("buildRobots", () => { it("honors SITE_INDEXING overrides", () => { process.env.SITE_INDEXING = "false"; - expect(buildRobots("communityrule.info").rules).toEqual({ - userAgent: "*", - disallow: "/", - }); + expect(buildRobots("communityrule.info").rules).toEqual( + buildRobots("staging.communityrule.info").rules, + ); process.env.SITE_INDEXING = "true"; expect(buildRobots("staging.communityrule.info").sitemap).toBe( -- 2.53.0 From 4b0d15e89b8648d6814638562542c6b541a55f65 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:00:12 -0700 Subject: [PATCH 2/2] Match the Learn guide grid to the frames, and put the tag and small Rule on the guide card. Co-authored-by: Cursor --- app/(marketing)/learn/page.tsx | 18 ++++++++++-- .../ContentThumbnailTemplate.container.tsx | 4 ++- .../ContentThumbnailTemplate.types.ts | 7 +++-- .../ContentThumbnailTemplate.view.tsx | 16 ++++++---- .../how-communityrule-works-horizontal.svg | 29 +++++++++++++++++++ .../blog/how-communityrule-works-tag.svg | 14 +++++++++ .../blog/how-communityrule-works-vertical.svg | 12 ++++++++ tests/pages/learn.test.tsx | 11 +++++-- 8 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 public/content/blog/how-communityrule-works-tag.svg diff --git a/app/(marketing)/learn/page.tsx b/app/(marketing)/learn/page.tsx index 04a63dd..045a95f 100644 --- a/app/(marketing)/learn/page.tsx +++ b/app/(marketing)/learn/page.tsx @@ -32,6 +32,20 @@ export const metadata: Metadata = routeMetadata("/learn", { const CASE_STUDY_TILE_RADIUS_CLASS = "rounded-[23.093px]"; +/** Figma card grid: 1 column below 640, 2 from 640, 3 from 1024, 4 from 1440. */ +const ARTICLE_GRID_CLASS = [ + "flex flex-col gap-[var(--space-050)]", + "md:flex-row md:flex-wrap md:justify-center md:gap-[var(--space-400)]", + "md:px-[var(--space-1200)] md:py-[var(--space-600)]", + "lg:px-[var(--space-1800)] lg:pt-[var(--space-800)] lg:pb-[var(--space-1800)]", + "[&>*]:min-w-0 [&>*]:w-full", + "md:[&>*]:w-[calc((100%-var(--space-400))/2)]", + "lg:[&>*]:w-[calc((100%-2*var(--space-400))/3)]", + "xl:[&>*]:w-[calc((100%-3*var(--space-400))/4)]", +].join(" "); + +const GUIDE_TAG_SRC = "/content/blog/how-communityrule-works-tag.svg"; + const CASE_STUDY_LINK_CLASS = [ CASE_STUDY_TILE_RADIUS_CLASS, "block shrink-0 cursor-pointer outline-none transition-[opacity,transform] duration-200", @@ -163,14 +177,14 @@ export default function LearnPage() {
{articlePosts.map((post) => ( diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx index 0391318..f6626ea 100644 --- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx +++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx @@ -24,6 +24,7 @@ const ContentThumbnailTemplateContainer = memo( showByline = true, showLeadingImage = true, leadingImageAlt, + leadingImageSrc, }) => { const variant = variantProp; const sizing = sizingProp; @@ -56,7 +57,7 @@ const ContentThumbnailTemplateContainer = memo( }; // For "responsive", emit both orientations so the source can - // swap at smd without a second card in the DOM. + // swap at md (640px) without a second card in the DOM. const backgroundImage = variant === "responsive" ? getBackgroundImage(post, "horizontal") @@ -78,6 +79,7 @@ const ContentThumbnailTemplateContainer = memo( showByline={showByline} showLeadingImage={showLeadingImage} leadingImageAlt={leadingImageAlt} + leadingImageSrc={leadingImageSrc} /> ); }, diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts index b98b52c..5958fa3 100644 --- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts +++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts @@ -11,8 +11,8 @@ export interface ContentThumbnailTemplateProps { post: BlogPost; className?: string; /** - * vertical | horizontal — single layout. responsive — horizontal at . + * vertical | horizontal — single layout. responsive — horizontal below md + * (640px), vertical from md up (Learn grid); one card, swapped via . */ variant?: ContentThumbnailTemplateVariantValue; /** @@ -28,6 +28,8 @@ export interface ContentThumbnailTemplateProps { showLeadingImage?: boolean; /** Alt for the tag mark. Empty when the mark repeats the title. */ leadingImageAlt?: string; + /** Tag mark source. Falls back to the catalog tag for the post slug. */ + leadingImageSrc?: string; } export interface ContentThumbnailTemplateViewProps { @@ -42,4 +44,5 @@ export interface ContentThumbnailTemplateViewProps { showByline: boolean; showLeadingImage: boolean; leadingImageAlt?: string; + leadingImageSrc?: string; } diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx index 866574c..3eb15b3 100644 --- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx +++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx @@ -14,18 +14,19 @@ function ContentThumbnailTemplateView({ showByline, showLeadingImage, leadingImageAlt, + leadingImageSrc, }: ContentThumbnailTemplateViewProps) { if (variant === "responsive") { - // Single card; swaps the orientation-specific image at smd - // (530px), aspect-ratio and content positioning switch via Tailwind. + // Single card; swaps to the vertical image at md (640px), + // which is where Learn's grid goes from one landscape card to two. return ( -
+
{backgroundImageSmd ? ( ) : null} @@ -37,13 +38,14 @@ function ContentThumbnailTemplateView({ />
-
+
@@ -72,6 +74,7 @@ function ContentThumbnailTemplateView({ showByline={showByline} showLeadingImage={showLeadingImage} leadingImageAlt={leadingImageAlt} + leadingImageSrc={leadingImageSrc} />
@@ -97,6 +100,7 @@ function ContentThumbnailTemplateView({ showByline={showByline} showLeadingImage={showLeadingImage} leadingImageAlt={leadingImageAlt} + leadingImageSrc={leadingImageSrc} />
@@ -124,6 +128,7 @@ function ContentThumbnailTemplateView({ showByline={showByline} showLeadingImage={showLeadingImage} leadingImageAlt={leadingImageAlt} + leadingImageSrc={leadingImageSrc} /> @@ -149,6 +154,7 @@ function ContentThumbnailTemplateView({ showByline={showByline} showLeadingImage={showLeadingImage} leadingImageAlt={leadingImageAlt} + leadingImageSrc={leadingImageSrc} /> diff --git a/public/content/blog/how-communityrule-works-horizontal.svg b/public/content/blog/how-communityrule-works-horizontal.svg index d76a004..d841739 100644 --- a/public/content/blog/how-communityrule-works-horizontal.svg +++ b/public/content/blog/how-communityrule-works-horizontal.svg @@ -1,3 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/content/blog/how-communityrule-works-tag.svg b/public/content/blog/how-communityrule-works-tag.svg new file mode 100644 index 0000000..b8fd584 --- /dev/null +++ b/public/content/blog/how-communityrule-works-tag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/content/blog/how-communityrule-works-vertical.svg b/public/content/blog/how-communityrule-works-vertical.svg index a3dc954..8d24983 100644 --- a/public/content/blog/how-communityrule-works-vertical.svg +++ b/public/content/blog/how-communityrule-works-vertical.svg @@ -1,3 +1,15 @@ + + + + + + + + + + + + diff --git a/tests/pages/learn.test.tsx b/tests/pages/learn.test.tsx index 4e23ce8..7a5ad9c 100644 --- a/tests/pages/learn.test.tsx +++ b/tests/pages/learn.test.tsx @@ -119,7 +119,14 @@ describe("LearnPage", () => { render(); const grid = screen.getByTestId("learn-article-grid"); - expect(grid).toHaveClass("smd:justify-center", "smd:flex-wrap"); + expect(grid).toHaveClass("md:justify-center", "md:flex-wrap"); + expect(grid).toHaveClass( + "lg:[&>*]:w-[calc((100%-2*var(--space-400))/3)]", + ); + expect(grid).toHaveClass( + "xl:[&>*]:w-[calc((100%-3*var(--space-400))/4)]", + ); + expect(grid.className).not.toContain("/5)]"); const links = within(grid).getAllByRole("link"); expect(links).toHaveLength(mockPosts.length + 1); @@ -136,7 +143,7 @@ describe("LearnPage", () => { expect(within(grid).queryByText("April 15, 2025")).not.toBeInTheDocument(); const sources = grid.querySelectorAll( - "picture source[media='(min-width: 530px)']", + "picture source[media='(min-width: 640px)']", ); expect(sources.length).toBe(mockPosts.length + 1); -- 2.53.0