moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View 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;
}
`;