diff --git a/app/(dev)/components-preview/page.tsx b/app/(dev)/components-preview/page.tsx index f653013..acdacd8 100644 --- a/app/(dev)/components-preview/page.tsx +++ b/app/(dev)/components-preview/page.tsx @@ -1,9 +1,10 @@ "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"; @@ -466,7 +467,7 @@ 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

@@ -633,6 +634,74 @@ 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.log("Card clicked")} + /> +
+
+

+ Horizontal + Selected +

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

+ Vertical + Recommended +

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

+ Vertical + Selected +

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

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..3ddcf80 --- /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 = `rounded-[var(--radius-measures-radius-small)] bg-[#FFFFFF] p-4 transition-all 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/controls/TextArea/TextArea.container.tsx b/app/components/controls/TextArea/TextArea.container.tsx index b0a7d59..5999393 100644 --- a/app/components/controls/TextArea/TextArea.container.tsx +++ b/app/components/controls/TextArea/TextArea.container.tsx @@ -4,7 +4,7 @@ import { memo, forwardRef } from "react"; import { useComponentId, useFormField } from "../../../hooks"; import { TextAreaView } from "./TextArea.view"; import type { TextAreaProps } from "./TextArea.types"; -import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant } from "../../../../lib/propNormalization"; +import { normalizeInputState, normalizeSmallMediumLargeSize, normalizeLabelVariant, normalizeTextAreaAppearance } from "../../../../lib/propNormalization"; const TextAreaContainer = forwardRef( ( @@ -27,6 +27,7 @@ const TextAreaContainer = forwardRef( textHint = false, formHeader = true, showHelpIcon = false, + appearance: appearanceProp = "default", ...props }, ref, @@ -35,6 +36,7 @@ const TextAreaContainer = forwardRef( const size = normalizeSmallMediumLargeSize(sizeProp); const labelVariant = normalizeLabelVariant(labelVariantProp); const state = normalizeInputState(stateProp); + const appearance = normalizeTextAreaAppearance(appearanceProp); // Generate unique ID for accessibility if not provided const { id: textareaId, labelId } = useComponentId("textarea", id); @@ -74,11 +76,26 @@ const TextAreaContainer = forwardRef( }, }; - // State styles + // State styles (embedded: Figma 20736-12668 – borderless, darker grey block, white text) const getStateStyles = (): { textarea: string; label: string; } => { + if (appearance === "embedded") { + if (disabled) { + return { + textarea: + "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] cursor-not-allowed opacity-60", + label: "text-[var(--color-content-default-secondary)]", + }; + } + return { + textarea: + "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset", + label: "text-[var(--color-content-default-secondary)]", + }; + } + if (disabled) { return { textarea: @@ -138,8 +155,8 @@ const TextAreaContainer = forwardRef( : `${currentSize.label} font-inter`; const textareaClasses = ` - w-full border transition-all duration-200 ease-in-out - focus:outline-none focus:ring-0 resize-none + scrollbar-design w-full transition-all duration-200 ease-in-out resize-none + ${appearance === "embedded" ? "rounded-[var(--radius-300,12px)]" : "border"} ${currentSize.textarea} ${stateStyles.textarea} ${className} @@ -180,6 +197,7 @@ const TextAreaContainer = forwardRef( textHint={textHint} formHeader={formHeader} showHelpIcon={showHelpIcon} + appearance={appearance} {...props} /> ); diff --git a/app/components/controls/TextArea/TextArea.types.ts b/app/components/controls/TextArea/TextArea.types.ts index b482d71..002130d 100644 --- a/app/components/controls/TextArea/TextArea.types.ts +++ b/app/components/controls/TextArea/TextArea.types.ts @@ -3,6 +3,8 @@ import type { InputStateValue } from "../../../../lib/propNormalization"; export type TextAreaSizeValue = "small" | "medium" | "large" | "Small" | "Medium" | "Large"; export type TextAreaLabelVariantValue = "default" | "horizontal" | "Default" | "Horizontal"; +export type TextAreaAppearanceValue = "default" | "embedded" | "Default" | "Embedded"; + export interface TextAreaProps extends Omit< React.TextareaHTMLAttributes, "size" | "onChange" | "onFocus" | "onBlur" @@ -47,6 +49,12 @@ export interface TextAreaProps extends Omit< * @default false */ showHelpIcon?: boolean; + /** + * Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668): + * borderless, darker grey background, white text. "default" is standard bordered input. + * @default "default" + */ + appearance?: TextAreaAppearanceValue; } export interface TextAreaViewProps { @@ -73,4 +81,5 @@ export interface TextAreaViewProps { textHint?: boolean; formHeader?: boolean; showHelpIcon?: boolean; + appearance?: "default" | "embedded"; } diff --git a/app/components/controls/TextArea/TextArea.view.tsx b/app/components/controls/TextArea/TextArea.view.tsx index 80bdf25..b7b1fc4 100644 --- a/app/components/controls/TextArea/TextArea.view.tsx +++ b/app/components/controls/TextArea/TextArea.view.tsx @@ -24,6 +24,12 @@ export const TextAreaView = forwardRef( textHint = false, formHeader = true, showHelpIcon = false, + appearance: _appearance, + // Component-only props: do not pass to DOM + size: _size, + labelVariant: _labelVariant, + state: _state, + error: _error, ...props }, ref, 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..fe30413 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) */} ( + ({ + cards, + selectedId: controlledSelectedId, + selectedIds: controlledSelectedIds, + onCardSelect: controlledOnCardSelect, + expanded: controlledExpanded, + onToggleExpand: controlledOnToggleExpand, + hasMore = true, + toggleLabel = DEFAULT_TOGGLE_LABEL, + showLessLabel = DEFAULT_SHOW_LESS_LABEL, + title = "", + description = "", + 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..7dfe180 --- /dev/null +++ b/app/components/utility/CardStack/CardStack.types.ts @@ -0,0 +1,35 @@ +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; + 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; + 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..23eb8de --- /dev/null +++ b/app/components/utility/CardStack/CardStack.view.tsx @@ -0,0 +1,114 @@ +"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, + 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); + + 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/Scrollbar/Scrollbar.types.ts b/app/components/utility/Scrollbar/Scrollbar.types.ts new file mode 100644 index 0000000..29af7fd --- /dev/null +++ b/app/components/utility/Scrollbar/Scrollbar.types.ts @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export interface ScrollbarProps { + /** Content to scroll. */ + children: ReactNode; + /** Optional class name merged with scrollbar-design. */ + className?: string; + /** Vertical scroll only, horizontal only, or both. @default "vertical" */ + orientation?: "vertical" | "horizontal" | "both"; +} diff --git a/app/components/utility/Scrollbar/Scrollbar.view.tsx b/app/components/utility/Scrollbar/Scrollbar.view.tsx new file mode 100644 index 0000000..445492c --- /dev/null +++ b/app/components/utility/Scrollbar/Scrollbar.view.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { ScrollbarProps } from "./Scrollbar.types"; + +const overflowClass = { + vertical: "overflow-x-clip overflow-y-auto", + horizontal: "overflow-y-clip overflow-x-auto", + both: "overflow-auto", +} as const; + +export function ScrollbarView({ + children, + className = "", + orientation = "vertical", +}: ScrollbarProps) { + return ( +
+ {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..b052b1c --- /dev/null +++ b/app/components/utility/Tag/Tag.container.tsx @@ -0,0 +1,25 @@ +"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..01bf193 --- /dev/null +++ b/app/components/utility/Tag/Tag.view.tsx @@ -0,0 +1,28 @@ +"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/create/[step]/page.tsx b/app/create/[step]/page.tsx index 22b4786..5c0239b 100644 --- a/app/create/[step]/page.tsx +++ b/app/create/[step]/page.tsx @@ -17,8 +17,7 @@ const VALID_STEPS: CreateFlowStep[] = [ "select", "upload", "review", - "compact-cards", - "expanded-cards", + "cards", "right-rail", "final-review", "completed", diff --git a/app/create/cards/page.tsx b/app/create/cards/page.tsx new file mode 100644 index 0000000..2814345 --- /dev/null +++ b/app/create/cards/page.tsx @@ -0,0 +1,299 @@ +"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, + onChange, +}: { + title: string; + value: string; + onChange: (value: string) => void; +}) { + return ( +
+
+

+ {title} +

+ + ? + +
+