diff --git a/app/components/RuleCard/RuleCard.view.tsx b/app/components/RuleCard/RuleCard.view.tsx index 4e637e9..bdd2625 100644 --- a/app/components/RuleCard/RuleCard.view.tsx +++ b/app/components/RuleCard/RuleCard.view.tsx @@ -29,15 +29,22 @@ export function RuleCardView({ const isSmall = size === "S"; const isExtraSmall = size === "XS"; - // Card dimensions - fixed width for expanded states (568px for L, 398px for M per Figma) - // XS and S don't have fixed widths when expanded - const cardPadding = isLarge || isSmall + // Card dimensions - use CSS classes from className if provided, otherwise use size-based logic + // Check if className already has padding/gap classes + const hasResponsivePadding = className?.includes("p-[") || className?.includes("px-[") || className?.includes("py-[") || className?.includes("pt-[") || className?.includes("pb-["); + const hasResponsiveGap = className?.includes("gap-["); + + const cardPadding = hasResponsivePadding + ? "" // If className has responsive padding, don't add size-based padding + : isLarge || isSmall ? "p-[24px]" : isMedium ? "p-[16px]" : "pb-[24px] pt-[12px] px-[12px]"; // XS: asymmetric padding const cardGap = expanded ? "gap-[16px]" + : hasResponsiveGap + ? "" // If className has responsive gap, don't add size-based gap : isLarge ? "gap-[10px]" : isMedium @@ -51,32 +58,24 @@ export function RuleCardView({ : "" // XS and S: no fixed width : ""; - // Logo/Icon dimensions + // Logo/Icon dimensions - use CSS responsive classes // For S: 80px container with 12px padding = 56px icon area - // For XS: 40px container with 16px padding = 8px icon area (very small, but matches Figma) - const logoSize = isLarge - ? 103 - : isMedium - ? 56 - : isSmall - ? 56 // S: 80px container - 12px padding * 2 = 56px icon - : 8; // XS: 40px container - 16px padding * 2 = 8px icon - const logoContainerClass = isLarge - ? "size-[103px]" - : isMedium - ? "size-[56px]" - : isSmall - ? "size-[80px]" // S: 80px container - : "size-[40px]"; // XS: 40px container + // For XS: 72px container with 16px padding = 40px icon (72 - 16*2 = 40px) + const logoSize = 103; // Use max size, CSS will resize + const logoContainerClass = ` + max-[639px]:size-[72px] + min-[640px]:max-[1023px]:size-[80px] + min-[1024px]:max-[1439px]:size-[56px] + min-[1440px]:size-[103px] + `; - // Title typography - const titleClass = isLarge - ? "font-bricolage-grotesque font-extrabold text-[36px] leading-[44px]" - : isMedium - ? "font-bricolage-grotesque font-bold text-[24px] leading-[32px]" - : isSmall - ? "font-bricolage-grotesque font-bold text-[28px] leading-[36px]" // S: 28px, bold, Bricolage - : "font-inter font-bold text-[20px] leading-[28px]"; // XS: 20px, bold, Inter + // Title typography - use CSS responsive classes + const titleClass = ` + max-[639px]:font-inter max-[639px]:font-bold max-[639px]:text-[20px] max-[639px]:leading-[28px] + min-[640px]:max-[1023px]:font-bricolage-grotesque min-[640px]:max-[1023px]:font-bold min-[640px]:max-[1023px]:text-[28px] min-[640px]:max-[1023px]:leading-[36px] + min-[1024px]:max-[1439px]:font-bricolage-grotesque min-[1024px]:max-[1439px]:font-bold min-[1024px]:max-[1439px]:text-[24px] min-[1024px]:max-[1439px]:leading-[32px] + min-[1440px]:font-bricolage-grotesque min-[1440px]:font-extrabold min-[1440px]:text-[36px] min-[1440px]:leading-[44px] + `; // Description typography const descriptionClass = isLarge @@ -93,7 +92,7 @@ export function RuleCardView({ // Check if it's a localhost URL or external URL that needs regular img tag const isLocalhost = logoUrl.startsWith("http://localhost") || logoUrl.startsWith("https://localhost"); - const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity ${isSmall ? "p-[12px]" : isExtraSmall ? "p-[16px]" : ""}`; + const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`; if (isLocalhost) { return ( @@ -104,7 +103,7 @@ export function RuleCardView({ alt={logoAlt || title} width={logoSize} height={logoSize} - className={`${isSmall || isExtraSmall ? "w-full h-full" : "absolute inset-0 w-full h-full"} object-cover rounded-full`} + className="w-full h-full object-cover rounded-full" /> ); @@ -117,7 +116,7 @@ export function RuleCardView({ alt={logoAlt || title} width={logoSize} height={logoSize} - className={`${isSmall || isExtraSmall ? "w-full h-full" : "absolute inset-0 w-full h-full"} object-cover rounded-full`} + className="w-full h-full object-cover rounded-full" /> ); @@ -125,20 +124,19 @@ export function RuleCardView({ if (icon) { return ( -
+
{icon}
); } if (communityInitials) { - const initialsSize = isLarge - ? "text-[36px]" - : isMedium - ? "text-[24px]" - : isSmall - ? "text-[20px]" - : "text-[16px]"; + const initialsSize = ` + max-[639px]:text-[16px] + min-[640px]:max-[1023px]:text-[20px] + min-[1024px]:max-[1439px]:text-[24px] + min-[1440px]:text-[36px] + `; return (
@@ -152,9 +150,18 @@ export function RuleCardView({ }; + // Border radius - use CSS classes if provided via className, otherwise use size-based logic + const borderRadiusClass = className?.includes("rounded-") + ? "" // If className already has border radius, don't add size-based one + : isExtraSmall + ? "rounded-[var(--measures-radius-200,8px)]" + : isSmall + ? "rounded-[var(--measures-radius-300,12px)]" + : "rounded-[var(--radius-measures-radius-small)]"; + return (
{/* Outermost container with bottom border - taller to match Figma */} -
+
{/* Logo/Icon - fixed width/height, vertically centered, does not touch bottom */} {renderLogo() && ( -
+
{renderLogo()}
)} {/* Spacing between icon and title */} - {!isSmall && !isExtraSmall &&
} +
{/* Container with no padding and left border - extends full height to touch bottom */} {title && ( -
+
{/* Inner container for header text with padding */} -
+

{title}

diff --git a/app/components/RuleStack/RuleStack.view.tsx b/app/components/RuleStack/RuleStack.view.tsx index a735e46..d7533c2 100644 --- a/app/components/RuleStack/RuleStack.view.tsx +++ b/app/components/RuleStack/RuleStack.view.tsx @@ -1,8 +1,11 @@ "use client"; +import { useState, useEffect } from "react"; import Image from "next/image"; import { useTranslation } from "../../contexts/MessagesContext"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; import RuleCard from "../RuleCard"; +import SectionHeader from "../SectionHeader"; import Button from "../Button"; import { getAssetPath } from "../../../lib/assetUtils"; import type { RuleStackViewProps } from "./RuleStack.types"; @@ -12,78 +15,153 @@ export function RuleStackView({ onTemplateClick, }: RuleStackViewProps) { const t = useTranslation("pages.home.ruleStack"); + const [isMounted, setIsMounted] = useState(false); + + // Debug: Log button text to ensure translation works + const buttonText = t("button.seeAllTemplates"); + + // Determine current breakpoint for RuleCard size + // 320-639: XS, 640-767: S, 768-1023: S, 1024-1439: M, 1440+: L + const isMax639 = useMediaQuery("(max-width: 639px)"); + const isMin640Max1023 = useMediaQuery("(min-width: 640px) and (max-width: 1023px)"); + const isMin1024Max1439 = useMediaQuery("(min-width: 1024px) and (max-width: 1439px)"); + const isMin1440 = useMediaQuery("(min-width: 1440px)"); + + // Handle hydration: only use media queries after mount + useEffect(() => { + setIsMounted(true); + }, []); + + // Use CSS classes for responsive sizing to avoid hydration mismatch + // Default to M size for SSR, then let CSS handle the responsive sizing + const cardSize = isMounted + ? isMax639 + ? "XS" + : isMin640Max1023 + ? "S" + : isMin1024Max1439 + ? "M" + : isMin1440 + ? "L" + : "M" + : "M"; + + // Icon sizes: XS=40px, S=56px, M=56px, L=90px + // Use a large default (90px) and let CSS handle responsive sizing + const iconSize = 90; + + // Card data + const cards = [ + { + title: t("cards.consensusClusters.title"), + description: t("cards.consensusClusters.description"), + iconAlt: t("cards.consensusClusters.iconAlt"), + iconPath: "assets/Icon_Sociocracy.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-lime)]", + }, + { + title: t("cards.consensus.title"), + description: t("cards.consensus.description"), + iconAlt: t("cards.consensus.iconAlt"), + iconPath: "assets/Icon_Consensus.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-rust)]", + }, + { + title: t("cards.electedBoard.title"), + description: t("cards.electedBoard.description"), + iconAlt: t("cards.electedBoard.iconAlt"), + iconPath: "assets/Icon_ElectedBoard.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-red)]", + }, + { + title: t("cards.petition.title"), + description: t("cards.petition.description"), + iconAlt: t("cards.petition.iconAlt"), + iconPath: "assets/Icon_Petition.svg", + backgroundColor: "bg-[var(--color-surface-default-brand-teal)]", + }, + ]; return (
-
- - } - backgroundColor="bg-[var(--color-surface-default-brand-lime)]" - onClick={() => onTemplateClick(t("cards.consensusClusters.title"))} - /> - - } - backgroundColor="bg-[var(--color-surface-default-brand-rust)]" - onClick={() => onTemplateClick(t("cards.consensus.title"))} - /> - - } - backgroundColor="bg-[var(--color-surface-default-brand-red)]" - onClick={() => onTemplateClick(t("cards.electedBoard.title"))} - /> - - } - backgroundColor="bg-[var(--color-surface-default-brand-teal)]" - onClick={() => onTemplateClick(t("cards.petition.title"))} - /> + {/* Section Header */} + + + {/* Cards Container */} +
+ {cards.map((card, index) => ( + + } + backgroundColor={card.backgroundColor} + onClick={() => onTemplateClick(card.title)} + /> + ))}
{/* See all templates button */} -
-
diff --git a/messages/en/pages/home.json b/messages/en/pages/home.json index 61e1913..564c934 100644 --- a/messages/en/pages/home.json +++ b/messages/en/pages/home.json @@ -54,6 +54,9 @@ "buttonHref": "#contact" }, "ruleStack": { + "title": "Popular templates", + "subtitle": "These are popular patterns for making decisions in mutual aid and open source communities. You can use them as they are or as a starting place for customizing your own CommunityRule.", + "subtitleLg": "These are popular patterns for making decisions in communities with egalitarian values. You can use them as they are or as a starting place for customizing your own CommunityRule.", "cards": { "consensusClusters": { "title": "Consensus clusters", diff --git a/tests/pages/user-journey.test.jsx b/tests/pages/user-journey.test.jsx index 4233cbd..bbf7c9b 100644 --- a/tests/pages/user-journey.test.jsx +++ b/tests/pages/user-journey.test.jsx @@ -251,7 +251,8 @@ describe("User Journey Integration", () => { // 3. User sees governance options - wait for dynamically imported component await waitFor(() => { - expect(screen.getByText("Consensus clusters")).toBeInTheDocument(); + // Use a more flexible matcher in case text is split across elements + expect(screen.getByText(/Consensus clusters/i)).toBeInTheDocument(); }); // 4. User sees features and benefits - wait for dynamically imported component diff --git a/tests/unit/RuleCard.test.jsx b/tests/unit/RuleCard.test.jsx index e4c8161..f009574 100644 --- a/tests/unit/RuleCard.test.jsx +++ b/tests/unit/RuleCard.test.jsx @@ -147,7 +147,9 @@ describe("RuleCard Component", () => { render(); const heading = screen.getByRole("heading", { level: 3 }); - expect(heading).toHaveClass("font-bricolage-grotesque", "font-extrabold"); + // Check for responsive font classes - at 1440px+ it should have font-bricolage-grotesque and font-extrabold + expect(heading?.className).toMatch(/min-\[1440px\]:font-bricolage-grotesque/); + expect(heading?.className).toMatch(/min-\[1440px\]:font-extrabold/); }); it("renders expanded state with categories", () => { diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx index 7d17a3f..41362dc 100644 --- a/tests/unit/RuleStack.test.jsx +++ b/tests/unit/RuleStack.test.jsx @@ -74,17 +74,17 @@ describe("RuleStack Component", () => { render(); const section = document.querySelector("section"); - expect(section).toHaveClass( - "py-[var(--spacing-scale-032)]", - "px-[var(--spacing-scale-020)]", - ); + // Check for responsive padding classes + expect(section).toHaveClass("px-[20px]", "py-[32px]"); + expect(section?.className).toMatch(/min-\[640px\]:px-\[32px\]/); + expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/); }); test("applies responsive grid layout", () => { render(); const grid = document.querySelector('[class*="flex flex-col gap-[18px]"]'); - expect(grid).toHaveClass("xmd:grid", "xmd:grid-cols-2"); + expect(grid).toHaveClass("min-[768px]:grid", "min-[768px]:grid-cols-2"); }); test("renders RuleCard components with correct props", () => { @@ -124,19 +124,18 @@ describe("RuleStack Component", () => { const section = document.querySelector("section"); expect(section).toBeInTheDocument(); - // Check for proper heading structure in cards + // Check for proper heading structure: 1 from SectionHeader + 4 from RuleCards const headings = screen.getAllByRole("heading"); - expect(headings).toHaveLength(4); // Four rule cards + expect(headings).toHaveLength(5); // One section header + four rule cards }); test("applies responsive spacing", () => { render(); const section = document.querySelector("section"); - expect(section).toHaveClass( - "md:py-[var(--spacing-scale-048)]", - "lg:py-[var(--spacing-scale-064)]", - ); + // Check for responsive padding classes + expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/); + expect(section?.className).toMatch(/min-\[1024px\]:py-\[64px\]/); }); test("renders icons with correct attributes", () => { @@ -147,12 +146,11 @@ describe("RuleStack Component", () => { "src", "/assets/Icon_Sociocracy.svg", ); - expect(sociocracyIcon).toHaveClass( - "md:w-[56px]", - "md:h-[56px]", - "lg:w-[90px]", - "lg:h-[90px]", - ); + // Check for responsive icon size classes + expect(sociocracyIcon?.className).toMatch(/min-\[640px\]:max-\[1023px\]:w-\[56px\]/); + expect(sociocracyIcon?.className).toMatch(/min-\[640px\]:max-\[1023px\]:h-\[56px\]/); + expect(sociocracyIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/); + expect(sociocracyIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/); }); test("applies different background colors to cards", () => { @@ -174,7 +172,9 @@ describe("RuleStack Component", () => { render(); const button = screen.getByRole("button", { name: "See all templates" }); - expect(button).toHaveClass("bg-transparent", "border-[1.5px]"); + // Button component uses outline variant which has bg-transparent and border + expect(button?.className).toMatch(/bg-transparent/); + expect(button?.className).toMatch(/border/); }); test("applies flex layout for button container", () => {