diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5ac471f..68b05d3 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,7 +2,7 @@ name: CI Pipeline run-name: "${{ gitea.actor }} triggered CI pipeline" on: - workflow_dispatch: {} # Manual trigger only - run tests locally before merging + workflow_dispatch: {} # Manual trigger only - run tests locally before merging # Auto-runs disabled for solo development # Re-enable when ready for collaborators: # pull_request: diff --git a/app/(dev)/components-preview/page.tsx b/app/(dev)/components-preview/page.tsx index f653013..f448360 100644 --- a/app/(dev)/components-preview/page.tsx +++ b/app/(dev)/components-preview/page.tsx @@ -1,12 +1,16 @@ "use client"; import { useState } from "react"; -import RuleCard from "../components/cards/RuleCard"; -import Chip from "../components/controls/Chip"; -import MultiSelect from "../components/controls/MultiSelect"; +import RuleCard from "../../components/cards/RuleCard"; +import Card from "../../components/cards/Card"; +import Chip from "../../components/controls/Chip"; +import MultiSelect from "../../components/controls/MultiSelect"; import Image from "next/image"; import { getAssetPath } from "../../../lib/assetUtils"; +/** Module-level counter for unique rule card chip IDs (avoids ref in initial state). */ +let ruleCardIdCounter = 0; + interface ChipData { id: string; label: string; @@ -17,7 +21,13 @@ interface ChipData { // MultiSelect example component with state management function MultiSelectExample({ size }: { size: "S" | "M" }) { - const [options, setOptions] = useState>([ + const [options, setOptions] = useState< + Array<{ + id: string; + label: string; + state: "Unselected" | "Selected" | "Custom"; + }> + >([ { id: "1", label: "1 member", state: "Unselected" }, { id: "2", label: "2-10 members", state: "Unselected" }, { id: "3", label: "10-24 members", state: "Unselected" }, @@ -35,17 +45,14 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) { ...opt, state: opt.state === "Selected" ? "Unselected" : "Selected", } - : opt - ) + : opt, + ), ); }; const handleAddClick = () => { const newId = `custom-${Date.now()}`; - setOptions((prev) => [ - ...prev, - { id: newId, label: "", state: "Custom" }, - ]); + setOptions((prev) => [...prev, { id: newId, label: "", state: "Custom" }]); }; const handleCustomConfirm = (chipId: string, value: string) => { @@ -53,8 +60,8 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) { prev.map((opt) => opt.id === chipId ? { ...opt, label: value, state: "Selected" as const } - : opt - ) + : opt, + ), ); }; @@ -83,28 +90,52 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) { } export default function ComponentsPreview() { - const [chipStates, setChipStates] = useState>({ + const [chipStates, setChipStates] = useState< + Record + >({ "default-s": "Unselected", "default-m": "Unselected", "inverse-s": "Unselected", "inverse-m": "Unselected", }); - + // Manage custom chips separately const [customChips, setCustomChips] = useState([ - { id: "custom-1", label: "", state: "Custom", palette: "Default", size: "S" }, - { id: "custom-2", label: "", state: "Custom", palette: "Default", size: "M" }, + { + id: "custom-1", + label: "", + state: "Custom", + palette: "Default", + size: "S", + }, + { + id: "custom-2", + label: "", + state: "Custom", + palette: "Default", + size: "M", + }, ]); // RuleCard categories with chip options and state management - const [ruleCardCategories, setRuleCardCategories] = useState; - onChipClick?: (categoryName: string, chipId: string) => void; - onAddClick?: (categoryName: string) => void; - onCustomChipConfirm?: (categoryName: string, chipId: string, value: string) => void; - onCustomChipClose?: (categoryName: string, chipId: string) => void; - }>>([ + const [ruleCardCategories, setRuleCardCategories] = useState< + Array<{ + name: string; + chipOptions: Array<{ + id: string; + label: string; + state: "Unselected" | "Selected" | "Custom"; + }>; + onChipClick?: (_categoryName: string, _chipId: string) => void; + onAddClick?: (_categoryName: string) => void; + onCustomChipConfirm?: ( + _categoryName: string, + _chipId: string, + _value: string, + ) => void; + onCustomChipClose?: (_categoryName: string, _chipId: string) => void; + }> + >([ { name: "Values", chipOptions: [ @@ -124,17 +155,20 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", + state: + opt.state === "Selected" + ? "Unselected" + : "Selected", } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onAddClick: (categoryName: string) => { - const newId = `custom-${categoryName}-${Date.now()}`; + const newId = `custom-${categoryName}-${++ruleCardIdCounter}`; setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -145,11 +179,15 @@ export default function ComponentsPreview() { { id: newId, label: "", state: "Custom" }, ], } - : cat - ) + : cat, + ), ); }, - onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + onCustomChipConfirm: ( + categoryName: string, + chipId: string, + value: string, + ) => { setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -158,11 +196,11 @@ export default function ComponentsPreview() { chipOptions: cat.chipOptions.map((opt) => opt.id === chipId ? { ...opt, label: value, state: "Selected" } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onCustomChipClose: (categoryName: string, chipId: string) => { @@ -171,18 +209,18 @@ export default function ComponentsPreview() { cat.name === categoryName ? { ...cat, - chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + chipOptions: cat.chipOptions.filter( + (opt) => opt.id !== chipId, + ), } - : cat - ) + : cat, + ), ); }, }, { name: "Communication", - chipOptions: [ - { id: "comm-1", label: "Signal", state: "Unselected" }, - ], + chipOptions: [{ id: "comm-1", label: "Signal", state: "Unselected" }], onChipClick: (categoryName: string, chipId: string) => { setRuleCardCategories((prev) => prev.map((cat) => @@ -193,17 +231,20 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", + state: + opt.state === "Selected" + ? "Unselected" + : "Selected", } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onAddClick: (categoryName: string) => { - const newId = `custom-${categoryName}-${Date.now()}`; + const newId = `custom-${categoryName}-${++ruleCardIdCounter}`; setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -214,11 +255,15 @@ export default function ComponentsPreview() { { id: newId, label: "", state: "Custom" }, ], } - : cat - ) + : cat, + ), ); }, - onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + onCustomChipConfirm: ( + categoryName: string, + chipId: string, + value: string, + ) => { setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -227,11 +272,11 @@ export default function ComponentsPreview() { chipOptions: cat.chipOptions.map((opt) => opt.id === chipId ? { ...opt, label: value, state: "Selected" } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onCustomChipClose: (categoryName: string, chipId: string) => { @@ -240,10 +285,12 @@ export default function ComponentsPreview() { cat.name === categoryName ? { ...cat, - chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + chipOptions: cat.chipOptions.filter( + (opt) => opt.id !== chipId, + ), } - : cat - ) + : cat, + ), ); }, }, @@ -262,17 +309,20 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", + state: + opt.state === "Selected" + ? "Unselected" + : "Selected", } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onAddClick: (categoryName: string) => { - const newId = `custom-${categoryName}-${Date.now()}`; + const newId = `custom-${categoryName}-${++ruleCardIdCounter}`; setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -283,11 +333,15 @@ export default function ComponentsPreview() { { id: newId, label: "", state: "Custom" }, ], } - : cat - ) + : cat, + ), ); }, - onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + onCustomChipConfirm: ( + categoryName: string, + chipId: string, + value: string, + ) => { setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -296,11 +350,11 @@ export default function ComponentsPreview() { chipOptions: cat.chipOptions.map((opt) => opt.id === chipId ? { ...opt, label: value, state: "Selected" } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onCustomChipClose: (categoryName: string, chipId: string) => { @@ -309,10 +363,12 @@ export default function ComponentsPreview() { cat.name === categoryName ? { ...cat, - chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + chipOptions: cat.chipOptions.filter( + (opt) => opt.id !== chipId, + ), } - : cat - ) + : cat, + ), ); }, }, @@ -332,17 +388,20 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", + state: + opt.state === "Selected" + ? "Unselected" + : "Selected", } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onAddClick: (categoryName: string) => { - const newId = `custom-${categoryName}-${Date.now()}`; + const newId = `custom-${categoryName}-${++ruleCardIdCounter}`; setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -353,11 +412,15 @@ export default function ComponentsPreview() { { id: newId, label: "", state: "Custom" }, ], } - : cat - ) + : cat, + ), ); }, - onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + onCustomChipConfirm: ( + categoryName: string, + chipId: string, + value: string, + ) => { setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -366,11 +429,11 @@ export default function ComponentsPreview() { chipOptions: cat.chipOptions.map((opt) => opt.id === chipId ? { ...opt, label: value, state: "Selected" } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onCustomChipClose: (categoryName: string, chipId: string) => { @@ -379,10 +442,12 @@ export default function ComponentsPreview() { cat.name === categoryName ? { ...cat, - chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + chipOptions: cat.chipOptions.filter( + (opt) => opt.id !== chipId, + ), } - : cat - ) + : cat, + ), ); }, }, @@ -402,17 +467,20 @@ export default function ComponentsPreview() { opt.id === chipId ? { ...opt, - state: opt.state === "Selected" ? "Unselected" : "Selected", + state: + opt.state === "Selected" + ? "Unselected" + : "Selected", } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onAddClick: (categoryName: string) => { - const newId = `custom-${categoryName}-${Date.now()}`; + const newId = `custom-${categoryName}-${++ruleCardIdCounter}`; setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -423,11 +491,15 @@ export default function ComponentsPreview() { { id: newId, label: "", state: "Custom" }, ], } - : cat - ) + : cat, + ), ); }, - onCustomChipConfirm: (categoryName: string, chipId: string, value: string) => { + onCustomChipConfirm: ( + categoryName: string, + chipId: string, + value: string, + ) => { setRuleCardCategories((prev) => prev.map((cat) => cat.name === categoryName @@ -436,11 +508,11 @@ export default function ComponentsPreview() { chipOptions: cat.chipOptions.map((opt) => opt.id === chipId ? { ...opt, label: value, state: "Selected" } - : opt + : opt, ), } - : cat - ) + : cat, + ), ); }, onCustomChipClose: (categoryName: string, chipId: string) => { @@ -449,10 +521,12 @@ export default function ComponentsPreview() { cat.name === categoryName ? { ...cat, - chipOptions: cat.chipOptions.filter((opt) => opt.id !== chipId), + chipOptions: cat.chipOptions.filter( + (opt) => opt.id !== chipId, + ), } - : cat - ) + : cat, + ), ); }, }, @@ -466,7 +540,8 @@ export default function ComponentsPreview() { Component Preview

- RuleCard and Chip component examples - states, palettes, sizes, and interactions + RuleCard, Card, and Chip component examples - states, palettes, + sizes, and interactions

@@ -480,7 +555,7 @@ export default function ComponentsPreview() {

Default palette -

+
setChipStates((prev) => ({ ...prev, - "default-s": prev["default-s"] === "Selected" ? "Unselected" : "Selected", + "default-s": + prev["default-s"] === "Selected" + ? "Unselected" + : "Selected", })) } /> @@ -502,7 +580,10 @@ export default function ComponentsPreview() { onClick={() => setChipStates((prev) => ({ ...prev, - "default-m": prev["default-m"] === "Selected" ? "Unselected" : "Selected", + "default-m": + prev["default-m"] === "Selected" + ? "Unselected" + : "Selected", })) } /> @@ -527,27 +608,35 @@ export default function ComponentsPreview() { prev.map((c) => c.id === chip.id ? { ...c, label: value, state: "Selected" } - : c - ) + : c, + ), ); }} onClose={(e) => { e.stopPropagation(); - setCustomChips((prev) => prev.filter((c) => c.id !== chip.id)); + setCustomChips((prev) => + prev.filter((c) => c.id !== chip.id), + ); }} onClick={(e) => { e.stopPropagation(); // Only toggle if the chip is in Selected or Unselected state (not Custom) - if (chip.state === "Selected" || chip.state === "Unselected") { + if ( + chip.state === "Selected" || + chip.state === "Unselected" + ) { setCustomChips((prev) => prev.map((c) => c.id === chip.id ? { ...c, - state: c.state === "Selected" ? "Unselected" : "Selected", + state: + c.state === "Selected" + ? "Unselected" + : "Selected", } - : c - ) + : c, + ), ); } }} @@ -560,7 +649,13 @@ export default function ComponentsPreview() { const newId = `custom-${Date.now()}`; setCustomChips((prev) => [ ...prev, - { id: newId, label: "", state: "Custom", palette: "Default", size: "S" }, + { + id: newId, + label: "", + state: "Custom", + palette: "Default", + size: "S", + }, ]); }} className="flex gap-[var(--measures-spacing-050,2px)] items-center justify-center p-[var(--measures-spacing-200,8px)] rounded-[var(--measures-radius-full,9999px)] shrink-0 hover:opacity-80 transition-opacity" @@ -591,11 +686,14 @@ export default function ComponentsPreview() {
{/* Inverse palette - on white background */} -
+

Inverse palette (on white background)

-
+
setChipStates((prev) => ({ ...prev, - "inverse-s": prev["inverse-s"] === "Selected" ? "Unselected" : "Selected", + "inverse-s": + prev["inverse-s"] === "Selected" + ? "Unselected" + : "Selected", })) } /> @@ -617,7 +718,10 @@ export default function ComponentsPreview() { onClick={() => setChipStates((prev) => ({ ...prev, - "inverse-m": prev["inverse-m"] === "Selected" ? "Unselected" : "Selected", + "inverse-m": + prev["inverse-m"] === "Selected" + ? "Unselected" + : "Selected", })) } /> @@ -633,6 +737,75 @@ export default function ComponentsPreview() {
+ {/* Card Component - Create flow selection card variants */} +
+

+ Card Component +

+
+

+ Horizontal and vertical orientations with recommended and selected + states. +

+
+
+

+ Horizontal + Recommended +

+ console.warn("Card clicked")} + /> +
+
+

+ Horizontal + Selected +

+ console.warn("Card clicked")} + /> +
+
+

+ Vertical + Recommended +

+ console.warn("Card clicked")} + /> +
+
+

+ Vertical + Selected +

+ console.warn("Card clicked")} + /> +
+
+
+
+ {/* Collapsed State - Large */}

@@ -648,9 +821,9 @@ export default function ComponentsPreview() { className="w-[525px]" logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png" logoAlt="Mutual Aid Mondays" - onClick={() => console.log("Card clicked: Mutual Aid Mondays")} - /> -

+ onClick={() => console.warn("Card clicked: Mutual Aid Mondays")} + /> +
{/* Collapsed State - Medium */} @@ -668,17 +841,17 @@ export default function ComponentsPreview() { className="w-[289px]" logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png" logoAlt="Mutual Aid Mondays" - onClick={() => console.log("Card clicked: Mutual Aid Mondays")} - /> + onClick={() => console.warn("Card clicked: Mutual Aid Mondays")} + />
{/* Expanded State - Large */} -
-

+
+

Expanded State - Large (L) -

-
+

+
console.log("Card clicked: Mutual Aid Mondays")} - /> -
+ onClick={() => console.warn("Card clicked: Mutual Aid Mondays")} + /> +
{/* Expanded State - Medium */} @@ -710,16 +883,16 @@ export default function ComponentsPreview() { logoUrl="http://localhost:3845/assets/d2513a6ab56f2b2927e8a7c442c06326e7a29541.png" logoAlt="Mutual Aid Mondays" categories={ruleCardCategories} - onClick={() => console.log("Card clicked: Mutual Aid Mondays")} + onClick={() => console.warn("Card clicked: Mutual Aid Mondays")} /> -
- + + {/* Different Background Colors */} -
-

+
+

Different Background Colors -

+

} - onClick={() => console.log("Consensus clusters selected")} + onClick={() => console.warn("Consensus clusters selected")} /> } - onClick={() => console.log("Consensus selected")} - /> -
-
+ onClick={() => console.warn("Consensus selected")} + /> + +
{/* Logo Fallback */} @@ -774,9 +947,9 @@ export default function ComponentsPreview() { size="L" className="w-[525px]" communityInitials="CE" - onClick={() => console.log("Community Example selected")} - /> - + onClick={() => console.warn("Community Example selected")} + /> + {/* MultiSelect Component */} @@ -787,7 +960,7 @@ export default function ComponentsPreview() {
{/* Small size */} - + {/* Medium size */}
diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index bdd91e2..a2a7c21 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -189,7 +189,7 @@ export default async function BlogPostPage({ params }: PageProps) { url: "https://communityrule.com", logo: { "@type": "ImageObject", - url: "https://communityrule.com/assets/Logo.svg", + url: "https://communityrule.com/assets/logo/Logo.svg", }, }, datePublished: post.frontmatter.date, diff --git a/app/(marketing)/blog/page.tsx b/app/(marketing)/blog/page.tsx index 62c0ad8..c6f633a 100644 --- a/app/(marketing)/blog/page.tsx +++ b/app/(marketing)/blog/page.tsx @@ -1,5 +1,5 @@ import { getAllBlogPosts } from "../../../lib/content"; -import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate"; +import ContentThumbnailTemplate from "../../components/content/ContentThumbnailTemplate"; import type { Metadata } from "next"; export const metadata: Metadata = { diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 525b6ef..7daabfd 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -12,12 +12,15 @@ const LogoWall = dynamic(() => import("../components/sections/LogoWall"), { ssr: true, }); -const NumberedCards = dynamic(() => import("../components/sections/NumberedCards"), { - loading: () => ( -
- ), - ssr: true, -}); +const NumberedCards = dynamic( + () => import("../components/sections/NumberedCards"), + { + loading: () => ( +
+ ), + ssr: true, + }, +); const RuleStack = dynamic(() => import("../components/sections/RuleStack"), { loading: () => ( @@ -26,12 +29,15 @@ const RuleStack = dynamic(() => import("../components/sections/RuleStack"), { ssr: true, }); -const FeatureGrid = dynamic(() => import("../components/sections/FeatureGrid"), { - loading: () => ( -
- ), - ssr: true, -}); +const FeatureGrid = dynamic( + () => import("../components/sections/FeatureGrid"), + { + loading: () => ( +
+ ), + ssr: true, + }, +); const QuoteBlock = dynamic(() => import("../components/sections/QuoteBlock"), { loading: () => ( diff --git a/app/components/ContextMenu/ContextMenuItem/ContextMenuItem.types.ts b/app/components/ContextMenu/ContextMenuItem/ContextMenuItem.types.ts index ab57073..c78a934 100644 --- a/app/components/ContextMenu/ContextMenuItem/ContextMenuItem.types.ts +++ b/app/components/ContextMenu/ContextMenuItem/ContextMenuItem.types.ts @@ -1,4 +1,10 @@ -export type ContextMenuItemSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large"; +export type ContextMenuItemSizeValue = + | "small" + | "medium" + | "large" + | "Small" + | "Medium" + | "Large"; export interface ContextMenuItemProps extends React.HTMLAttributes { children?: React.ReactNode; diff --git a/app/components/WebVitalsDashboard/WebVitalsDashboard.container.tsx b/app/components/WebVitalsDashboard/WebVitalsDashboard.container.tsx index 0b9ea0c..9edf346 100644 --- a/app/components/WebVitalsDashboard/WebVitalsDashboard.container.tsx +++ b/app/components/WebVitalsDashboard/WebVitalsDashboard.container.tsx @@ -40,7 +40,25 @@ const WebVitalsDashboardContainer = memo(() => { if (typeof window !== "undefined") { import("web-vitals").then((webVitals) => { - const { getCLS, getFID, getFCP, getLCP, getTTFB } = webVitals as any; + // web-vitals v4 typings don't expose legacy get* names the same way; runtime bundle still provides them for this dashboard. + const { getCLS, getFID, getFCP, getLCP, getTTFB } = + webVitals as unknown as { + getCLS: ( + _fn: (_m: { value: number; rating: string }) => void, + ) => void; + getFID: ( + _fn: (_m: { value: number; rating: string }) => void, + ) => void; + getFCP: ( + _fn: (_m: { value: number; rating: string }) => void, + ) => void; + getLCP: ( + _fn: (_m: { value: number; rating: string }) => void, + ) => void; + getTTFB: ( + _fn: (_m: { value: number; rating: string }) => void, + ) => void; + }; getLCP((metric: { value: number; rating: VitalData["rating"] }) => { setVitals((prev) => ({ diff --git a/app/components/asset/Icon.tsx b/app/components/asset/Icon.tsx new file mode 100644 index 0000000..6adaea1 --- /dev/null +++ b/app/components/asset/Icon.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { memo } from "react"; +import ExclamationIcon from "./icon/exclamation.svg"; + +export type IconName = "exclamation"; + +/** SVG import may be a React component or a module object { default: Component } (e.g. with Turbopack) */ +const iconMap: Record< + IconName, + | React.ComponentType> + | { default: React.ComponentType> } +> = { + exclamation: ExclamationIcon, +}; + +export interface IconProps { + name: IconName; + className?: string; + /** Width and height (default 24) */ + size?: number; + "aria-hidden"?: boolean; +} + +function IconComponent({ + name, + className = "", + size = 24, + "aria-hidden": ariaHidden = true, +}: IconProps) { + const SvgModule = iconMap[name]; + if (!SvgModule) return null; + // Turbopack/bundler may expose SVG as { default: Component } instead of the component directly + const Svg = + typeof SvgModule === "object" && + SvgModule !== null && + "default" in SvgModule + ? ( + SvgModule as { + default: React.ComponentType>; + } + ).default + : (SvgModule as React.ComponentType>); + if (typeof Svg !== "function") return null; + return ( + + ); +} + +export default memo(IconComponent); diff --git a/app/components/asset/icon/exclamation.svg b/app/components/asset/icon/exclamation.svg new file mode 100644 index 0000000..ec01856 --- /dev/null +++ b/app/components/asset/icon/exclamation.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/asset/index.ts b/app/components/asset/index.ts new file mode 100644 index 0000000..47bc8b8 --- /dev/null +++ b/app/components/asset/index.ts @@ -0,0 +1,3 @@ +export { default as Icon } from "./Icon"; +export type { IconName, IconProps } from "./Icon"; +export { default as Logo } from "./logo"; diff --git a/app/components/asset/logo/Logo.tsx b/app/components/asset/logo/Logo.tsx new file mode 100644 index 0000000..1e494f0 --- /dev/null +++ b/app/components/asset/logo/Logo.tsx @@ -0,0 +1,134 @@ +import { memo } from "react"; +import Link from "next/link"; +import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; + +interface LogoProps { + size?: + | "default" + | "footer" + | "createFlow" + | "topNavFolderTop" + | "topNavHeader"; + /** + * Visual style: default (dark on light) or inverse (e.g. black/white on teal). + * @default "default" + */ + palette?: "default" | "inverse"; + /** + * Whether to show the "CommunityRule" wordmark. + * @default true + */ + wordmark?: boolean; +} + +interface SizeConfig { + containerHeight: string; + gap: string; + textSize: string; + lineHeight: string; + iconSize: string; +} + +const Logo = memo( + ({ size = "default", palette = "default", wordmark = true }) => { + // Size configurations + const sizes: Record = { + default: { + containerHeight: "h-[41px]", + gap: "gap-[8.28px]", + textSize: "text-[21.97px]", + lineHeight: "leading-[27.05px]", + iconSize: "w-[27.05px] h-[27.05px]", + }, + footer: { + containerHeight: + "h-[41px] sm:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]", + gap: "gap-[8.28px] sm:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]", + textSize: + "text-[21.97px] sm:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]", + lineHeight: + "leading-[27.05px] sm:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]", + iconSize: + "w-[27.05px] h-[27.05px] sm:w-[calc(27.05px*1.37)] sm:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]", + }, + createFlow: { + containerHeight: "h-[30px] md:h-[41px]", + gap: "gap-[6px] md:gap-[8.28px]", + textSize: "text-[16.48px] md:text-[21.97px]", + lineHeight: "leading-[20.28px] md:leading-[27.05px]", + iconSize: "w-[20.28px] h-[20.28px] md:w-[27.05px] md:h-[27.05px]", + }, + topNavFolderTop: { + containerHeight: + "h-[14.11px] sm:h-[21.06px] md:h-[32.24px] lg:h-[28px] xl:h-[36px]", + gap: "gap-0 sm:gap-[3.19px] md:gap-[4.89px] lg:gap-[6.55px] xl:gap-[8.64px]", + textSize: + "text-[11.57px] sm:text-[11.69px] md:text-[17.89px] lg:text-[21.97px] xl:text-[29.01px]", + lineHeight: + "leading-[14.24px] sm:leading-[14.39px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[35.7px]", + iconSize: + "w-[14.11px] h-[14.11px] sm:w-[14.39px] sm:h-[14.39px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[35.7px] xl:h-[35.7px]", + }, + topNavHeader: { + containerHeight: + "h-[20.85px] sm:h-[20.85px] md:h-[17.91px] lg:h-[28px] xl:h-[34px]", + gap: "gap-0 sm:gap-[4.21px] md:gap-[6.51px] lg:gap-[6.55px] xl:gap-[8.19px]", + textSize: + "text-[11.57px] sm:text-[11.57px] md:text-[17.89px] lg:text-[21.97px] xl:text-[27.47px]", + lineHeight: + "leading-[14.24px] sm:leading-[14.24px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[33.81px]", + iconSize: + "w-[14.24px] h-[14.24px] sm:w-[14.24px] sm:h-[14.24px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[33.81px] xl:h-[33.81px]", + }, + }; + + const config = sizes[size || "default"] || sizes.default; + const isInverse = palette === "inverse"; + const textColorClass = isInverse + ? "text-[var(--color-content-invert-primary)]" + : "text-[var(--color-content-default-primary)]"; + const wordmarkVisibilityClass = + size === "topNavFolderTop" || size === "topNavHeader" + ? wordmark + ? "hidden sm:block" + : "hidden" + : wordmark + ? "" + : "hidden"; + + return ( + +
+ {/* Logo Text - responsive visibility for topNav sizes */} +
+ CommunityRule +
+ + {/* Vector Icon */} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ); + }, +); + +Logo.displayName = "Logo"; + +export default Logo; diff --git a/app/components/asset/logo/index.ts b/app/components/asset/logo/index.ts new file mode 100644 index 0000000..f252704 --- /dev/null +++ b/app/components/asset/logo/index.ts @@ -0,0 +1 @@ +export { default } from "./Logo"; diff --git a/app/components/buttons/Button.tsx b/app/components/buttons/Button.tsx index 2aa2c6a..50b8c2d 100644 --- a/app/components/buttons/Button.tsx +++ b/app/components/buttons/Button.tsx @@ -109,15 +109,11 @@ const Button = memo( const variant = getVariantFromTypeAndPalette(buttonType, buttonPalette); const sizeStyles: Record = { - xsmall: - "p-[var(--spacing-scale-004)] gap-[var(--spacing-scale-002)]", - small: - "p-[var(--spacing-scale-008)] gap-[var(--spacing-scale-002)]", + xsmall: "p-[var(--spacing-scale-004)] gap-[var(--spacing-scale-002)]", + small: "p-[var(--spacing-scale-008)] gap-[var(--spacing-scale-002)]", medium: "p-[var(--spacing-scale-010)] gap-[var(--spacing-scale-004)]", - large: - "p-[var(--spacing-scale-012)] gap-[var(--spacing-scale-006)]", - xlarge: - "p-[var(--spacing-scale-016)] gap-[var(--spacing-scale-008)]", + large: "p-[var(--spacing-scale-012)] gap-[var(--spacing-scale-006)]", + xlarge: "p-[var(--spacing-scale-016)] gap-[var(--spacing-scale-008)]", }; const fontStyles: Record = { @@ -135,7 +131,8 @@ const Button = memo( "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", outline: "bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-invert-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", - "outline-inverse": "bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-default-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "outline-inverse": + "bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-default-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", ghost: "bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", "ghost-inverse": diff --git a/app/components/cards/Card/Card.container.tsx b/app/components/cards/Card/Card.container.tsx new file mode 100644 index 0000000..e18dc90 --- /dev/null +++ b/app/components/cards/Card/Card.container.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { memo } from "react"; +import { CardView } from "./Card.view"; +import type { CardProps } from "./Card.types"; + +const CardContainer = memo( + ({ + label, + supportText = "", + recommended = false, + selected = false, + orientation = "horizontal", + showInfoIcon = false, + id, + className = "", + onClick, + }) => { + const handleClick = () => { + if (onClick) onClick(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleClick(); + } + }; + + return ( + + ); + }, +); + +CardContainer.displayName = "Card"; + +export default CardContainer; diff --git a/app/components/cards/Card/Card.types.ts b/app/components/cards/Card/Card.types.ts new file mode 100644 index 0000000..42efdeb --- /dev/null +++ b/app/components/cards/Card/Card.types.ts @@ -0,0 +1,25 @@ +export interface CardProps { + label: string; + supportText?: string; + recommended?: boolean; + selected?: boolean; + orientation: "horizontal" | "vertical"; + showInfoIcon?: boolean; + /** Optional id for the card root (e.g. data-card-id for focus after modal close). */ + id?: string; + className?: string; + onClick?: () => void; +} + +export interface CardViewProps { + label: string; + supportText: string; + recommended: boolean; + selected: boolean; + orientation: "horizontal" | "vertical"; + showInfoIcon: boolean; + id: string | undefined; + className: string; + onClick: () => void; + onKeyDown: (event: React.KeyboardEvent) => void; +} diff --git a/app/components/cards/Card/Card.view.tsx b/app/components/cards/Card/Card.view.tsx new file mode 100644 index 0000000..e9ba02d --- /dev/null +++ b/app/components/cards/Card/Card.view.tsx @@ -0,0 +1,101 @@ +"use client"; + +import Tag from "../../utility/Tag"; +import type { CardViewProps } from "./Card.types"; + +function InfoIcon() { + return ( + + ? + + ); +} + +function CardTag({ + recommended, + selected, +}: { + recommended: boolean; + selected: boolean; +}) { + if (selected) return ; + if (recommended) return ; + return null; +} + +export function CardView({ + label, + supportText, + recommended, + selected, + orientation, + showInfoIcon, + id: cardId, + className, + onClick, + onKeyDown, +}: CardViewProps) { + const borderClass = "border border-[var(--color-border-default-primary)]"; + const selectedBorder = selected + ? "outline outline-2 outline-dashed outline-black outline-offset-[-2px]" + : ""; + const baseClasses = `select-none rounded-[var(--radius-measures-radius-small)] bg-[#FFFFFF] p-4 transition-[border-color,box-shadow,outline] duration-200 cursor-pointer ${borderClass} ${selectedBorder} ${className}`; + + if (orientation === "horizontal") { + return ( +
+
+ + + {label} + + {supportText ? ( +

+ {supportText} +

+ ) : null} +
+
+ ); + } + + return ( +
+
+
+ + {label} + + {showInfoIcon ? : null} +
+ {supportText ? ( +

+ {supportText} +

+ ) : null} +
+
+ +
+
+ ); +} diff --git a/app/components/cards/Card/index.tsx b/app/components/cards/Card/index.tsx new file mode 100644 index 0000000..160e0bf --- /dev/null +++ b/app/components/cards/Card/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Card.container"; +export type { CardProps } from "./Card.types"; diff --git a/app/components/cards/NumberCard.tsx b/app/components/cards/NumberCard.tsx index cddb2b5..5d0e311 100644 --- a/app/components/cards/NumberCard.tsx +++ b/app/components/cards/NumberCard.tsx @@ -29,7 +29,8 @@ interface NumberCardProps { const NumberCard = memo(({ number, text, size: sizeProp }) => { // Base classes common to all sizes - const baseClasses = "bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg"; + const baseClasses = + "bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg"; // If size prop is provided, use explicit size classes // Otherwise, use responsive breakpoints for backward compatibility @@ -40,16 +41,22 @@ const NumberCard = memo(({ number, text, size: sizeProp }) => { const sizeClasses = { Small: "flex flex-col items-end justify-center gap-4 p-5 relative", Medium: "flex flex-row items-center gap-8 p-8 relative", - Large: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative", - XLarge: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative", + Large: + "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative", + XLarge: + "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative", }; // Text size classes const textClasses = { - Small: "font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]", - Medium: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]", - Large: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]", - XLarge: "font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]", + Small: + "font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]", + Medium: + "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]", + Large: + "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]", + XLarge: + "font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]", }; // Section number wrapper classes - Small doesn't need a wrapper @@ -74,11 +81,9 @@ const NumberCard = memo(({ number, text, size: sizeProp }) => {
{/* Section Number - Direct child for Small */} - + {/* Card Content */} -

- {text} -

+

{text}

); } @@ -92,9 +97,7 @@ const NumberCard = memo(({ number, text, size: sizeProp }) => { {/* Card Content */}
-

- {text} -

+

{text}

); @@ -103,7 +106,9 @@ const NumberCard = memo(({ number, text, size: sizeProp }) => { // Responsive breakpoints for backward compatibility (matches original behavior) // Maps to: Small (mobile) -> Medium (sm) -> Large (lg) -> XLarge (xl) return ( -
+
{/* Section Number - Responsive positioning */}
diff --git a/app/components/cards/RuleCard/RuleCard.types.ts b/app/components/cards/RuleCard/RuleCard.types.ts index 516f9ed..c766789 100644 --- a/app/components/cards/RuleCard/RuleCard.types.ts +++ b/app/components/cards/RuleCard/RuleCard.types.ts @@ -5,7 +5,11 @@ export interface Category { chipOptions: ChipOption[]; onChipClick?: (categoryName: string, chipId: string) => void; onAddClick?: (categoryName: string) => void; - onCustomChipConfirm?: (categoryName: string, chipId: string, value: string) => void; + onCustomChipConfirm?: ( + categoryName: string, + chipId: string, + value: string, + ) => void; onCustomChipClose?: (categoryName: string, chipId: string) => void; } diff --git a/app/components/cards/RuleCard/RuleCard.view.tsx b/app/components/cards/RuleCard/RuleCard.view.tsx index 1a0ab30..dd7af0c 100644 --- a/app/components/cards/RuleCard/RuleCard.view.tsx +++ b/app/components/cards/RuleCard/RuleCard.view.tsx @@ -28,34 +28,39 @@ export function RuleCardView({ const isMedium = size === "M"; const isSmall = size === "S"; const isExtraSmall = size === "XS"; - + // 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 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 + ? "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 - ? "gap-[12px]" - : "gap-[18px]"; // XS and S: 18px gap + ? "" // If className has responsive gap, don't add size-based gap + : isLarge + ? "gap-[10px]" + : isMedium + ? "gap-[12px]" + : "gap-[18px]"; // XS and S: 18px gap const cardWidth = expanded ? isLarge ? "w-[568px]" : isMedium - ? "w-[398px]" - : "" // XS and S: no fixed width + ? "w-[398px]" + : "" // XS and S: no fixed width : ""; // Logo/Icon dimensions - use CSS responsive classes @@ -81,19 +86,21 @@ export function RuleCardView({ const descriptionClass = isLarge ? "font-inter font-medium text-[18px] leading-[24px]" : isMedium - ? "font-inter font-medium text-[14px] leading-[16px]" - : isSmall - ? "font-inter font-medium text-[14px] leading-[16px]" // S: 14px, medium, Inter - : "font-inter font-medium text-[12px] leading-[14px]"; // XS: 12px, medium, Inter + ? "font-inter font-medium text-[14px] leading-[16px]" + : isSmall + ? "font-inter font-medium text-[14px] leading-[16px]" // S: 14px, medium, Inter + : "font-inter font-medium text-[12px] leading-[14px]"; // XS: 12px, medium, Inter // Render logo/icon const renderLogo = () => { if (logoUrl) { // 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 isLocalhost = + logoUrl.startsWith("http://localhost") || + logoUrl.startsWith("https://localhost"); + const containerClass = `${logoContainerClass} relative rounded-full overflow-hidden mix-blend-luminosity max-[639px]:p-[16px] min-[640px]:max-[1023px]:p-[12px]`; - + if (isLocalhost) { return (
@@ -108,7 +115,7 @@ export function RuleCardView({
); } - + return (
); } - + if (icon) { return ( -
+
{icon}
); } - + if (communityInitials) { const initialsSize = ` max-[639px]:text-[16px] @@ -138,26 +147,29 @@ export function RuleCardView({ min-[1440px]:text-[36px] `; return ( -
- +
+ {communityInitials}
); } - + return null; }; - // Border radius - use CSS classes if provided via className, otherwise use size-based logic - const borderRadiusClass = className?.includes("rounded-") + 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)]"; + : 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 */} -
+ " + /> {/* Container with no padding and left border - extends full height to touch bottom */} {title && ( -
+ `} + > {/* Inner container for header text with padding */} -
-

- {title} -

+ `} + > +

+ {title} +

)} @@ -237,7 +261,11 @@ export function RuleCardView({ category.onAddClick?.(category.name); }} onCustomChipConfirm={(chipId, value) => { - category.onCustomChipConfirm?.(category.name, chipId, value); + category.onCustomChipConfirm?.( + category.name, + chipId, + value, + ); }} onCustomChipClose={(chipId) => { category.onCustomChipClose?.(category.name, chipId); @@ -250,11 +278,9 @@ export function RuleCardView({
)} {/* Footer: Description */} - {description && ( + {description && (
-

- {description} -

+

{description}

)} @@ -263,8 +289,8 @@ export function RuleCardView({ description && (

- {description} -

+ {description} +

) )} diff --git a/app/components/content/ContentContainer/ContentContainer.types.ts b/app/components/content/ContentContainer/ContentContainer.types.ts index 18716f8..7fbe41d 100644 --- a/app/components/content/ContentContainer/ContentContainer.types.ts +++ b/app/components/content/ContentContainer/ContentContainer.types.ts @@ -1,6 +1,10 @@ import type { BlogPost } from "../../../../lib/content"; -export type ContentContainerSizeValue = "xs" | "responsive" | "Xs" | "Responsive"; +export type ContentContainerSizeValue = + | "xs" + | "responsive" + | "Xs" + | "Responsive"; export interface ContentContainerProps { post: BlogPost; diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts index 3c8fe75..425ee5b 100644 --- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts +++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts @@ -1,6 +1,10 @@ import type { BlogPost } from "../../../../lib/content"; -export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal" | "Vertical" | "Horizontal"; +export type ContentThumbnailTemplateVariantValue = + | "vertical" + | "horizontal" + | "Vertical" + | "Horizontal"; export interface ContentThumbnailTemplateProps { post: BlogPost; diff --git a/app/components/controls/Checkbox/Checkbox.container.tsx b/app/components/controls/Checkbox/Checkbox.container.tsx index 7650f3a..df9e74d 100644 --- a/app/components/controls/Checkbox/Checkbox.container.tsx +++ b/app/components/controls/Checkbox/Checkbox.container.tsx @@ -4,7 +4,10 @@ import { memo } from "react"; import { useComponentId } from "../../../hooks"; import { CheckboxView } from "./Checkbox.view"; import type { CheckboxProps } from "./Checkbox.types"; -import { normalizeMode, normalizeState } from "../../../../lib/propNormalization"; +import { + normalizeMode, + normalizeState, +} from "../../../../lib/propNormalization"; const CheckboxContainer = memo( ({ @@ -24,7 +27,7 @@ const CheckboxContainer = memo( // Normalize props to handle both PascalCase (Figma) and lowercase (codebase) const mode = normalizeMode(modeProp); const state = normalizeState(stateProp); - + const isInverse = mode === "inverse"; const isStandard = mode === "standard"; @@ -43,7 +46,9 @@ const CheckboxContainer = memo( transition-all duration-200 ease-in-out - `.trim().replace(/\s+/g, " "); + ` + .trim() + .replace(/\s+/g, " "); // Get box styles based on state and checked status per Figma designs const getBoxStyles = (): string => { diff --git a/app/components/controls/CheckboxGroup/CheckboxGroup.container.tsx b/app/components/controls/CheckboxGroup/CheckboxGroup.container.tsx index 2fac6e8..ea96562 100644 --- a/app/components/controls/CheckboxGroup/CheckboxGroup.container.tsx +++ b/app/components/controls/CheckboxGroup/CheckboxGroup.container.tsx @@ -22,8 +22,10 @@ const CheckboxGroupContainer = ({ const groupId = name || `checkbox-group-${generatedId}`; // Internal state to track checked values (only used if value prop is not provided) - const [internalCheckedValues, setInternalCheckedValues] = useState([]); - + const [internalCheckedValues, setInternalCheckedValues] = useState( + [], + ); + // Use controlled value if provided, otherwise use internal state const checkedValues = value !== undefined ? value : internalCheckedValues; diff --git a/app/components/controls/CheckboxGroup/CheckboxGroup.view.tsx b/app/components/controls/CheckboxGroup/CheckboxGroup.view.tsx index 8c2d7e0..b43b6f4 100644 --- a/app/components/controls/CheckboxGroup/CheckboxGroup.view.tsx +++ b/app/components/controls/CheckboxGroup/CheckboxGroup.view.tsx @@ -23,10 +23,7 @@ export function CheckboxGroupView({ // If there's subtext, render checkbox without label and handle layout separately if (option.subtext) { return ( -
+
( } }, [isCustom]); - const handleCheck = (value: string, event: React.MouseEvent) => { + const handleCheck = ( + value: string, + event: React.MouseEvent, + ) => { if (onCheck && value.trim()) { onCheck(value.trim(), event); // Reset input after successful check @@ -63,7 +66,10 @@ const ChipContainer = memo( const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && inputValue.trim() && onCheck) { event.preventDefault(); - handleCheck(inputValue.trim(), event as unknown as React.MouseEvent); + handleCheck( + inputValue.trim(), + event as unknown as React.MouseEvent, + ); } else if (event.key === "Escape" && onClose) { event.preventDefault(); handleClose(event as unknown as React.MouseEvent); @@ -95,4 +101,3 @@ const ChipContainer = memo( ChipContainer.displayName = "Chip"; export default ChipContainer; - diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts index 13ca6ed..dab6332 100644 --- a/app/components/controls/Chip/Chip.types.ts +++ b/app/components/controls/Chip/Chip.types.ts @@ -68,4 +68,3 @@ export interface ChipViewProps { inputRef?: React.RefObject; ariaLabel?: string; } - diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx index 2402994..a393411 100644 --- a/app/components/controls/Chip/Chip.view.tsx +++ b/app/components/controls/Chip/Chip.view.tsx @@ -42,32 +42,26 @@ function ChipView({ // Palette + state styling based on Figma examples // Use consistent border width to prevent layout shift const borderWidth = isSmall ? "border-[1.25px]" : "border-2"; - - let background = "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]"; - let border = - `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`; + + let background = + "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]"; + let border = `${borderWidth} border-[var(--color-border-default-tertiary,#464646)]`; let textColor = "text-[color:var(--color-content-default-brand-primary,#fefcc9)]"; if (isDefault) { if (state === "custom") { - background = - "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom + background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background for custom border = "border-none"; - textColor = - "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; + textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; } else if (state === "disabled") { - background = - "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background + background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background border = "border-none"; - textColor = - "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; + textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"; } else if (isSelected) { - background = - "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected + background = "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`; - textColor = - "text-[color:var(--color-content-inverse-primary,black)]"; + textColor = "text-[color:var(--color-content-inverse-primary,black)]"; } else { // Unselected default background = @@ -78,24 +72,20 @@ function ChipView({ } } else if (isInverse) { if (state === "disabled") { - background = - "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]"; + background = "bg-[var(--color-surface-inverse-tertiary,#d2d2d2)]"; border = "border-none"; - textColor = - "text-[color:var(--color-content-inverse-primary,black)]"; + textColor = "text-[color:var(--color-content-inverse-primary,black)]"; } else if (isSelected) { background = "bg-[var(--color-surface-default-semi-opaque,rgba(0,0,0,0.05))]"; border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`; - textColor = - "text-[color:var(--color-content-inverse-primary,black)]"; + textColor = "text-[color:var(--color-content-inverse-primary,black)]"; } else { // Unselected / custom inverse background = "bg-[var(--color-surface-default-transparent,rgba(0,0,0,0))]"; border = `${borderWidth} border-[var(--color-border-default-primary,#141414)]`; - textColor = - "text-[color:var(--color-content-inverse-primary,black)]"; + textColor = "text-[color:var(--color-content-inverse-primary,black)]"; } } @@ -134,7 +124,9 @@ function ChipView({ .filter(Boolean) .join(" "); - const handleClick = (event: React.MouseEvent) => { + const handleClick = ( + event: React.MouseEvent, + ) => { if (isDisabled) { event.preventDefault(); return; @@ -162,7 +154,9 @@ function ChipView({ }} {...sharedA11y} > -
+
{/* Check button */} {onCheck && ( + + {/* Description text */} +
+

+ Add images, PDFs, and other files to the policy +

+
+
+
+ ); +} + +UploadView.displayName = "UploadView"; + +export default memo(UploadView); diff --git a/app/components/controls/Upload/index.tsx b/app/components/controls/Upload/index.tsx new file mode 100644 index 0000000..2335015 --- /dev/null +++ b/app/components/controls/Upload/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Upload.container"; +export type { UploadProps } from "./Upload.types"; diff --git a/app/components/icons/Avatar.tsx b/app/components/icons/Avatar.tsx index a1d55ec..3a85066 100644 --- a/app/components/icons/Avatar.tsx +++ b/app/components/icons/Avatar.tsx @@ -1,7 +1,15 @@ import { memo } from "react"; import { normalizeSize } from "../../../lib/propNormalization"; -export type AvatarSizeValue = "small" | "medium" | "large" | "xlarge" | "Small" | "Medium" | "Large" | "XLarge"; +export type AvatarSizeValue = + | "small" + | "medium" + | "large" + | "xlarge" + | "Small" + | "Medium" + | "Large" + | "XLarge"; interface AvatarProps extends React.ImgHTMLAttributes { src: string; @@ -19,7 +27,8 @@ const Avatar = memo( // Normalize props to handle both PascalCase (Figma) and lowercase (codebase) const size = normalizeSize(sizeProp, "small"); const sizeStyles: Record = { - small: "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)] border-[1.5px] border-[#FFFFFF4D] border-solid", + small: + "w-[var(--spacing-scale-016)] h-[var(--spacing-scale-016)] border-[1.5px] border-[#FFFFFF4D] border-solid", medium: "w-[var(--spacing-scale-018)] h-[var(--spacing-scale-018)]", large: "w-[var(--spacing-scale-024)] h-[var(--spacing-scale-024)]", xlarge: "w-[var(--spacing-scale-032)] h-[var(--spacing-scale-032)]", @@ -27,7 +36,10 @@ const Avatar = memo( const baseStyles = `rounded-[var(--radius-measures-radius-full)] object-cover box-border ${sizeStyles[size]} ${className}`; - return {alt}; + return ( + /* eslint-disable-next-line @next/next/no-img-element -- avatar image from URL */ + {alt} + ); }, ); diff --git a/app/components/icons/Logo.tsx b/app/components/icons/Logo.tsx deleted file mode 100644 index e932010..0000000 --- a/app/components/icons/Logo.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { memo } from "react"; -import Link from "next/link"; -import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; - -interface LogoProps { - size?: - | "default" - | "footer" - | "createFlow" - | "topNavFolderTop" - | "topNavHeader"; - showText?: boolean; -} - -interface SizeConfig { - containerHeight: string; - gap: string; - textSize: string; - lineHeight: string; - iconSize: string; -} - -const Logo = memo(({ size = "default", showText = true }) => { - // Size configurations - const sizes: Record = { - default: { - containerHeight: "h-[41px]", - gap: "gap-[8.28px]", - textSize: "text-[21.97px]", - lineHeight: "leading-[27.05px]", - iconSize: "w-[27.05px] h-[27.05px]", - }, - footer: { - containerHeight: "h-[41px] sm:h-[calc(40px*1.37)] lg:h-[calc(40px*2.05)]", - gap: "gap-[8.28px] sm:gap-[calc(8px*1.37)] lg:gap-[calc(8px*2.05)]", - textSize: "text-[21.97px] sm:text-[calc(21.97px*1.37)] lg:text-[calc(21.97px*2.05)]", - lineHeight: "leading-[27.05px] sm:leading-[calc(27.05px*1.37)] lg:leading-[calc(27.05px*2.05)]", - iconSize: "w-[27.05px] h-[27.05px] sm:w-[calc(27.05px*1.37)] sm:h-[calc(27.05px*1.37)] lg:w-[calc(27.05px*2.05)] lg:h-[calc(27.05px*2.05)]", - }, - createFlow: { - containerHeight: "h-[30px] md:h-[41px]", - gap: "gap-[6px] md:gap-[8.28px]", - textSize: "text-[16.48px] md:text-[21.97px]", - lineHeight: "leading-[20.28px] md:leading-[27.05px]", - iconSize: "w-[20.28px] h-[20.28px] md:w-[27.05px] md:h-[27.05px]", - }, - topNavFolderTop: { - containerHeight: "h-[14.11px] sm:h-[21.06px] md:h-[32.24px] lg:h-[28px] xl:h-[36px]", - gap: "gap-0 sm:gap-[3.19px] md:gap-[4.89px] lg:gap-[6.55px] xl:gap-[8.64px]", - textSize: "text-[11.57px] sm:text-[11.69px] md:text-[17.89px] lg:text-[21.97px] xl:text-[29.01px]", - lineHeight: "leading-[14.24px] sm:leading-[14.39px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[35.7px]", - iconSize: "w-[14.11px] h-[14.11px] sm:w-[14.39px] sm:h-[14.39px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[35.7px] xl:h-[35.7px]", - }, - topNavHeader: { - containerHeight: "h-[20.85px] sm:h-[20.85px] md:h-[17.91px] lg:h-[28px] xl:h-[34px]", - gap: "gap-0 sm:gap-[4.21px] md:gap-[6.51px] lg:gap-[6.55px] xl:gap-[8.19px]", - textSize: "text-[11.57px] sm:text-[11.57px] md:text-[17.89px] lg:text-[21.97px] xl:text-[27.47px]", - lineHeight: "leading-[14.24px] sm:leading-[14.24px] md:leading-[22.02px] lg:leading-[27.05px] xl:leading-[33.81px]", - iconSize: "w-[14.24px] h-[14.24px] sm:w-[14.24px] sm:h-[14.24px] md:w-[22.02px] md:h-[22.02px] lg:w-[27.05px] lg:h-[27.05px] xl:w-[33.81px] xl:h-[33.81px]", - }, - }; - - const config = sizes[size || "default"] || sizes.default; - - return ( - -
- {/* Logo Text - responsive visibility for topNav sizes */} -
- CommunityRule -
- - {/* Vector Icon */} - {/* eslint-disable-next-line @next/next/no-img-element */} - -
- - ); -}); - -Logo.displayName = "Logo"; - -export default Logo; diff --git a/app/components/modals/Alert/Alert.container.tsx b/app/components/modals/Alert/Alert.container.tsx index df27eff..e01624a 100644 --- a/app/components/modals/Alert/Alert.container.tsx +++ b/app/components/modals/Alert/Alert.container.tsx @@ -3,7 +3,10 @@ import { memo } from "react"; import { AlertView } from "./Alert.view"; import type { AlertProps } from "./Alert.types"; -import { normalizeAlertStatus, normalizeAlertType } from "../../../../lib/propNormalization"; +import { + normalizeAlertStatus, + normalizeAlertType, +} from "../../../../lib/propNormalization"; const AlertContainer = memo( ({ diff --git a/app/components/modals/Create/Create.container.tsx b/app/components/modals/Create/Create.container.tsx index a43ab04..0390eb2 100644 --- a/app/components/modals/Create/Create.container.tsx +++ b/app/components/modals/Create/Create.container.tsx @@ -10,6 +10,7 @@ const CreateContainer = memo( onClose, title, description, + headerContent, children, footerContent, showBackButton = true, @@ -113,6 +114,7 @@ const CreateContainer = memo( onClose={onClose} title={title} description={description} + headerContent={headerContent} // eslint-disable-next-line react/no-children-prop children={children} footerContent={footerContent} diff --git a/app/components/modals/Create/Create.types.ts b/app/components/modals/Create/Create.types.ts index 481f008..4b6214a 100644 --- a/app/components/modals/Create/Create.types.ts +++ b/app/components/modals/Create/Create.types.ts @@ -1,8 +1,11 @@ export interface CreateProps { isOpen: boolean; onClose: () => void; + /** Default header: title + description. Omit to use title/description. */ title?: string; description?: string; + /** Custom header slot. When set, replaces title/description for full control. */ + headerContent?: React.ReactNode; children?: React.ReactNode; footerContent?: React.ReactNode; showBackButton?: boolean; @@ -17,35 +20,12 @@ export interface CreateProps { className?: string; ariaLabel?: string; ariaLabelledBy?: string; - /** - * Whether to enable Create block array content type (Figma prop). - * @default false - */ + /** Figma / design alignment (unused in implementation). */ createBlockArray?: boolean; - /** - * Whether to enable Text input content type (Figma prop). - * @default false - */ textInput?: boolean; - /** - * Whether to enable Text area content type (Figma prop). - * @default false - */ textArea?: boolean; - /** - * Whether to enable Multi-select content type (Figma prop). - * @default false - */ multiSelect?: boolean; - /** - * Whether to enable Upload content type (Figma prop). - * @default false - */ upload?: boolean; - /** - * Whether to enable Proportion content type (Figma prop). - * @default false - */ proportion?: boolean; } @@ -54,6 +34,7 @@ export interface CreateViewProps { onClose: () => void; title?: string; description?: string; + headerContent?: React.ReactNode; children?: React.ReactNode; footerContent?: React.ReactNode; showBackButton: boolean; diff --git a/app/components/modals/Create/Create.view.tsx b/app/components/modals/Create/Create.view.tsx index 090a3d9..112d624 100644 --- a/app/components/modals/Create/Create.view.tsx +++ b/app/components/modals/Create/Create.view.tsx @@ -11,6 +11,7 @@ export function CreateView({ onClose, title, description, + headerContent, children, footerContent, showBackButton, @@ -40,21 +41,23 @@ export function CreateView({ aria-hidden="true" /> - {/* Create Dialog */} + {/* Create Dialog: max-h ensures modal fits viewport; content scrolls inside */}
{/* Header with close buttons */} - {/* Header Lockup Section (Sticky) */} - {(title || description) && ( -
+ {/* Header: custom headerContent (when provided) or default title/description */} + {headerContent !== undefined ? ( +
{headerContent}
+ ) : title || description ? ( +
- )} + ) : null} - {/* Content Area (Scrollable) */} -
+ {/* Content Area (scrollable when content overflows) */} +
{children}
- {/* Footer */} + {/* Footer (always visible at bottom of modal) */} ( - ({ children, text, position: positionProp = "top", className = "", disabled = false }) => { + ({ + children, + text, + position: positionProp = "top", + className = "", + disabled = false, + }) => { // Normalize props to handle both PascalCase (Figma) and lowercase (codebase) const position = normalizeTooltipPosition(positionProp); const [isVisible, setIsVisible] = useState(false); diff --git a/app/components/navigation/Footer.tsx b/app/components/navigation/Footer.tsx index fd7eb24..e154462 100644 --- a/app/components/navigation/Footer.tsx +++ b/app/components/navigation/Footer.tsx @@ -3,7 +3,7 @@ import { memo } from "react"; import { useTranslation } from "../../contexts/MessagesContext"; import Link from "next/link"; -import Logo from "../icons/Logo"; +import Logo from "../asset/logo"; import Separator from "../utility/Separator"; import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; @@ -40,7 +40,7 @@ const Footer = memo(() => { lg:gap-[var(--spacing-measures-spacing-060,60px)]" > {/* Logo */} - + {/* Content section */}
@@ -66,6 +66,7 @@ const Footer = memo(() => { className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group" aria-label={t("social.bluesky.ariaLabel")} > + {/* eslint-disable-next-line @next/next/no-img-element -- social logo */} Bluesky { className="flex items-center gap-[var(--spacing-measures-spacing-06,6px)] hover:opacity-80 active:opacity-60 focus:opacity-80 focus:outline-none focus:ring-2 focus:ring-[var(--color-content-default-primary)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-primary)] transition-opacity p-2 -m-2 cursor-pointer group" aria-label={t("social.gitlab.ariaLabel")} > + {/* eslint-disable-next-line @next/next/no-img-element -- social icon */} GitLab( ({ children, className = "", size: sizeProp = "X Small", ...props }) => { const size = normalizeMenuBarSize(sizeProp); const t = useTranslation("menuBar"); - + // Size styles based on Figma specifications const sizeStyles: Record< "X Small" | "Small" | "Medium" | "Large" | "X Large", diff --git a/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx b/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx index 17d5feb..7e87897 100644 --- a/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx +++ b/app/components/navigation/MenuBarItem/MenuBarItem.container.tsx @@ -32,11 +32,17 @@ const MenuBarItemContainer = memo( "X Small" | "Small" | "Medium" | "Large" | "X Large", string > = { - "X Small": reducedPadding ? "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]" : "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]", + "X Small": reducedPadding + ? "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]" + : "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]", Small: "px-[var(--spacing-scale-004)] py-[var(--spacing-scale-002)]", - Medium: reducedPadding ? "px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] h-[32px]" : "px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] h-[32px]", - Large: "px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]", - "X Large": "px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]", + Medium: reducedPadding + ? "px-[var(--spacing-scale-002)] py-[var(--spacing-scale-008)] h-[32px]" + : "px-[var(--spacing-scale-008)] py-[var(--spacing-scale-008)] h-[32px]", + Large: + "px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]", + "X Large": + "px-[var(--spacing-scale-016)] py-[var(--spacing-scale-016)] h-[44px]", }; // Text styles based on Figma specifications @@ -46,41 +52,34 @@ const MenuBarItemContainer = memo( > = { "X Small": "font-inter text-[10px] leading-[12px] font-medium tracking-[0%]", - Small: - "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]", - Medium: - "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]", - Large: - "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]", + Small: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]", + Medium: "font-inter text-[12px] leading-[14px] font-medium tracking-[0%]", + Large: "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]", "X Large": "font-inter text-[24px] leading-[28px] font-normal tracking-[0%]", }; // State styles for Default mode (yellow text on dark background) - const defaultModeStyles: Record< - "default" | "hover" | "selected", - string - > = { - default: - "bg-transparent text-[var(--color-content-default-brand-primary,#fefcc9)] hover:bg-[var(--color-gray-800)] hover:text-[var(--color-content-default-brand-primary,#fefcc9)]", - hover: - "bg-[var(--color-gray-800)] text-[var(--color-content-default-brand-primary,#fefcc9)]", - selected: - "border border-[var(--color-border-default-brand-primary,#fdfaa8)] text-[var(--color-content-default-brand-primary,#fefcc9)] bg-transparent hover:bg-[var(--color-gray-800)]", - }; + const defaultModeStyles: Record<"default" | "hover" | "selected", string> = + { + default: + "bg-transparent text-[var(--color-content-default-brand-primary,#fefcc9)] hover:bg-[var(--color-gray-800)] hover:text-[var(--color-content-default-brand-primary,#fefcc9)]", + hover: + "bg-[var(--color-gray-800)] text-[var(--color-content-default-brand-primary,#fefcc9)]", + selected: + "border border-[var(--color-border-default-brand-primary,#fdfaa8)] text-[var(--color-content-default-brand-primary,#fefcc9)] bg-transparent hover:bg-[var(--color-gray-800)]", + }; // State styles for Inverse mode (black text on yellow background) - const inverseModeStyles: Record< - "default" | "hover" | "selected", - string - > = { - default: - "bg-transparent text-[var(--color-content-inverse-primary,black)] hover:bg-[var(--color-surface-brand-accent,#4d4a00)] hover:text-[var(--color-content-inverse-primary,black)]", - hover: - "bg-[var(--color-surface-brand-accent,#4d4a00)] text-[var(--color-content-inverse-primary,black)]", - selected: - "border border-[var(--color-border-default-primary,#141414)] text-[var(--color-content-inverse-primary,black)] bg-transparent hover:bg-[var(--color-surface-brand-accent,#4d4a00)]", - }; + const inverseModeStyles: Record<"default" | "hover" | "selected", string> = + { + default: + "bg-transparent text-[var(--color-content-inverse-primary,black)] hover:bg-[var(--color-surface-brand-accent,#4d4a00)] hover:text-[var(--color-content-inverse-primary,black)]", + hover: + "bg-[var(--color-surface-brand-accent,#4d4a00)] text-[var(--color-content-inverse-primary,black)]", + selected: + "border border-[var(--color-border-default-primary,#141414)] text-[var(--color-content-inverse-primary,black)] bg-transparent hover:bg-[var(--color-surface-brand-accent,#4d4a00)]", + }; // Get state styles based on mode const stateStyles = diff --git a/app/components/navigation/MenuBarItem/MenuBarItem.types.ts b/app/components/navigation/MenuBarItem/MenuBarItem.types.ts index cd26b9e..6354cae 100644 --- a/app/components/navigation/MenuBarItem/MenuBarItem.types.ts +++ b/app/components/navigation/MenuBarItem/MenuBarItem.types.ts @@ -5,14 +5,9 @@ export type MenuBarItemSizeValue = | "Large" | "X Large"; -export type MenuBarItemStateValue = - | "default" - | "hover" - | "selected"; +export type MenuBarItemStateValue = "default" | "hover" | "selected"; -export type MenuBarItemModeValue = - | "default" - | "inverse"; +export type MenuBarItemModeValue = "default" | "inverse"; export interface MenuBarItemProps extends React.AnchorHTMLAttributes { href?: string; diff --git a/app/components/navigation/NavigationItem/NavigationItem.container.tsx b/app/components/navigation/NavigationItem/NavigationItem.container.tsx index 3afe5aa..0e15c57 100644 --- a/app/components/navigation/NavigationItem/NavigationItem.container.tsx +++ b/app/components/navigation/NavigationItem/NavigationItem.container.tsx @@ -3,7 +3,10 @@ import { memo } from "react"; import NavigationItemView from "./NavigationItem.view"; import type { NavigationItemProps } from "./NavigationItem.types"; -import { normalizeNavigationItemVariant, normalizeNavigationItemSize } from "../../../../lib/propNormalization"; +import { + normalizeNavigationItemVariant, + normalizeNavigationItemSize, +} from "../../../../lib/propNormalization"; const NavigationItemContainer = memo( ({ diff --git a/app/components/navigation/NavigationItem/NavigationItem.types.ts b/app/components/navigation/NavigationItem/NavigationItem.types.ts index 2338b59..e99e2cd 100644 --- a/app/components/navigation/NavigationItem/NavigationItem.types.ts +++ b/app/components/navigation/NavigationItem/NavigationItem.types.ts @@ -1,5 +1,9 @@ export type NavigationItemVariantValue = "default" | "Default"; -export type NavigationItemSizeValue = "default" | "xsmall" | "Default" | "XSmall"; +export type NavigationItemSizeValue = + | "default" + | "xsmall" + | "Default" + | "XSmall"; export interface NavigationItemProps extends Omit< React.AnchorHTMLAttributes, diff --git a/app/components/navigation/TopNav/TopNav.container.tsx b/app/components/navigation/TopNav/TopNav.container.tsx index 1036fe3..8f72ff9 100644 --- a/app/components/navigation/TopNav/TopNav.container.tsx +++ b/app/components/navigation/TopNav/TopNav.container.tsx @@ -1,13 +1,12 @@ "use client"; import { memo } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useTranslation } from "../../../contexts/MessagesContext"; import MenuBarItem from "../MenuBarItem"; import Button from "../../buttons/Button"; import AvatarContainer from "../../utility/AvatarContainer"; import Avatar from "../../icons/Avatar"; -import Logo from "../../icons/Logo"; import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; import { TopNavView } from "./TopNav.view"; import type { TopNavProps, NavSize } from "./TopNav.types"; @@ -19,13 +18,9 @@ export const avatarImages = [ ]; const TopNavContainer = memo( - ({ - folderTop = false, - loggedIn = false, - profile = false, - logIn = true, - }) => { + ({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => { const pathname = usePathname(); + const router = useRouter(); const t = useTranslation("header"); // Schema markup for site navigation @@ -34,7 +29,9 @@ const TopNavContainer = memo( "@type": "WebSite", name: "CommunityRule", url: "https://communityrule.com", - ...(folderTop && { description: "Build operating manuals for successful communities" }), + ...(folderTop && { + description: "Build operating manuals for successful communities", + }), potentialAction: { "@type": "SearchAction", target: "https://communityrule.com/search?q={search_term_string}", @@ -54,7 +51,10 @@ const TopNavContainer = memo( const renderNavigationItems = (size: NavSize) => { // Map NavSize to Figma MenuBarItem sizes - const sizeMap: Record = { + const sizeMap: Record< + NavSize, + "X Small" | "Small" | "Medium" | "Large" | "X Large" + > = { default: "Small", xsmall: "X Small", xsmallUseCases: "X Small", @@ -85,7 +85,10 @@ const TopNavContainer = memo( mode={mode} state={pathname === item.href ? "selected" : "default"} reducedPadding={isUseCases} - ariaLabel={t("ariaLabels.navigateToPage").replace("{text}", item.text)} + ariaLabel={t("ariaLabels.navigateToPage").replace( + "{text}", + item.text, + )} > {item.text} @@ -113,7 +116,10 @@ const TopNavContainer = memo( const renderLoginButton = (size: NavSize) => { // Map NavSize to Figma MenuBarItem sizes - const sizeMap: Record = { + const sizeMap: Record< + NavSize, + "X Small" | "Small" | "Medium" | "Large" | "X Large" + > = { default: "Small", xsmall: "X Small", xsmallUseCases: "X Small", @@ -159,7 +165,7 @@ const TopNavContainer = memo( size={buttonSize} buttonType={buttonType} palette={palette} - href="/create/informational" + onClick={() => router.push("/create/informational")} ariaLabel={t("ariaLabels.createNewRule")} > {renderAvatarGroup(containerSize, avatarSize)} diff --git a/app/components/navigation/TopNav/TopNav.view.tsx b/app/components/navigation/TopNav/TopNav.view.tsx index 2c39212..bcb5f72 100644 --- a/app/components/navigation/TopNav/TopNav.view.tsx +++ b/app/components/navigation/TopNav/TopNav.view.tsx @@ -7,7 +7,7 @@ import { getAssetPath } from "../../../../lib/assetUtils"; import MenuBar from "../MenuBar"; import type { TopNavViewProps } from "./TopNav.types"; -import Logo from "../../icons/Logo"; +import Logo from "../../asset/logo"; function TopNavView({ folderTop, @@ -44,7 +44,11 @@ function TopNavView({ {/* Header Tab - Yellow tab container with decorative Union images */}
{/* Logo - Consistent left positioning within HeaderTab */} - + {/* XSmall menu bar - positioned next to logo */}
@@ -55,18 +59,21 @@ function TopNavView({
{/* Decorative Union images for tab appearance */} + {/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG, not content */} + {/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */} + {/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */} - {renderNavigationItems("homeMd")} + + {renderNavigationItems("homeMd")} +
{/* 1024-1440px (lg: breakpoint): MenuBar Large */} @@ -158,7 +167,11 @@ function TopNavView({ aria-label={t("ariaLabels.mainNavigation")} > {/* Logo - Consistent left positioning across all breakpoints */} - + {/* Navigation Links - Consistent center positioning */}
@@ -190,7 +203,9 @@ function TopNavView({
- {renderNavigationItems("xlarge")} + + {renderNavigationItems("xlarge")} +
diff --git a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx index bb5ee50..33c703b 100644 --- a/app/components/sections/AskOrganizer/AskOrganizer.container.tsx +++ b/app/components/sections/AskOrganizer/AskOrganizer.container.tsx @@ -44,7 +44,9 @@ const AskOrganizerContainer = memo( onContactClick, }) => { // Normalize props to handle both PascalCase (Figma) and lowercase (codebase) - const variant = normalizeAskOrganizerVariant(variantProp) as AskOrganizerVariant; + const variant = normalizeAskOrganizerVariant( + variantProp, + ) as AskOrganizerVariant; const t = useTranslation(); const defaultButtonText = buttonText ?? t("askOrganizer.buttonText"); const defaultButtonHref = buttonHref ?? t("askOrganizer.buttonHref"); diff --git a/app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types.ts b/app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types.ts new file mode 100644 index 0000000..b812bb7 --- /dev/null +++ b/app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types.ts @@ -0,0 +1,16 @@ +export interface CommunityRuleDocumentEntry { + title: string; + body: string; +} + +export interface CommunityRuleDocumentSection { + categoryName: string; + entries: CommunityRuleDocumentEntry[]; +} + +export interface CommunityRuleDocumentProps { + sections: CommunityRuleDocumentSection[]; + className?: string; + /** When true, wrap in white background with left teal bar (small breakpoint). */ + useCardStyle?: boolean; +} diff --git a/app/components/sections/CommunityRuleDocument/CommunityRuleDocument.view.tsx b/app/components/sections/CommunityRuleDocument/CommunityRuleDocument.view.tsx new file mode 100644 index 0000000..35db080 --- /dev/null +++ b/app/components/sections/CommunityRuleDocument/CommunityRuleDocument.view.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { memo } from "react"; +import type { CommunityRuleDocumentProps } from "./CommunityRuleDocument.types"; + +const SECTION_GAP = "var(--measures-spacing-1200, 64px)"; +const TEAL_BG = "var(--color-teal-teal50, #c9fef9)"; +const SECTION_LINE_COLOR = "var(--color-border-default-tertiary, #464646)"; + +function CommunityRuleDocumentView({ + sections, + className = "", + useCardStyle = false, +}: CommunityRuleDocumentProps) { + const rootClass = useCardStyle + ? `rounded-[12px] bg-white pl-3 border-l-4 ${className}` + : className; + const rootStyle = useCardStyle ? { borderLeftColor: TEAL_BG } : undefined; + + const sectionLineStyle = useCardStyle + ? undefined + : { borderLeftColor: SECTION_LINE_COLOR }; + + return ( +
+ {sections.map((section, sectionIndex) => ( +
+ {/* Section content: line runs full height of this block via border-left */} +
+

+ {section.categoryName} +

+
+ {section.entries.map((entry, entryIndex) => ( +
+

+ {entry.title} +

+

+ {entry.body} +

+
+ ))} +
+
+
+ ))} +
+ ); +} + +CommunityRuleDocumentView.displayName = "CommunityRuleDocumentView"; + +export default memo(CommunityRuleDocumentView); diff --git a/app/components/sections/CommunityRuleDocument/index.tsx b/app/components/sections/CommunityRuleDocument/index.tsx new file mode 100644 index 0000000..c184ed7 --- /dev/null +++ b/app/components/sections/CommunityRuleDocument/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./CommunityRuleDocument.view"; +export type { CommunityRuleDocumentProps } from "./CommunityRuleDocument.types"; diff --git a/app/components/sections/HeroBanner/HeroBanner.tsx b/app/components/sections/HeroBanner/HeroBanner.tsx index 1cc6e2c..f415731 100644 --- a/app/components/sections/HeroBanner/HeroBanner.tsx +++ b/app/components/sections/HeroBanner/HeroBanner.tsx @@ -46,6 +46,7 @@ const HeroBanner = memo( {/* Hero Image Container */}
+ {/* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */} {imageAlt} { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer breakpoint until after mount to avoid hydration mismatch setIsMounted(true); }, []); @@ -38,12 +43,12 @@ export function RuleStackView({ ? isMax639 ? "XS" : isMin640Max1023 - ? "S" - : isMin1024Max1439 - ? "M" - : isMin1440 - ? "L" - : "M" + ? "S" + : isMin1024Max1439 + ? "M" + : isMin1440 + ? "L" + : "M" : "M"; // Icon sizes: XS=40px, S=56px, M=56px, L=90px @@ -150,17 +155,15 @@ export function RuleStackView({
{/* See all templates button */} -
-
diff --git a/app/components/sections/SectionHeader.tsx b/app/components/sections/SectionHeader.tsx index b018da3..0a5224e 100644 --- a/app/components/sections/SectionHeader.tsx +++ b/app/components/sections/SectionHeader.tsx @@ -3,7 +3,11 @@ import { memo } from "react"; import { normalizeSectionHeaderVariant } from "../../../lib/propNormalization"; -export type SectionHeaderVariantValue = "default" | "multi-line" | "Default" | "Multi-Line"; +export type SectionHeaderVariantValue = + | "default" + | "multi-line" + | "Default" + | "Multi-Line"; interface SectionHeaderProps { title: string; diff --git a/app/components/sections/SectionNumber.tsx b/app/components/sections/SectionNumber.tsx index 0595c48..5c687e1 100644 --- a/app/components/sections/SectionNumber.tsx +++ b/app/components/sections/SectionNumber.tsx @@ -26,6 +26,7 @@ const SectionNumber = memo(({ number }) => { return (
+ {/* eslint-disable-next-line @next/next/no-img-element -- dynamic src from getImageSrc */} {`Section( ({ diff --git a/app/components/type/ContentLockup/ContentLockup.view.tsx b/app/components/type/ContentLockup/ContentLockup.view.tsx index 68d7e3d..0b5565a 100644 --- a/app/components/type/ContentLockup/ContentLockup.view.tsx +++ b/app/components/type/ContentLockup/ContentLockup.view.tsx @@ -60,12 +60,15 @@ function ContentLockupView({ ) : null} {variant === "hero" && ( - + <> + {/* eslint-disable-next-line @next/next/no-img-element -- decorative shape SVG */} + + )}
@@ -93,19 +96,32 @@ function ContentLockupView({
{/* Small button for xsm and sm breakpoints */}
-
{/* Large button for md and lg breakpoints */}
-
{/* XLarge button for xl breakpoint */}
-
diff --git a/app/components/type/HeaderLockup/HeaderLockup.container.tsx b/app/components/type/HeaderLockup/HeaderLockup.container.tsx new file mode 100644 index 0000000..132037f --- /dev/null +++ b/app/components/type/HeaderLockup/HeaderLockup.container.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { memo } from "react"; +import HeaderLockupView from "./HeaderLockup.view"; +import type { HeaderLockupProps } from "./HeaderLockup.types"; +import { + normalizeHeaderLockupJustification, + normalizeHeaderLockupSize, + normalizeHeaderLockupPalette, +} from "../../../../lib/propNormalization"; + +const HeaderLockupContainer = memo( + ({ + title, + description, + justification: justificationProp = "left", + size: sizeProp = "L", + palette: paletteProp = "default", + }) => { + // Normalize props to handle both PascalCase (Figma) and lowercase (codebase) + const justification = normalizeHeaderLockupJustification(justificationProp); + const size = normalizeHeaderLockupSize(sizeProp); + const palette = normalizeHeaderLockupPalette(paletteProp); + + return ( + + ); + }, +); + +HeaderLockupContainer.displayName = "HeaderLockup"; + +export default HeaderLockupContainer; diff --git a/app/components/type/HeaderLockup/HeaderLockup.types.ts b/app/components/type/HeaderLockup/HeaderLockup.types.ts new file mode 100644 index 0000000..cac76f2 --- /dev/null +++ b/app/components/type/HeaderLockup/HeaderLockup.types.ts @@ -0,0 +1,45 @@ +export type HeaderLockupJustificationValue = + | "left" + | "center" + | "Left" + | "Center"; +export type HeaderLockupSizeValue = "L" | "M" | "l" | "m"; +export type HeaderLockupPaletteValue = + | "default" + | "inverse" + | "Default" + | "Inverse"; + +export interface HeaderLockupProps { + /** + * Title text (required) + */ + title: string; + /** + * Description text (optional) + */ + description?: string; + /** + * Text justification. Accepts both PascalCase (Figma) and lowercase (codebase). + * Figma uses PascalCase, codebase uses lowercase - both are supported. + */ + justification?: HeaderLockupJustificationValue; + /** + * Size variant. Accepts both PascalCase (Figma) and lowercase (codebase). + * Figma uses PascalCase, codebase uses lowercase - both are supported. + */ + size?: HeaderLockupSizeValue; + /** + * Palette. Default = light text (dark bg); Inverse = dark text (light bg). + * Accepts both PascalCase (Figma) and lowercase (codebase). + */ + palette?: HeaderLockupPaletteValue; +} + +export interface HeaderLockupViewProps { + title: string; + description?: string; + justification: "left" | "center"; + size: "L" | "M"; + palette: "default" | "inverse"; +} diff --git a/app/components/type/HeaderLockup/HeaderLockup.view.tsx b/app/components/type/HeaderLockup/HeaderLockup.view.tsx new file mode 100644 index 0000000..b68ffae --- /dev/null +++ b/app/components/type/HeaderLockup/HeaderLockup.view.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { memo } from "react"; +import type { HeaderLockupViewProps } from "./HeaderLockup.types"; + +function HeaderLockupView({ + title, + description, + justification, + size, + palette, +}: HeaderLockupViewProps) { + const isL = size === "L"; + const isLeft = justification === "left"; + const isInverse = palette === "inverse"; + + const titleColorClass = isInverse + ? "text-[var(--color-content-invert-primary)]" + : "text-[var(--color-content-default-primary,white)]"; + const descriptionColorClass = isInverse + ? "text-[#2d2d2d]" + : "text-[var(--color-content-default-tertiary,#b4b4b4)]"; + + return ( +
+ {/* Title */} +
+

+ {title} +

+
+ + {/* Description */} + {description && ( +

+ {description} +

+ )} +
+ ); +} + +HeaderLockupView.displayName = "HeaderLockupView"; + +export default memo(HeaderLockupView); diff --git a/app/components/type/HeaderLockup/index.tsx b/app/components/type/HeaderLockup/index.tsx new file mode 100644 index 0000000..db2d3dd --- /dev/null +++ b/app/components/type/HeaderLockup/index.tsx @@ -0,0 +1 @@ +export { default } from "./HeaderLockup.container"; diff --git a/app/components/type/NumberedList/NumberedList.container.tsx b/app/components/type/NumberedList/NumberedList.container.tsx new file mode 100644 index 0000000..e6a652d --- /dev/null +++ b/app/components/type/NumberedList/NumberedList.container.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { memo } from "react"; +import NumberedListView from "./NumberedList.view"; +import type { NumberedListProps } from "./NumberedList.types"; +import { normalizeNumberedListSize } from "../../../../lib/propNormalization"; + +const NumberedListContainer = memo( + ({ items, size: sizeProp = "M" }) => { + // Normalize props to handle both PascalCase (Figma) and lowercase (codebase) + const size = normalizeNumberedListSize(sizeProp); + + return ; + }, +); + +NumberedListContainer.displayName = "NumberedList"; + +export default NumberedListContainer; diff --git a/app/components/type/NumberedList/NumberedList.types.ts b/app/components/type/NumberedList/NumberedList.types.ts new file mode 100644 index 0000000..a65233c --- /dev/null +++ b/app/components/type/NumberedList/NumberedList.types.ts @@ -0,0 +1,23 @@ +export type NumberedListSizeValue = "M" | "S" | "m" | "s"; + +export interface NumberedListItem { + title: string; + description: string; +} + +export interface NumberedListProps { + /** + * Array of list items, each with title and description + */ + items: NumberedListItem[]; + /** + * Size variant. Accepts both PascalCase (Figma) and lowercase (codebase). + * Figma uses PascalCase, codebase uses lowercase - both are supported. + */ + size?: NumberedListSizeValue; +} + +export interface NumberedListViewProps { + items: NumberedListItem[]; + size: "M" | "S"; +} diff --git a/app/components/type/NumberedList/NumberedList.view.tsx b/app/components/type/NumberedList/NumberedList.view.tsx new file mode 100644 index 0000000..0f532fd --- /dev/null +++ b/app/components/type/NumberedList/NumberedList.view.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { memo } from "react"; +import type { NumberedListViewProps } from "./NumberedList.types"; + +function NumberedListView({ items, size }: NumberedListViewProps) { + const isM = size === "M"; + + return ( +
    + {items.map((item, index) => ( +
  1. + {/* Number Indicator */} +
    +
    + {index + 1} +
    +
    + + {/* Content */} +
    + {/* Title */} +
    +

    + {item.title} +

    +
    + + {/* Description */} +

    + {item.description} +

    +
    +
  2. + ))} +
+ ); +} + +NumberedListView.displayName = "NumberedListView"; + +export default memo(NumberedListView); diff --git a/app/components/type/NumberedList/index.tsx b/app/components/type/NumberedList/index.tsx new file mode 100644 index 0000000..fc06cf6 --- /dev/null +++ b/app/components/type/NumberedList/index.tsx @@ -0,0 +1 @@ +export { default } from "./NumberedList.container"; diff --git a/app/components/utility/AvatarContainer.tsx b/app/components/utility/AvatarContainer.tsx index f3599ae..8eb4ff1 100644 --- a/app/components/utility/AvatarContainer.tsx +++ b/app/components/utility/AvatarContainer.tsx @@ -1,7 +1,15 @@ import { memo } from "react"; import { normalizeSize } from "../../../lib/propNormalization"; -export type AvatarContainerSizeValue = "small" | "medium" | "large" | "xlarge" | "Small" | "Medium" | "Large" | "XLarge"; +export type AvatarContainerSizeValue = + | "small" + | "medium" + | "large" + | "xlarge" + | "Small" + | "Medium" + | "Large" + | "XLarge"; interface AvatarContainerProps extends React.HTMLAttributes { children?: React.ReactNode; diff --git a/app/components/utility/CardStack/CardStack.container.tsx b/app/components/utility/CardStack/CardStack.container.tsx new file mode 100644 index 0000000..48bc071 --- /dev/null +++ b/app/components/utility/CardStack/CardStack.container.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { memo, useCallback, useState } from "react"; +import { CardStackView } from "./CardStack.view"; +import type { CardStackProps } from "./CardStack.types"; + +const DEFAULT_TOGGLE_LABEL = "See all communication approaches"; +const DEFAULT_SHOW_LESS_LABEL = "Show less"; + +const CardStackContainer = memo( + ({ + cards, + selectedId: controlledSelectedId, + selectedIds: controlledSelectedIds, + onCardSelect: controlledOnCardSelect, + expanded: controlledExpanded, + onToggleExpand: controlledOnToggleExpand, + hasMore = true, + toggleLabel = DEFAULT_TOGGLE_LABEL, + showLessLabel = DEFAULT_SHOW_LESS_LABEL, + title = "", + description = "", + layout = "default", + className = "", + }) => { + const [internalExpanded, setInternalExpanded] = useState(false); + const [internalSelectedIds, setInternalSelectedIds] = useState( + [], + ); + + const expanded = + controlledExpanded !== undefined ? controlledExpanded : internalExpanded; + + const handleToggleExpand = useCallback(() => { + if (controlledOnToggleExpand) { + controlledOnToggleExpand(); + } else { + setInternalExpanded((prev) => !prev); + } + }, [controlledOnToggleExpand]); + + const selectedIds = + controlledSelectedIds !== undefined + ? controlledSelectedIds + : controlledSelectedId !== undefined + ? controlledSelectedId + ? [controlledSelectedId] + : [] + : internalSelectedIds; + + const handleCardSelect = useCallback( + (id: string) => { + if (controlledOnCardSelect) { + controlledOnCardSelect(id); + } else { + setInternalSelectedIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + } + }, + [controlledOnCardSelect], + ); + + return ( + + ); + }, +); + +CardStackContainer.displayName = "CardStack"; + +export default CardStackContainer; diff --git a/app/components/utility/CardStack/CardStack.types.ts b/app/components/utility/CardStack/CardStack.types.ts new file mode 100644 index 0000000..2c23324 --- /dev/null +++ b/app/components/utility/CardStack/CardStack.types.ts @@ -0,0 +1,38 @@ +export interface CardStackItem { + id: string; + label: string; + supportText?: string; + recommended?: boolean; +} + +export interface CardStackProps { + cards: CardStackItem[]; + selectedId?: string | null; + selectedIds?: string[]; + onCardSelect?: (id: string) => void; + expanded?: boolean; + onToggleExpand?: () => void; + hasMore?: boolean; + toggleLabel?: string; + showLessLabel?: string; + title?: string; + description?: string; + /** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */ + layout?: "default" | "singleStack"; + className?: string; +} + +export interface CardStackViewProps { + cards: CardStackItem[]; + selectedIds: string[]; + onCardSelect: (id: string) => void; + expanded: boolean; + onToggleExpand: () => void; + hasMore: boolean; + toggleLabel: string; + showLessLabel: string; + title: string; + description: string; + layout: "default" | "singleStack"; + className: string; +} diff --git a/app/components/utility/CardStack/CardStack.view.tsx b/app/components/utility/CardStack/CardStack.view.tsx new file mode 100644 index 0000000..34af000 --- /dev/null +++ b/app/components/utility/CardStack/CardStack.view.tsx @@ -0,0 +1,156 @@ +"use client"; + +import HeaderLockup from "../../type/HeaderLockup"; +import Card from "../../cards/Card"; +import type { CardStackViewProps } from "./CardStack.types"; + +export function CardStackView({ + cards, + selectedIds, + onCardSelect, + expanded, + onToggleExpand, + hasMore, + toggleLabel, + showLessLabel, + title, + description, + layout, + className, +}: CardStackViewProps) { + const isSelected = (id: string) => selectedIds.includes(id); + // Compact: recommended only (up to 5). Expanded: all cards. + const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5); + + // Single stack: always one column; expand reveals more in same stack (scrollable) + if (layout === "singleStack") { + const displayedCards = expanded ? cards : compactCards; + return ( +
+ {title || description ? ( +
+ +
+ ) : null} +
+ {displayedCards.map((item) => ( + onCardSelect(item.id)} + /> + ))} +
+ {hasMore ? ( + + ) : null} +
+ ); + } + + return ( +
+ {title || description ? ( +
+ +
+ ) : null} + + {expanded ? ( +
+ {cards.map((item) => ( + onCardSelect(item.id)} + /> + ))} +
+ ) : ( + <> + {/* Compact under 640: single column, up to 5 recommended cards */} +
+ {compactCards.map((item) => ( + onCardSelect(item.id)} + /> + ))} +
+ {/* Compact 640+: 6-col grid so each card spans 2; second row centered (cols 2–3 and 4–5) */} +
+ {compactCards.map((item, index) => { + const colClass = + index <= 2 + ? "md:col-span-2" + : index === 3 && compactCards.length === 4 + ? "md:col-start-3 md:col-span-2" + : index === 3 + ? "md:col-start-2 md:col-span-2" + : "md:col-start-4 md:col-span-2"; + return ( +
+ onCardSelect(item.id)} + /> +
+ ); + })} +
+ + )} + + {hasMore ? ( + + ) : null} +
+ ); +} diff --git a/app/components/utility/CardStack/index.tsx b/app/components/utility/CardStack/index.tsx new file mode 100644 index 0000000..b9c4976 --- /dev/null +++ b/app/components/utility/CardStack/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./CardStack.container"; +export type { CardStackProps, CardStackItem } from "./CardStack.types"; diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx b/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx index 1212196..4b964fd 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.container.tsx @@ -5,11 +5,12 @@ import { CreateFlowFooterView } from "./CreateFlowFooter.view"; import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; const CreateFlowFooterContainer = memo( - ({ secondButton, progressBar = true, className = "" }) => { + ({ secondButton, progressBar = true, onBackClick, className = "" }) => { return ( ); diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts b/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts index 4bf5d0d..0ca62bb 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.types.ts @@ -13,6 +13,10 @@ export interface CreateFlowFooterProps { * @default true */ progressBar?: boolean; + /** + * Callback function for Back button click + */ + onBackClick?: () => void; /** * Additional CSS classes */ diff --git a/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx b/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx index 4b9feef..4929653 100644 --- a/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx +++ b/app/components/utility/CreateFlowFooter/CreateFlowFooter.view.tsx @@ -5,11 +5,12 @@ import type { CreateFlowFooterProps } from "./CreateFlowFooter.types"; export function CreateFlowFooterView({ secondButton, progressBar = true, + onBackClick, className = "", }: CreateFlowFooterProps) { return (
@@ -28,14 +29,14 @@ export function CreateFlowFooterView({ palette="default" size="xsmall" className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]" + onClick={onBackClick} + disabled={!onBackClick} > Back {/* Second Button - Right */} - {secondButton && ( -
{secondButton}
- )} + {secondButton &&
{secondButton}
}
); diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx index 56de7eb..d732394 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.container.tsx @@ -15,13 +15,14 @@ const CreateFlowTopNavContainer = memo( onExport, onEdit, onExit, + buttonPalette, className = "", }) => { const router = useRouter(); - const handleExit = () => { + const handleExit = (options?: { saveDraft?: boolean }) => { if (onExit) { - onExit(); + onExit(options); } else { // Default behavior: navigate to home router.push("/"); @@ -38,6 +39,7 @@ const CreateFlowTopNavContainer = memo( onExport={onExport} onEdit={onEdit} onExit={handleExit} + buttonPalette={buttonPalette} className={className} /> ); diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts index 0559ad5..69f9afc 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.types.ts @@ -1,6 +1,6 @@ /** * Type definitions for CreateFlowTopNav component - * + * * Top navigation bar for the create rule flow. * Includes logo and action buttons (Share, Export, Edit, Exit). */ @@ -39,9 +39,15 @@ export interface CreateFlowTopNavProps { */ onEdit?: () => void; /** - * Callback when Exit/Save & Exit button is clicked + * Callback when Exit/Save & Exit button is clicked. + * When user is logged in, called with { saveDraft: true } to stub "Save & Exit". */ - onExit?: () => void; + onExit?: (options?: { saveDraft?: boolean }) => void; + /** + * Palette for nav buttons (e.g. "inverse" on completed page to match teal background) + * @default "default" + */ + buttonPalette?: "default" | "inverse"; /** * Additional CSS classes */ diff --git a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx index d132207..fd8cbb2 100644 --- a/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/utility/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -1,4 +1,4 @@ -import Logo from "../../icons/Logo"; +import Logo from "../../asset/logo"; import Button from "../../buttons/Button"; import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types"; @@ -11,13 +11,14 @@ export function CreateFlowTopNavView({ onExport, onEdit, onExit, + buttonPalette = "default", className = "", }: CreateFlowTopNavProps) { const exitButtonText = loggedIn ? "Save & Exit" : "Exit"; return (
@@ -27,14 +28,14 @@ export function CreateFlowTopNavView({ aria-label="Create Flow Navigation" > {/* Logo - Left */} - + {/* Button Group - Right */}
{hasShare && (
{helpIcon && (
+ {/* eslint-disable-next-line @next/next/no-img-element -- icon from asset path */} Help -
diff --git a/app/components/utility/ModalHeader/ModalHeader.view.tsx b/app/components/utility/ModalHeader/ModalHeader.view.tsx index 4bdad22..aac6486 100644 --- a/app/components/utility/ModalHeader/ModalHeader.view.tsx +++ b/app/components/utility/ModalHeader/ModalHeader.view.tsx @@ -19,6 +19,7 @@ export function ModalHeaderView({ className="absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full left-[24px] top-[12px] flex items-center justify-center cursor-pointer" aria-label="Close dialog" > + {/* eslint-disable-next-line @next/next/no-img-element -- icon asset */} + {children} +
+ ); +} diff --git a/app/components/utility/Scrollbar/index.tsx b/app/components/utility/Scrollbar/index.tsx new file mode 100644 index 0000000..bc9e054 --- /dev/null +++ b/app/components/utility/Scrollbar/index.tsx @@ -0,0 +1,5 @@ +export { ScrollbarView as default } from "./Scrollbar.view"; +export type { ScrollbarProps } from "./Scrollbar.types"; + +/** Class name to apply the design system scrollbar to any scrollable element (e.g. textarea, div). */ +export const SCROLLBAR_DESIGN_CLASS = "scrollbar-design"; diff --git a/app/components/utility/Tag/Tag.container.tsx b/app/components/utility/Tag/Tag.container.tsx new file mode 100644 index 0000000..4eef24b --- /dev/null +++ b/app/components/utility/Tag/Tag.container.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { memo } from "react"; +import { TagView } from "./Tag.view"; +import type { TagProps } from "./Tag.types"; + +const DEFAULT_LABELS: Record = { + recommended: "RECOMMENDED", + selected: "SELECTED", +}; + +const TagContainer = memo(({ variant, children, className = "" }) => { + const content = children ?? DEFAULT_LABELS[variant]; + return ( + + {content} + + ); +}); + +TagContainer.displayName = "Tag"; + +export default TagContainer; diff --git a/app/components/utility/Tag/Tag.types.ts b/app/components/utility/Tag/Tag.types.ts new file mode 100644 index 0000000..d0d3730 --- /dev/null +++ b/app/components/utility/Tag/Tag.types.ts @@ -0,0 +1,15 @@ +export type TagVariant = "recommended" | "selected"; + +export interface TagProps { + /** Visual variant: recommended (yellow) or selected (dark) */ + variant: TagVariant; + /** Tag text. Defaults to "RECOMMENDED" or "SELECTED" when not provided. */ + children?: React.ReactNode; + className?: string; +} + +export interface TagViewProps { + variant: TagVariant; + children: React.ReactNode; + className: string; +} diff --git a/app/components/utility/Tag/Tag.view.tsx b/app/components/utility/Tag/Tag.view.tsx new file mode 100644 index 0000000..26db892 --- /dev/null +++ b/app/components/utility/Tag/Tag.view.tsx @@ -0,0 +1,24 @@ +"use client"; + +import type { TagViewProps } from "./Tag.types"; + +/** + * Tag view – Figma 17861-22238. + * Recommended: light yellow bg (#F6EEA7), dark text (#3F3F3F). + * Selected: dark bg (#3F3F3F), white text (#FFFFFF). + * Typography: Inter Medium 10px, line-height 12, uppercase. + */ +export function TagView({ variant, children, className }: TagViewProps) { + const isRecommended = variant === "recommended"; + const bgClass = isRecommended ? "bg-[#F6EEA7]" : "bg-[#3F3F3F]"; + const textClass = isRecommended ? "text-[#3F3F3F]" : "text-[#FFFFFF]"; + + return ( + + {children} + + ); +} diff --git a/app/components/utility/Tag/index.tsx b/app/components/utility/Tag/index.tsx new file mode 100644 index 0000000..1cff338 --- /dev/null +++ b/app/components/utility/Tag/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Tag.container"; +export type { TagProps, TagVariant } from "./Tag.types"; diff --git a/app/contexts/MessagesContext.tsx b/app/contexts/MessagesContext.tsx index 92e4fc7..cb17c37 100644 --- a/app/contexts/MessagesContext.tsx +++ b/app/contexts/MessagesContext.tsx @@ -39,11 +39,11 @@ export function useMessages(): Messages { */ function getTranslationValue(messages: Messages, key: string): string { const keys = key.split("."); - let value: any = messages; + let value: unknown = messages; for (const k of keys) { if (value && typeof value === "object" && k in value) { - value = value[k as keyof typeof value]; + value = (value as Record)[k]; } else { return key; // Fallback to key if path not found } diff --git a/app/create/[step]/page.tsx b/app/create/[step]/page.tsx index 22b4786..94517c2 100644 --- a/app/create/[step]/page.tsx +++ b/app/create/[step]/page.tsx @@ -2,31 +2,15 @@ import { notFound } from "next/navigation"; import { use } from "react"; -import type { CreateFlowStep } from "../types"; +import { VALID_STEPS } from "../utils/flowSteps"; interface PageProps { params: Promise<{ step: string }>; } -/** - * Valid step IDs for the create rule flow - */ -const VALID_STEPS: CreateFlowStep[] = [ - "informational", - "text", - "select", - "upload", - "review", - "compact-cards", - "expanded-cards", - "right-rail", - "final-review", - "completed", -]; - /** * Dynamic route handler for create flow steps - * + * * Handles all flow steps via dynamic routing: /create/[step] * Validates step exists and renders appropriate template (placeholder for now) */ @@ -34,7 +18,7 @@ export default function CreateFlowStepPage({ params }: PageProps) { const { step } = use(params); // Validate step exists - if (!VALID_STEPS.includes(step as CreateFlowStep)) { + if (!(VALID_STEPS as readonly string[]).includes(step)) { notFound(); } diff --git a/app/create/cards/page.tsx b/app/create/cards/page.tsx new file mode 100644 index 0000000..184ee80 --- /dev/null +++ b/app/create/cards/page.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { useState, useCallback } from "react"; +import HeaderLockup from "../../components/type/HeaderLockup"; +import CardStack from "../../components/utility/CardStack"; +import Create from "../../components/modals/Create"; +import TextArea from "../../components/controls/TextArea"; + +const COMPACT_TITLE = "How should this community communicate with each-other?"; +const COMPACT_DESCRIPTION = + "You can select multiple methods for different needs or add your own"; +const EXPANDED_TITLE = + "What method should this community use to communicate with eachother?"; +const EXPANDED_DESCRIPTION = COMPACT_DESCRIPTION; + +/** Create is a shell; which variant shows is determined by which card was clicked; we pass different props and children by pendingCardId. */ + +/** Card ids for "Add platform" Create modal variants. */ +const IN_PERSON_CARD_ID = "in-person-meetings"; +const SIGNAL_CARD_ID = "signal"; +const VIDEO_MEETINGS_CARD_ID = "video-meetings"; + +/** Copy for the default confirm modal (non–add-platform cards). */ +const CONFIRM_MODAL = { + title: "Confirm selection", + description: "Confirm to select this option.", + nextButtonText: "Confirm", + showBackButton: false, + currentStep: undefined, + totalSteps: undefined, +} as const; + +/** + * "Add platform" variants share the same header pattern and "Add Platform" button. + * Each has its own title, description, and body (three TextArea sections). + */ +const ADD_PLATFORM_MODALS: Record< + string, + { title: string; description: string; nextButtonText: string } +> = { + [IN_PERSON_CARD_ID]: { + title: "In-Person Meetings", + description: + "Physical gatherings for high-bandwidth communication and relationship building.", + nextButtonText: "Add Platform", + }, + [SIGNAL_CARD_ID]: { + title: "Signal", + description: + "End-to-end encrypted messaging ideal for small, security-minded groups", + nextButtonText: "Add Platform", + }, + [VIDEO_MEETINGS_CARD_ID]: { + title: "Video Meetings", + description: "Synchronous video calls for remote face-to-face interaction.", + nextButtonText: "Add Platform", + }, +}; + +const SECTION_KEYS = [ + "Core Principle & Scope", + "Logistics, Admin & Norms", + "Code of Conduct", +] as const; +type SectionKey = (typeof SECTION_KEYS)[number]; + +/** Default section text per platform (Figma 20647-18271, 20647-18273, 20736-12668). */ +const ADD_PLATFORM_SECTION_DEFAULTS: Record< + string, + Record +> = { + [IN_PERSON_CARD_ID]: { + "Core Principle & Scope": `We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.`, + "Logistics, Admin & Norms": `Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.`, + "Code of Conduct": `We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry.`, + }, + [SIGNAL_CARD_ID]: { + "Core Principle & Scope": `We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.`, + "Logistics, Admin & Norms": `We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use "Emoji Reactions" (👍, ♥️) to acknowledge messages rather than typing "thanks," which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.`, + "Code of Conduct": `This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse.`, + }, + [VIDEO_MEETINGS_CARD_ID]: { + "Core Principle & Scope": `We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.`, + "Logistics, Admin & Norms": `The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the "Raise Hand" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.`, + "Code of Conduct": `We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. "Zoom-bombing" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk.`, + }, +}; + +/** + * Section with heading + info icon and an editable TextArea. + * This variant uses TextArea only (no TextInput); design is "Add Signal" / "Add Video Meetings". + */ +function CreateModalSection({ + title, + value: _value, + onChange, +}: { + title: string; + value: string; + onChange: (_value: string) => void; +}) { + return ( +
+
+

+ {title} +

+ + ? + +
+