This commit is contained in:
537
apps/www/app/_components/BrandPersonasCard.tsx
Normal file
537
apps/www/app/_components/BrandPersonasCard.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
'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<number, Persona>(
|
||||
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<Record<PersonaSlug, string>> = {
|
||||
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<ReadonlySet<PersonaSlug>>(
|
||||
() => 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 (
|
||||
<div
|
||||
data-testid="brand-personas-card-compact"
|
||||
data-variant="compact"
|
||||
aria-label={t('compact_aria')}
|
||||
>
|
||||
{/* 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. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="brand-personas-card"
|
||||
data-variant="landing"
|
||||
aria-labelledby="waggle-hive-heading"
|
||||
style={sectionStyle}
|
||||
>
|
||||
<header style={headerStyle}>
|
||||
{eyebrow ? <p style={eyebrowStyle}>{eyebrow}</p> : null}
|
||||
<h2 id="waggle-hive-heading" style={headingStyle}>
|
||||
{resolvedHeading}
|
||||
</h2>
|
||||
<p style={subtitleStyle}>{resolvedSubtitle}</p>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
role="list"
|
||||
data-testid="brand-personas-grid"
|
||||
className="waggle-persona-grid"
|
||||
>
|
||||
{LANDING_GRID_SEQUENCE.map((entry, index) => {
|
||||
if (entry === 'filler') {
|
||||
if (!showFillerTiles) return null;
|
||||
return (
|
||||
<li
|
||||
key={`filler-${index}`}
|
||||
aria-hidden="true"
|
||||
data-testid="brand-personas-filler"
|
||||
className="waggle-persona-filler"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const persona = personaByOrder.get(entry);
|
||||
if (!persona) {
|
||||
// Guard: should never happen — sequence mirrors canonical order.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PersonaTile
|
||||
key={persona.slug}
|
||||
persona={persona}
|
||||
hasError={erroredSlugs.has(persona.slug)}
|
||||
onAssetError={handleAssetError}
|
||||
onPersonaClick={onPersonaClick}
|
||||
onTileHover={onTileHover}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{cta ? (
|
||||
<div data-testid="brand-personas-cta" style={ctaWrapperStyle}>
|
||||
{cta}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<style>{scopedCss}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLElement>) => {
|
||||
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 = (
|
||||
<figure className="waggle-persona-figure">
|
||||
<div className="waggle-persona-asset-frame">
|
||||
{hasError ? (
|
||||
<div
|
||||
data-testid={`persona-placeholder-${persona.slug}`}
|
||||
data-placeholder="true"
|
||||
className="waggle-persona-placeholder"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="waggle-persona-placeholder-dot" />
|
||||
</div>
|
||||
) : (
|
||||
/* 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. */
|
||||
<Image
|
||||
src={persona.imagePath}
|
||||
alt={persona.alt}
|
||||
loading="eager"
|
||||
width={256}
|
||||
height={256}
|
||||
className="waggle-persona-asset"
|
||||
onError={() => onAssetError(persona.slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<figcaption className="waggle-persona-caption">
|
||||
<strong className="waggle-persona-title">{persona.title}</strong>
|
||||
<span className="waggle-persona-role">{persona.role}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
|
||||
const tileStyle = {
|
||||
'--accent': PERSONA_ACCENTS[persona.slug],
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<li
|
||||
data-testid={`persona-tile-${persona.slug}`}
|
||||
data-slug={persona.slug}
|
||||
data-placeholder={hasError ? 'true' : undefined}
|
||||
className="waggle-persona-tile"
|
||||
style={tileStyle}
|
||||
onMouseEnter={handleHover}
|
||||
onFocus={handleFocus}
|
||||
>
|
||||
{isInteractive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="waggle-persona-button"
|
||||
aria-label={persona.alt}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
{figure}
|
||||
</button>
|
||||
) : (
|
||||
figure
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 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;
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user