'use client'; import { useCallback, useState, type CSSProperties, type KeyboardEvent, type ReactNode, } from 'react'; import Image from 'next/image'; import { useTranslations } from 'next-intl'; import { HEX_TEXTURE_PATH, personas, type Persona, type PersonaSlug, } from '../_data/personas'; /** * 4x4 grid sequence in row-major order. `'filler'` slots occupy top-left, * top-right, and bottom-right corners. Numbers reference `Persona.order`. */ const LANDING_GRID_SEQUENCE: ReadonlyArray<'filler' | number> = [ 'filler', 1, 2, 'filler', 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 'filler', ]; const personaByOrder = new Map( personas.map((p) => [p.order, p]), ); /** * Per-persona accent — a curated warm ramp (honey / amber / copper / bronze / * terracotta family; no purple, no gradients). Round-6 palette discipline: * exactly ONE muted cool note survives (sleeping → night blue, where the hue * IS the meaning); everything else stays in the warm-Hive family. Each tile * gets its hue on the title, top hairline, and hover border/glow so the grid * reads as a cast of characters, not a spreadsheet. Hues are chosen so * horizontally/vertically adjacent tiles (4-col landing grid) never repeat, * and a few map to meaning (confused → terracotta flag, researcher/team → * deep bronze, analyst/architect → copper). */ const PERSONA_ACCENTS: Readonly> = { hunter: '#f6c45a', researcher: '#c07e16', analyst: '#d98a3d', connector: '#f2b950', architect: '#d98a3d', builder: '#e9a52c', writer: '#e0916f', orchestrator: '#e9a52c', marketer: '#f6c45a', team: '#c07e16', celebrating: '#f9d27e', confused: '#db8068', sleeping: '#86a9d1', }; export interface BrandPersonasCardProps { /** Optional uppercase kicker rendered above the heading (e.g. "Built for"). */ eyebrow?: string; heading?: string; subtitle?: string; showFillerTiles?: boolean; variant?: 'landing' | 'compact'; onTileHover?: (slug: PersonaSlug) => void; onPersonaClick?: (slug: PersonaSlug) => void; cta?: ReactNode; } /** * Single-surface canon of the 13 Waggle bee personas. * * @remarks * Copy is imported verbatim from `_data/personas.ts` — do not override in-place. * Assets are loaded eagerly via `next/image` (optimizer serves ~256px * AVIF/WebP of the 2048px source PNGs) with an `onError` fallback that flips * the tile to a hex-texture placeholder. The placeholder auto-disables when an * asset loads successfully, so shipping new PNGs requires no code change. * * @todo compact variant scaffolding — implement in future sprint */ export default function BrandPersonasCard({ eyebrow, heading, subtitle, showFillerTiles = true, variant = 'landing', onTileHover, onPersonaClick, cta, }: BrandPersonasCardProps) { const t = useTranslations('landing.brand_personas'); const resolvedHeading = heading ?? t('default_heading'); const resolvedSubtitle = subtitle ?? t('default_subtitle'); const [erroredSlugs, setErroredSlugs] = useState>( () => new Set(), ); const handleAssetError = useCallback((slug: PersonaSlug) => { setErroredSlugs((prev) => { if (prev.has(slug)) return prev; const next = new Set(prev); next.add(slug); return next; }); }, []); if (variant === 'compact') { return (
{/* Compact variant scaffolding — intentional stub. TypeScript interface is stable; parent pages may wire props today and receive a fuller layout in a future sprint without refactor. */}
); } return (
{eyebrow ?

{eyebrow}

: null}

{resolvedHeading}

{resolvedSubtitle}

    {LANDING_GRID_SEQUENCE.map((entry, index) => { if (entry === 'filler') { if (!showFillerTiles) return null; return (
{cta ? (
{cta}
) : null}
); } interface PersonaTileProps { persona: Persona; hasError: boolean; onAssetError: (slug: PersonaSlug) => void; onPersonaClick?: (slug: PersonaSlug) => void; onTileHover?: (slug: PersonaSlug) => void; } function PersonaTile({ persona, hasError, onAssetError, onPersonaClick, onTileHover, }: PersonaTileProps) { const handleClick = useCallback(() => { onPersonaClick?.(persona.slug); }, [onPersonaClick, persona.slug]); const handleKey = useCallback( (event: KeyboardEvent) => { if (!onPersonaClick) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onPersonaClick(persona.slug); } }, [onPersonaClick, persona.slug], ); const handleHover = useCallback(() => { onTileHover?.(persona.slug); }, [onTileHover, persona.slug]); const handleFocus = useCallback(() => { // Keyboard-only "hover" equivalent so a11y consumers get the same signal. onTileHover?.(persona.slug); }, [onTileHover, persona.slug]); const isInteractive = Boolean(onPersonaClick); const figure = (
{hasError ? ( ) : ( /* next/image (optimizer → ~256px AVIF/WebP) makes eager loading affordable; the raw 2048px PNGs are ~2.5 MB each. Eager so a tile never paints as an empty frame while lazy IO waits. */ {persona.alt} onAssetError(persona.slug)} /> )}
{persona.title} {persona.role}
); const tileStyle = { '--accent': PERSONA_ACCENTS[persona.slug], } as CSSProperties; return (
  • {isInteractive ? ( ) : ( figure )}
  • ); } /* ------------------------------------------------------------------ */ /* Inline styles (match existing apps/www convention — no Tailwind) */ /* ------------------------------------------------------------------ */ const sectionStyle: CSSProperties = { background: 'var(--hive-950, #0e0c07)', padding: '96px 24px', width: '100%', boxSizing: 'border-box', }; const headerStyle: CSSProperties = { maxWidth: 1200, margin: '0 auto 48px', textAlign: 'center', }; const eyebrowStyle: CSSProperties = { fontFamily: "var(--sans)", fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--honey-500, #e9a52c)', margin: 0, marginBottom: 12, }; const headingStyle: CSSProperties = { fontFamily: "var(--sans)", fontSize: 'clamp(28px, 4vw, 32px)', fontWeight: 700, color: 'var(--hive-50, #f6f1e4)', margin: 0, marginBottom: 12, }; const subtitleStyle: CSSProperties = { fontFamily: "var(--sans)", fontSize: 'clamp(16px, 2vw, 18px)', fontWeight: 400, color: 'var(--hive-300, #c8bfa9)', margin: 0, }; const ctaWrapperStyle: CSSProperties = { maxWidth: 1200, margin: '48px auto 0', display: 'flex', justifyContent: 'center', }; /* ------------------------------------------------------------------ */ /* Scoped CSS — component-local selectors to avoid global collisions */ /* ------------------------------------------------------------------ */ const scopedCss = ` .waggle-persona-grid { list-style: none; padding: 0; margin: 0 auto; max-width: 1200px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; } @media (max-width: 1023px) { .waggle-persona-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .waggle-persona-filler { display: none; } } @media (max-width: 640px) { .waggle-persona-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .waggle-persona-tile { position: relative; list-style: none; overflow: hidden; background: linear-gradient(180deg, #14110b 0%, #0e0c07 100%); border: 1px solid #1f1a12; border-radius: 16px; padding: 20px; min-height: 260px; transition: border-color 200ms ease-out, transform 200ms ease-out, box-shadow 200ms ease-out; } /* Per-role accent hairline across the top edge of each tile. */ .waggle-persona-tile::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--accent, #e9a52c); opacity: 0.5; transition: opacity 200ms ease-out; } .waggle-persona-tile:hover, .waggle-persona-tile:focus-within { border-color: var(--accent, #e9a52c); transform: translateY(-3px); box-shadow: 0 12px 34px -16px color-mix(in srgb, var(--accent, #e9a52c) 55%, transparent); } .waggle-persona-tile:hover::before, .waggle-persona-tile:focus-within::before { opacity: 1; } @media (prefers-reduced-motion: reduce) { .waggle-persona-tile, .waggle-persona-tile:hover, .waggle-persona-tile:focus-within { transition: none; transform: none; } } .waggle-persona-button { all: unset; display: block; width: 100%; cursor: pointer; border-radius: 12px; } .waggle-persona-button:focus-visible { outline: 2px solid var(--accent, #e9a52c); outline-offset: 2px; } .waggle-persona-figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 16px; } .waggle-persona-asset-frame { position: relative; width: 100%; aspect-ratio: 1 / 1; max-width: 256px; display: flex; align-items: center; justify-content: center; } .waggle-persona-asset { position: relative; width: 100%; height: 100%; object-fit: contain; display: block; /* The mascot PNGs are background-transparent (2026-07-06 flood-fill) — they sit directly on the card gradient. The old dark vignette + edge-fade masks compensated for baked-black squares and are gone: with real alpha they READ as a dark box behind the art. */ filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.35)); } .waggle-persona-placeholder { width: 100%; height: 100%; background-image: url("${HEX_TEXTURE_PATH}"); background-size: cover; background-position: center; background-color: #14110b; opacity: 0.6; border-radius: 12px; display: flex; align-items: center; justify-content: center; } .waggle-persona-placeholder-dot { display: block; width: 48px; height: 48px; border-radius: 50%; background: #f6c45a; box-shadow: 0 0 24px rgba(246, 196, 90, 0.4); } .waggle-persona-caption { text-align: center; display: flex; flex-direction: column; gap: 6px; } .waggle-persona-title { font-family: var(--sans); font-size: 16px; font-weight: 600; color: var(--accent, #f6c45a); letter-spacing: 0.01em; } .waggle-persona-role { font-family: var(--sans); font-size: 13px; font-weight: 400; color: #c8bfa9; line-height: 1.45; } /* Filler slots keep the staggered grid rhythm without reading as broken cards: no border, no card surface — an outlined-but-hollow frame looks like missing content. Instead: ambient comb texture that fades out radially, so the corners read as intentional negative space. Decorative only (aria-hidden on the element). */ .waggle-persona-filler { position: relative; list-style: none; min-height: 260px; border-radius: 16px; overflow: hidden; /* Round-7: complete the falloff — the texture dissolves toward the grid's outer edge so the ghost reads as intentional ambience, never as an unloaded card. */ -webkit-mask-image: linear-gradient(to right, rgba(0,0,0,0.8), rgba(0,0,0,0.15)); mask-image: linear-gradient(to right, rgba(0,0,0,0.8), rgba(0,0,0,0.15)); } .waggle-persona-filler:nth-of-type(1), li.waggle-persona-filler:first-child { -webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0.15)); mask-image: linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0.15)); } /* Round-6: fainter + slightly shrunken so the ghosts can't be mistaken for unloaded cards — clearly ambient texture, not content-in-waiting. */ .waggle-persona-filler::before { content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 50% 46%, rgba(233, 165, 44, 0.10), rgba(233, 165, 44, 0) 62%), url("${HEX_TEXTURE_PATH}") center / cover no-repeat; opacity: 0.2; transform: scale(0.88); -webkit-mask-image: radial-gradient(circle at 50% 50%, #000 22%, transparent 74%); mask-image: radial-gradient(circle at 50% 50%, #000 22%, transparent 74%); } .waggle-persona-filler::after { content: ""; position: absolute; left: 50%; top: 50%; width: 34px; height: 38px; transform: translate(-50%, -50%); background: no-repeat center / contain url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='42' height='46' viewBox='0 0 42 46' fill='none'%3E%3Cpath d='M21 2 L39 12.5 V33.5 L21 44 L3 33.5 V12.5 Z' stroke='%23e9a52c' stroke-width='1.4' stroke-linejoin='round'/%3E%3C/svg%3E"); opacity: 0.18; } `;