This commit is contained in:
58
apps/www/app/_components/BrandMark.tsx
Normal file
58
apps/www/app/_components/BrandMark.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
interface BrandMarkProps {
|
||||
readonly size?: number;
|
||||
readonly withWordmark?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waggle brand mark: a hive cell (pointy-top hexagon) holding a honey core —
|
||||
* one memory node in the graph. Pure SVG so it stays crisp at every density
|
||||
* and inherits no JPEG artifacts (replaces the legacy logo.jpeg raster).
|
||||
*/
|
||||
export default function BrandMark({ size = 28, withWordmark = false }: BrandMarkProps) {
|
||||
const mark = (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Waggle"
|
||||
>
|
||||
<path
|
||||
d="M16 3 L27.26 9.5 L27.26 22.5 L16 29 L4.74 22.5 L4.74 9.5 Z"
|
||||
stroke="var(--honey-500, #e9a52c)"
|
||||
strokeWidth="2.4"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M16 10.8 L20.5 13.4 L20.5 18.6 L16 21.2 L11.5 18.6 L11.5 13.4 Z"
|
||||
fill="var(--honey-400, #f6c45a)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
if (!withWordmark) return mark;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{mark}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
}}
|
||||
>
|
||||
Waggle
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
`;
|
||||
71
apps/www/app/_components/DownloadCTA.tsx
Normal file
71
apps/www/app/_components/DownloadCTA.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { detectOSFromUserAgent, type OSId } from '../_lib/os-detection';
|
||||
import { emit, events } from '../_lib/event-taxonomy';
|
||||
|
||||
interface DownloadCTAProps {
|
||||
readonly variant?: 'primary' | 'ghost';
|
||||
readonly size?: 'default' | 'small';
|
||||
readonly section: 'hero' | 'navbar' | 'solo-tier' | 'final-cta';
|
||||
readonly children?: ReactNode;
|
||||
readonly style?: CSSProperties;
|
||||
}
|
||||
|
||||
const DOWNLOAD_URL = '/download';
|
||||
|
||||
/**
|
||||
* OS-aware download CTA. Renders a generic "Download" label at SSR + first
|
||||
* paint, then swaps to "Download for {os}" after hydration via
|
||||
* `navigator.userAgent` detection.
|
||||
*
|
||||
* Styling comes from the shared `.btn` primitives in globals.css so every
|
||||
* download button on the page is pixel-identical. Strings live in
|
||||
* `messages/en.json` under `landing.download_cta.*` with an ICU placeholder
|
||||
* for the OS name.
|
||||
*/
|
||||
export default function DownloadCTA({
|
||||
variant = 'primary',
|
||||
size = 'default',
|
||||
section,
|
||||
children,
|
||||
style,
|
||||
}: DownloadCTAProps) {
|
||||
const t = useTranslations('landing.download_cta');
|
||||
const [os, setOS] = useState<OSId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
setOS(detectOSFromUserAgent(navigator.userAgent));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const label = children ?? (os ? t('with_os', { os }) : t('default'));
|
||||
|
||||
const className = [
|
||||
'btn',
|
||||
variant === 'primary' ? 'btn-primary' : 'btn-ghost',
|
||||
size === 'small' ? 'btn-small' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const handleClick = () => {
|
||||
emit({
|
||||
name: events.ctaClick,
|
||||
properties: { section, os: os ?? 'unknown' },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={DOWNLOAD_URL}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
style={style}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
67
apps/www/app/_components/FeatureGrid.module.css
Normal file
67
apps/www/app/_components/FeatureGrid.module.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 64px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 28px 26px 30px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
transition: border-color 0.2s ease, transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
border-color: var(--honey-line);
|
||||
box-shadow: var(--shadow-honey);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
color: var(--honey-400);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
127
apps/www/app/_components/FeatureGrid.tsx
Normal file
127
apps/www/app/_components/FeatureGrid.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './FeatureGrid.module.css';
|
||||
|
||||
type FeatureKey =
|
||||
| 'personas'
|
||||
| 'models'
|
||||
| 'harvest'
|
||||
| 'loops'
|
||||
| 'skills'
|
||||
| 'memory_center';
|
||||
|
||||
const FEATURES: readonly FeatureKey[] = [
|
||||
'personas',
|
||||
'models',
|
||||
'harvest',
|
||||
'loops',
|
||||
'skills',
|
||||
'memory_center',
|
||||
];
|
||||
|
||||
/**
|
||||
* Six feature cards, each naming a real subsystem (persona roster, LiteLLM
|
||||
* routing, Harvest, Loops + approval queue, skills/connectors/MCP catalog,
|
||||
* Memory Center). Strings under `landing.features.*`.
|
||||
*/
|
||||
export default async function FeatureGrid() {
|
||||
const t = await getTranslations('landing.features');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="features"
|
||||
className="section"
|
||||
aria-labelledby="features-heading"
|
||||
>
|
||||
<div className="container-wide">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="features-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{FEATURES.map((key, i) => (
|
||||
<Reveal key={key} delay={((i % 3) + 1) as 1 | 2 | 3}>
|
||||
<div className={styles.item}>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
<FeatureIcon feature={key} />
|
||||
</span>
|
||||
<h3 className={styles.title}>{t(`items.${key}.title`)}</h3>
|
||||
<p className={styles.body}>{t(`items.${key}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal 18px line icons — one per subsystem, stroke inherits currentColor. */
|
||||
function FeatureIcon({ feature }: { readonly feature: FeatureKey }) {
|
||||
const common = {
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 18 18',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
} as const;
|
||||
|
||||
switch (feature) {
|
||||
case 'personas':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<circle cx="6" cy="6.5" r="2.6" />
|
||||
<path d="M1.8 14.8 C2.4 11.8 4.4 10.6 6 10.6 C7.6 10.6 9.6 11.8 10.2 14.8" />
|
||||
<circle cx="12.8" cy="5.4" r="2" />
|
||||
<path d="M10.9 9.4 C12 8.8 14.6 9 16.2 12.4" />
|
||||
</svg>
|
||||
);
|
||||
case 'models':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="2.2" />
|
||||
<path d="M9 6.8 V2.2 M9 11.2 V15.8 M6.8 9 H2.2 M11.2 9 H15.8" />
|
||||
<circle cx="9" cy="2.2" r="1.2" />
|
||||
<circle cx="9" cy="15.8" r="1.2" />
|
||||
<circle cx="2.2" cy="9" r="1.2" />
|
||||
<circle cx="15.8" cy="9" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
case 'harvest':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<path d="M9 2 V11 M5.4 7.6 L9 11.2 L12.6 7.6" />
|
||||
<path d="M2.5 12.5 V14.5 C2.5 15.3 3.2 16 4 16 H14 C14.8 16 15.5 15.3 15.5 14.5 V12.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'loops':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<path d="M14.5 7 A6 6 0 1 0 15.5 10.5" />
|
||||
<path d="M15.8 3.4 L15.8 7.2 L12 7.2" />
|
||||
</svg>
|
||||
);
|
||||
case 'skills':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<rect x="2.4" y="2.4" width="5.6" height="5.6" rx="1.4" />
|
||||
<rect x="10" y="2.4" width="5.6" height="5.6" rx="1.4" />
|
||||
<rect x="2.4" y="10" width="5.6" height="5.6" rx="1.4" />
|
||||
<path d="M12.8 10.4 V15.2 M10.4 12.8 H15.2" />
|
||||
</svg>
|
||||
);
|
||||
case 'memory_center':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<path d="M9 1.8 L15.2 5.4 L15.2 12.6 L9 16.2 L2.8 12.6 L2.8 5.4 Z" />
|
||||
<circle cx="9" cy="9" r="2.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
71
apps/www/app/_components/FinalCTA.module.css
Normal file
71
apps/www/app/_components/FinalCTA.module.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.section {
|
||||
position: relative;
|
||||
padding: var(--section-pad) var(--gutter);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
bottom: -300px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 900px;
|
||||
height: 520px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(233, 165, 44, 0.1) 0%,
|
||||
rgba(233, 165, 44, 0.03) 45%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: relative;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: clamp(2.125rem, 4.5vw, 3.25rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: var(--text-lead);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.kvark {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kvarkLink {
|
||||
color: var(--honey-400);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.kvarkLink:hover {
|
||||
color: var(--honey-300);
|
||||
}
|
||||
51
apps/www/app/_components/FinalCTA.tsx
Normal file
51
apps/www/app/_components/FinalCTA.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import styles from './FinalCTA.module.css';
|
||||
|
||||
const WAGGLE_REPO_URL = 'https://github.com/marolinik/waggle-os';
|
||||
const KVARK_CONTACT =
|
||||
'mailto:kvark@egzakta.com?subject=Waggle%20%E2%86%92%20KVARK%20sovereign%20deployment';
|
||||
|
||||
/**
|
||||
* Closing CTA — echoes the hero promise, then routes to Download or GitHub,
|
||||
* with the KVARK sovereign-deployment escape hatch underneath. Strings
|
||||
* under `landing.final_cta.*`.
|
||||
*/
|
||||
export default async function FinalCTA() {
|
||||
const t = await getTranslations('landing.final_cta');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="final-cta"
|
||||
className={`${styles.section} honeycomb-bg`}
|
||||
aria-labelledby="final-heading"
|
||||
>
|
||||
<div className={styles.glow} aria-hidden="true" />
|
||||
<div className={styles.inner}>
|
||||
<h2 id="final-heading" className={styles.headline}>
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className={styles.subhead}>{t('subhead')}</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
<DownloadCTA section="final-cta" variant="primary" />
|
||||
<a
|
||||
href={WAGGLE_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
{t('cta_github')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className={styles.kvark}>
|
||||
{t('kvark_text')}
|
||||
<a href={KVARK_CONTACT} className={styles.kvarkLink}>
|
||||
{t('kvark_cta')} →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
95
apps/www/app/_components/Footer.module.css
Normal file
95
apps/www/app/_components/Footer.module.css
Normal file
@@ -0,0 +1,95 @@
|
||||
.footer {
|
||||
padding: 72px var(--gutter) 32px;
|
||||
background: var(--hive-950);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.grid {
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto 56px;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.brandBlock {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.brandDescription {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin-top: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.attribution {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--hive-200);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.columnList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.columnList li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.columnLink {
|
||||
font-size: 13px;
|
||||
color: var(--hive-300);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.columnLink:hover {
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.baseline {
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--hive-800);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.baselineRight {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.baseline {
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
105
apps/www/app/_components/Footer.tsx
Normal file
105
apps/www/app/_components/Footer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import BrandMark from './BrandMark';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
interface FooterLink {
|
||||
readonly key: string;
|
||||
readonly href: string;
|
||||
readonly external?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer link map. Rule: every link must resolve to a real destination —
|
||||
* no `#` placeholders. Columns whose content does not exist yet (blog,
|
||||
* press, changelog) are omitted until they do.
|
||||
*/
|
||||
const PRODUCT_LINKS: readonly FooterLink[] = [
|
||||
{
|
||||
key: 'download',
|
||||
href: '/download',
|
||||
},
|
||||
{ key: 'pricing', href: '/#pricing' },
|
||||
{ key: 'how_it_works', href: '/#how-it-works' },
|
||||
{ key: 'memory', href: '/#memory' },
|
||||
];
|
||||
|
||||
const RESEARCH_LINKS: readonly FooterLink[] = [
|
||||
{ key: 'methodology', href: '/docs/methodology' },
|
||||
{
|
||||
key: 'benchmarks',
|
||||
href: 'https://github.com/marolinik/hive-mind',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
key: 'hive_mind',
|
||||
href: 'https://github.com/marolinik/hive-mind',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
const COMPANY_LINKS: readonly FooterLink[] = [
|
||||
{ key: 'about_egzakta', href: 'https://egzakta.com', external: true },
|
||||
{ key: 'kvark', href: 'https://www.kvark.ai', external: true },
|
||||
{ key: 'contact', href: 'mailto:hello@egzakta.com' },
|
||||
];
|
||||
|
||||
const LEGAL_LINKS: readonly FooterLink[] = [
|
||||
{ key: 'terms', href: '/terms' },
|
||||
{ key: 'privacy', href: '/privacy' },
|
||||
{ key: 'cookies', href: '/cookies' },
|
||||
{ key: 'eu_ai_act', href: '/eu-ai-act' },
|
||||
{
|
||||
key: 'apache',
|
||||
href: 'https://github.com/marolinik/hive-mind/blob/master/LICENSE',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
const COLUMN_DEFS = [
|
||||
{ ns: 'product', links: PRODUCT_LINKS },
|
||||
{ ns: 'research', links: RESEARCH_LINKS },
|
||||
{ ns: 'company', links: COMPANY_LINKS },
|
||||
{ ns: 'legal', links: LEGAL_LINKS },
|
||||
] as const;
|
||||
|
||||
export default async function Footer() {
|
||||
const t = await getTranslations('landing.footer');
|
||||
|
||||
return (
|
||||
<footer id="footer" className={styles.footer}>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.brandBlock}>
|
||||
<BrandMark withWordmark />
|
||||
<p className={styles.brandDescription}>{t('brand.description')}</p>
|
||||
<p className={styles.attribution}>{t('brand.attribution')}</p>
|
||||
</div>
|
||||
|
||||
{COLUMN_DEFS.map((col) => (
|
||||
<div key={col.ns}>
|
||||
<h3 className={styles.columnTitle}>{t(`columns.${col.ns}.title`)}</h3>
|
||||
<ul className={styles.columnList}>
|
||||
{col.links.map((l) => (
|
||||
<li key={l.key}>
|
||||
<a
|
||||
href={l.href}
|
||||
{...(l.external
|
||||
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||
: null)}
|
||||
className={styles.columnLink}
|
||||
>
|
||||
{t(`columns.${col.ns}.links.${l.key}`)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.baseline}>
|
||||
<span>{t('base_line.left')}</span>
|
||||
<span className={styles.baselineRight}>{t('base_line.right')}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
160
apps/www/app/_components/Hero.module.css
Normal file
160
apps/www/app/_components/Hero.module.css
Normal file
@@ -0,0 +1,160 @@
|
||||
.section {
|
||||
position: relative;
|
||||
padding: 168px var(--gutter) 104px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
top: -240px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 900px;
|
||||
height: 560px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(233, 165, 44, 0.09) 0%,
|
||||
rgba(233, 165, 44, 0.03) 45%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 10fr) minmax(0, 9fr);
|
||||
gap: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: var(--text-display);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.headlineEmphasis {
|
||||
color: var(--honey-400);
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: var(--text-lead);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin-bottom: 36px;
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.microcopy {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
row-gap: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.microItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.microDot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--honey-500);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.section {
|
||||
padding-top: 136px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 56px;
|
||||
}
|
||||
|
||||
.copy {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Round-7: one benchmark strip in the hero's dead bottom quarter.
|
||||
Wave R Lane E: the 86.49% number is lifted to a flagship stat chip
|
||||
(larger mono, honey-wash lozenge) so the leaderboard claim carries weight
|
||||
near the CTAs; the rest stays quiet supporting mono copy. */
|
||||
.proofStrip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
max-width: 40em;
|
||||
color: var(--honey-500);
|
||||
text-decoration: none;
|
||||
}
|
||||
.proofStat {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--honey-wash);
|
||||
border: 1px solid var(--honey-line);
|
||||
font-family: var(--mono);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--honey-400);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.proofLabel {
|
||||
font-family: var(--mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.proofStrip:hover .proofStat {
|
||||
border-color: var(--honey-500);
|
||||
background: rgba(233, 165, 44, 0.16);
|
||||
}
|
||||
.proofStrip:hover .proofLabel {
|
||||
color: var(--hive-300);
|
||||
}
|
||||
71
apps/www/app/_components/Hero.tsx
Normal file
71
apps/www/app/_components/Hero.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import HeroVisual from './HeroVisual';
|
||||
import styles from './Hero.module.css';
|
||||
|
||||
const MICROCOPY_KEYS = [
|
||||
'microcopy_free',
|
||||
'microcopy_platforms',
|
||||
'microcopy_oss',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Hero — single committed headline (the A/B variant infra was removed with
|
||||
* the 2026-07 rebuild). Two columns: positioning copy left, the memory-core
|
||||
* window visual right. All strings under `landing.hero.*`.
|
||||
*/
|
||||
export default async function Hero() {
|
||||
const t = await getTranslations('landing.hero');
|
||||
|
||||
return (
|
||||
<section id="hero" className={`${styles.section} honeycomb-bg`}>
|
||||
<div className={styles.glow} aria-hidden="true" />
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.copy}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
|
||||
<h1 className={styles.headline}>
|
||||
{t('headline_lead')}{' '}
|
||||
<span className={styles.headlineEmphasis}>
|
||||
{t('headline_emphasis')}
|
||||
</span>
|
||||
.
|
||||
</h1>
|
||||
|
||||
<p className={styles.subhead}>{t('subhead')}</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
<DownloadCTA section="hero" variant="primary" />
|
||||
<a href="#proof" className="btn btn-ghost">
|
||||
{t('cta_secondary')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul className={styles.microcopy}>
|
||||
{MICROCOPY_KEYS.map((key) => (
|
||||
<li key={key} className={styles.microItem}>
|
||||
<span aria-hidden="true" className={styles.microDot} />
|
||||
{t(key)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Round-7: the hero's dead bottom quarter carries the product's
|
||||
strongest proof — one benchmark strip, linking to #proof.
|
||||
Wave R Lane E: the 86.49% number is lifted to a flagship stat
|
||||
(larger mono, honey) so the leaderboard claim carries visual
|
||||
weight near the CTAs; the rest stays quiet supporting copy. */}
|
||||
<a href="#proof" className={styles.proofStrip}>
|
||||
<span className={styles.proofStat}>{t('proof_stat')}</span>
|
||||
<span className={styles.proofLabel}>{t('proof_strip')}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className={styles.visual}>
|
||||
<HeroVisual />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
137
apps/www/app/_components/HeroVisual.module.css
Normal file
137
apps/www/app/_components/HeroVisual.module.css
Normal file
@@ -0,0 +1,137 @@
|
||||
.window {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titleBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(31, 26, 18, 0.6);
|
||||
}
|
||||
|
||||
.trafficDots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.trafficDot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--hive-600);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.titleBadge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--honey-400);
|
||||
border: 1px solid var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.footerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Edge pulse: honey energy flowing between memory and models. */
|
||||
.edge {
|
||||
stroke: var(--line-strong);
|
||||
stroke-width: 1.2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.edgePulse {
|
||||
stroke: var(--honey-500);
|
||||
stroke-width: 1.4;
|
||||
fill: none;
|
||||
stroke-dasharray: 10 110;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
animation: edge-flow 3.2s linear infinite;
|
||||
}
|
||||
|
||||
.edgePulse2 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.edgePulse3 {
|
||||
animation-delay: 1.6s;
|
||||
}
|
||||
|
||||
.edgePulse4 {
|
||||
animation-delay: 2.4s;
|
||||
}
|
||||
|
||||
@keyframes edge-flow {
|
||||
from {
|
||||
stroke-dashoffset: 120;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.coreGlow {
|
||||
animation: core-breathe 4s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
@keyframes core-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.edgePulse {
|
||||
animation: none;
|
||||
stroke-dasharray: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.coreGlow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
160
apps/www/app/_components/HeroVisual.tsx
Normal file
160
apps/www/app/_components/HeroVisual.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import styles from './HeroVisual.module.css';
|
||||
|
||||
const CHIPS = [
|
||||
{ key: 'claude', x: 28, y: 36, flow: 'out' },
|
||||
{ key: 'gpt', x: 374, y: 36, flow: 'out' },
|
||||
{ key: 'qwen', x: 28, y: 258, flow: 'in' },
|
||||
{ key: 'gemini', x: 374, y: 258, flow: 'out' },
|
||||
] as const;
|
||||
|
||||
const CHIP_W = 118;
|
||||
const CHIP_H = 44;
|
||||
|
||||
/**
|
||||
* Hero visualization — a desktop-app window (Waggle ships as a Tauri
|
||||
* binary) framing the true architecture: one local memory core serving
|
||||
* four model chips. Edges pulse honey; the `qwen · local` edge flows
|
||||
* INTO the core (commit) while the others flow out (recall).
|
||||
*
|
||||
* Server component: the animation is pure CSS (HeroVisual.module.css),
|
||||
* disabled under `prefers-reduced-motion`. No invented numbers anywhere —
|
||||
* labels name real subsystems only.
|
||||
*/
|
||||
export default async function HeroVisual() {
|
||||
const t = await getTranslations('landing.hero_visual');
|
||||
|
||||
return (
|
||||
<figure className={styles.window} aria-label={t('aria_label')}>
|
||||
<div className={styles.titleBar}>
|
||||
<span className={styles.trafficDots} aria-hidden="true">
|
||||
<span className={styles.trafficDot} />
|
||||
<span className={styles.trafficDot} />
|
||||
<span className={styles.trafficDot} />
|
||||
</span>
|
||||
<span className={styles.titleText}>{t('window_title')}</span>
|
||||
<span className={styles.titleBadge}>{t('window_badge')}</span>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox="0 0 520 340"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="hv-core-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="rgba(233,165,44,0.22)" />
|
||||
<stop offset="70%" stopColor="rgba(233,165,44,0.05)" />
|
||||
<stop offset="100%" stopColor="rgba(233,165,44,0)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Edges: base line + honey pulse. Recall edges run core → chip;
|
||||
the commit edge (qwen · local) runs chip → core. */}
|
||||
<path className={styles.edge} d="M 232,140 C 200,112 182,84 150,62" />
|
||||
<path
|
||||
className={`${styles.edgePulse}`}
|
||||
d="M 232,140 C 200,112 182,84 150,62"
|
||||
/>
|
||||
|
||||
<path className={styles.edge} d="M 288,140 C 320,112 338,84 370,62" />
|
||||
<path
|
||||
className={`${styles.edgePulse} ${styles.edgePulse2}`}
|
||||
d="M 288,140 C 320,112 338,84 370,62"
|
||||
/>
|
||||
|
||||
<path className={styles.edge} d="M 150,278 C 182,256 200,224 232,196" />
|
||||
<path
|
||||
className={`${styles.edgePulse} ${styles.edgePulse3}`}
|
||||
d="M 150,278 C 182,256 200,224 232,196"
|
||||
/>
|
||||
|
||||
<path className={styles.edge} d="M 288,196 C 320,224 338,256 370,278" />
|
||||
<path
|
||||
className={`${styles.edgePulse} ${styles.edgePulse4}`}
|
||||
d="M 288,196 C 320,224 338,256 370,278"
|
||||
/>
|
||||
|
||||
{/* Memory core */}
|
||||
<circle
|
||||
className={styles.coreGlow}
|
||||
cx="260"
|
||||
cy="168"
|
||||
r="78"
|
||||
fill="url(#hv-core-glow)"
|
||||
/>
|
||||
<path
|
||||
d="M260 112 L308.5 140 L308.5 196 L260 224 L211.5 196 L211.5 140 Z"
|
||||
fill="rgba(31, 26, 18, 0.85)"
|
||||
stroke="var(--honey-500)"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<text
|
||||
x="260"
|
||||
y="165"
|
||||
textAnchor="middle"
|
||||
fill="var(--hive-50)"
|
||||
fontSize="13.5"
|
||||
fontWeight="600"
|
||||
fontFamily="var(--sans)"
|
||||
>
|
||||
{t('center_label')}
|
||||
</text>
|
||||
<text
|
||||
x="260"
|
||||
y="184"
|
||||
textAnchor="middle"
|
||||
fill="var(--text-muted)"
|
||||
fontSize="9"
|
||||
fontFamily="var(--mono)"
|
||||
>
|
||||
{t('center_sublabel')}
|
||||
</text>
|
||||
|
||||
{/* Model chips */}
|
||||
{CHIPS.map((chip) => (
|
||||
<g key={chip.key}>
|
||||
<rect
|
||||
x={chip.x}
|
||||
y={chip.y}
|
||||
width={CHIP_W}
|
||||
height={CHIP_H}
|
||||
rx="10"
|
||||
fill="var(--surface)"
|
||||
stroke="var(--line-strong)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={chip.x + 14}
|
||||
y={chip.y + 19}
|
||||
fill="var(--hive-100)"
|
||||
fontSize="12"
|
||||
fontWeight="600"
|
||||
fontFamily="var(--mono)"
|
||||
>
|
||||
{t(`chips.${chip.key}_primary`)}
|
||||
</text>
|
||||
<text
|
||||
x={chip.x + 14}
|
||||
y={chip.y + 33}
|
||||
fill={chip.flow === 'in' ? 'var(--honey-400)' : 'var(--text-muted)'}
|
||||
fontSize="9"
|
||||
fontFamily="var(--mono)"
|
||||
>
|
||||
{chip.flow === 'in' ? '↑ ' : '↓ '}
|
||||
{t(`chips.${chip.key}_sub`)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className={styles.footerBar}>
|
||||
<span>{t('footer_left')}</span>
|
||||
<span>{t('footer_right')}</span>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
93
apps/www/app/_components/HowItWorks.module.css
Normal file
93
apps/www/app/_components/HowItWorks.module.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 52px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
padding: 30px 28px 32px;
|
||||
border-radius: var(--r-lg);
|
||||
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
|
||||
border: 1px solid var(--line-soft);
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.step:hover {
|
||||
border-color: var(--honey-line);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-honey);
|
||||
}
|
||||
|
||||
/* Flow connector: a honey chevron seated in the gap before every step after
|
||||
the first, so the three cards read as one sequence (01 → 02 → 03).
|
||||
Each step is wrapped in a <Reveal> div, so the chevron hangs off the reveal
|
||||
wrapper (the actual grid cell), not the inner .step card. */
|
||||
.steps > :global(.reveal) {
|
||||
position: relative;
|
||||
}
|
||||
.steps > :global(.reveal) + :global(.reveal)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 51px;
|
||||
left: -22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: no-repeat center / contain
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M7 4l6 6-6 6' stroke='%23e9a52c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
opacity: 0.75;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stepNumber {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--honey-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
box-shadow: 0 0 20px rgba(233, 165, 44, 0.12);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: var(--text-h3);
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stepBody {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.65;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.steps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Chevrons point downward when the flow stacks vertically. */
|
||||
.steps > :global(.reveal) + :global(.reveal)::before {
|
||||
top: -21px;
|
||||
left: 30px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
44
apps/www/app/_components/HowItWorks.tsx
Normal file
44
apps/www/app/_components/HowItWorks.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './HowItWorks.module.css';
|
||||
|
||||
const STEPS = ['step_01', 'step_02', 'step_03'] as const;
|
||||
|
||||
/**
|
||||
* Three-step product story: import history → work in workspaces → memory
|
||||
* compounds. Strings under `landing.how_it_works.*`.
|
||||
*/
|
||||
export default async function HowItWorks() {
|
||||
const t = await getTranslations('landing.how_it_works');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="how-it-works"
|
||||
className="section"
|
||||
aria-labelledby="how-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="how-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.steps}>
|
||||
{STEPS.map((step, i) => (
|
||||
<Reveal key={step} delay={(i + 1) as 1 | 2 | 3}>
|
||||
<div className={styles.step}>
|
||||
<span className={styles.stepNumber} aria-hidden="true">
|
||||
{t(`${step}.number`)}
|
||||
</span>
|
||||
<h3 className={styles.stepTitle}>{t(`${step}.title`)}</h3>
|
||||
<p className={styles.stepBody}>{t(`${step}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
89
apps/www/app/_components/MemoryDiagram.module.css
Normal file
89
apps/www/app/_components/MemoryDiagram.module.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 72px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.7;
|
||||
color: var(--hive-300);
|
||||
margin-top: 20px;
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
.chips {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 28px 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--hive-200);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
border-radius: 999px;
|
||||
padding: 7px 14px;
|
||||
}
|
||||
|
||||
/* ── Pipeline card ── */
|
||||
.pipeline {
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
padding: 32px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.node {
|
||||
border-radius: var(--r);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
padding: 14px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nodeAccent {
|
||||
border-color: var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
}
|
||||
|
||||
.nodeTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nodeSub {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.connector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
color: var(--honey-600);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 48px;
|
||||
}
|
||||
}
|
||||
80
apps/www/app/_components/MemoryDiagram.tsx
Normal file
80
apps/www/app/_components/MemoryDiagram.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './MemoryDiagram.module.css';
|
||||
|
||||
const CHIP_KEYS = ['local', 'provenance', 'erasure'] as const;
|
||||
|
||||
/**
|
||||
* "Under the hood" — the real memory pipeline, named after the actual
|
||||
* subsystems (frames → hybrid search → knowledge graph → any model).
|
||||
* Copy under `landing.memory.*`; the diagram is semantic HTML so it
|
||||
* stacks naturally and needs no JS.
|
||||
*/
|
||||
export default async function MemoryDiagram() {
|
||||
const t = await getTranslations('landing.memory');
|
||||
|
||||
return (
|
||||
<section id="memory" className="section" aria-labelledby="memory-heading">
|
||||
<div className="container">
|
||||
<div className={styles.grid}>
|
||||
<div>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="memory-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className={styles.body}>{t('body')}</p>
|
||||
<ul className={styles.chips}>
|
||||
{CHIP_KEYS.map((key) => (
|
||||
<li key={key} className={styles.chip}>
|
||||
{t(`chips.${key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.pipeline} role="img" aria-label={t('headline')}>
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.input')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.frames')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.search')}</span>
|
||||
<span className={styles.nodeSub}>{t('diagram.search_sub')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.graph')}</span>
|
||||
<span className={styles.nodeSub}>{t('diagram.graph_sub')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={`${styles.node} ${styles.nodeAccent}`}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.output')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<span className={styles.connector} aria-hidden="true">
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="none">
|
||||
<path
|
||||
d="M6 0 V11 M2 8 L6 12 L10 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
145
apps/www/app/_components/Navbar.module.css
Normal file
145
apps/www/app/_components/Navbar.module.css
Normal file
@@ -0,0 +1,145 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
transition: background 0.25s ease, border-color 0.25s ease,
|
||||
backdrop-filter 0.25s ease;
|
||||
border-bottom: 1px solid transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.headerScrolled {
|
||||
background: rgba(14, 12, 7, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom-color: var(--line-soft);
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--gutter);
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 500;
|
||||
color: var(--hive-300);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--hive-50);
|
||||
background: rgba(236, 227, 208, 0.05);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.signIn {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 500;
|
||||
color: var(--hive-200);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
.signIn:hover {
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: none;
|
||||
background: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--hive-100);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobilePanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signIn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.mobilePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px var(--gutter) 20px;
|
||||
background: rgba(14, 12, 7, 0.96);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.mobileLink {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--hive-200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobileLink:hover {
|
||||
color: var(--hive-50);
|
||||
background: rgba(236, 227, 208, 0.05);
|
||||
}
|
||||
|
||||
.mobileActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px 0;
|
||||
}
|
||||
}
|
||||
153
apps/www/app/_components/Navbar.tsx
Normal file
153
apps/www/app/_components/Navbar.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SignInButton, UserButton, Show } from '@clerk/nextjs';
|
||||
import BrandMark from './BrandMark';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import styles from './Navbar.module.css';
|
||||
|
||||
/* Absolute-path anchors so the navbar also works from /privacy, /terms,
|
||||
and the other legal pages that render this chrome. */
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/#how-it-works', key: 'how_it_works' },
|
||||
{ href: '/#memory', key: 'memory' },
|
||||
{ href: '/#proof', key: 'benchmark' },
|
||||
{ href: '/#open-source', key: 'open_source' },
|
||||
{ href: '/#pricing', key: 'pricing' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fixed top navigation. Transparent over the hero, gains a blurred backdrop
|
||||
* + hairline border after a small scroll. Collapses to a menu button below
|
||||
* 860px; the mobile panel reuses the same anchor list.
|
||||
*
|
||||
* Stays a Client Component for scroll-aware backdrop + menu state. All
|
||||
* strings under `landing.navbar.*`.
|
||||
*/
|
||||
export default function Navbar() {
|
||||
const t = useTranslations('landing.navbar');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const headerClass = [
|
||||
styles.header,
|
||||
scrolled || open ? styles.headerScrolled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<header className={headerClass}>
|
||||
<div className={styles.inner}>
|
||||
<a href="/" className={styles.brand} aria-label={t('aria.home')}>
|
||||
<BrandMark withWordmark />
|
||||
</a>
|
||||
|
||||
<nav className={styles.nav} aria-label={t('aria.primary')}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a key={item.key} href={item.href} className={styles.navLink}>
|
||||
{t(`links.${item.key}`)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Show when="signed-out">
|
||||
<SignInButton mode="modal">
|
||||
<button type="button" className={styles.signIn}>
|
||||
{t('ctas.sign_in')}
|
||||
</button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<UserButton />
|
||||
</Show>
|
||||
<DownloadCTA section="navbar" size="small">
|
||||
{t('ctas.download')}
|
||||
</DownloadCTA>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={t('aria.toggle_menu')}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<MenuIcon open={open} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
className={styles.mobilePanel}
|
||||
aria-label={t('aria.primary')}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className={styles.mobileLink}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t(`links.${item.key}`)}
|
||||
</a>
|
||||
))}
|
||||
<div className={styles.mobileActions}>
|
||||
<Show when="signed-out">
|
||||
<SignInButton mode="modal">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signIn}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
{t('ctas.sign_in')}
|
||||
</button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<UserButton />
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuIcon({ open }: { readonly open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
d="M4 4 L14 14 M14 4 L4 14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
d="M2 5 H16 M2 9 H16 M2 13 H16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
87
apps/www/app/_components/OpenSource.module.css
Normal file
87
apps/www/app/_components/OpenSource.module.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 72px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.7;
|
||||
color: var(--hive-300);
|
||||
margin-top: 20px;
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--hive-950);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminalBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(31, 26, 18, 0.6);
|
||||
}
|
||||
|
||||
.terminalDots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.terminalDot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--hive-600);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.terminalTitle {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminalBody {
|
||||
margin: 0;
|
||||
padding: 22px 22px 26px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.9;
|
||||
color: var(--hive-200);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--honey-500);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.comment {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.output {
|
||||
color: var(--healthy);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 48px;
|
||||
}
|
||||
}
|
||||
88
apps/www/app/_components/OpenSource.tsx
Normal file
88
apps/www/app/_components/OpenSource.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './OpenSource.module.css';
|
||||
|
||||
const HIVE_MIND_URL = 'https://github.com/marolinik/hive-mind';
|
||||
const NPM_URL = 'https://www.npmjs.com/package/@hive-mind/core';
|
||||
|
||||
/**
|
||||
* Open-source section: hive-mind (the memory substrate) with a terminal
|
||||
* showing the offline benchmark reproduction path. Command + output are
|
||||
* verbatim from the OSS repo (benchmarks/locomo/artifacts/w4-n1540/
|
||||
* recount.mjs — verified against the local clone 2026-07-03); if that
|
||||
* script moves, update this terminal. Strings under `landing.open_source.*`.
|
||||
*/
|
||||
export default async function OpenSource() {
|
||||
const t = await getTranslations('landing.open_source');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="open-source"
|
||||
className="section"
|
||||
aria-labelledby="oss-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<div className={styles.grid}>
|
||||
<div>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="oss-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className={styles.body}>{t('body')}</p>
|
||||
<div className={styles.ctaRow}>
|
||||
<a
|
||||
href={HIVE_MIND_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('cta_github')}
|
||||
</a>
|
||||
<a
|
||||
href={NPM_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
{t('cta_npm')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.terminal} aria-label={t('terminal_aria')}>
|
||||
<div className={styles.terminalBar}>
|
||||
<span className={styles.terminalDots} aria-hidden="true">
|
||||
<span className={styles.terminalDot} />
|
||||
<span className={styles.terminalDot} />
|
||||
<span className={styles.terminalDot} />
|
||||
</span>
|
||||
<span className={styles.terminalTitle}>
|
||||
{t('terminal_title')}
|
||||
</span>
|
||||
</div>
|
||||
<pre className={styles.terminalBody}>
|
||||
<code>
|
||||
<span className={styles.prompt}>$ </span>
|
||||
git clone https://github.com/marolinik/hive-mind{'\n'}
|
||||
<span className={styles.prompt}>$ </span>
|
||||
cd hive-mind/benchmarks/locomo{'\n'}
|
||||
<span className={styles.comment}>
|
||||
# recount the committed judgments — no API keys, no network
|
||||
</span>
|
||||
{'\n'}
|
||||
<span className={styles.prompt}>$ </span>
|
||||
node artifacts/w4-n1540/recount.mjs{'\n'}
|
||||
<span className={styles.output}>
|
||||
overall 1332/1540 = 86.49%{'\n'}
|
||||
RECOUNT OK — committed judgments reproduce 86.49%.
|
||||
</span>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
190
apps/www/app/_components/Pricing.module.css
Normal file
190
apps/www/app/_components/Pricing.module.css
Normal file
@@ -0,0 +1,190 @@
|
||||
.header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 999px;
|
||||
width: fit-content;
|
||||
margin: 0 auto 56px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--sans);
|
||||
background: transparent;
|
||||
color: var(--hive-300);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.toggleActive {
|
||||
background: var(--surface-2);
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.savePill {
|
||||
color: var(--honey-400);
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.checkoutNotice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
max-width: 680px;
|
||||
margin: -28px auto 36px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--honey-line);
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--honey-500) 10%, transparent);
|
||||
color: var(--hive-100);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noticeLink {
|
||||
color: var(--honey-300);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.noticeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto 40px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tierCard {
|
||||
position: relative;
|
||||
border-radius: var(--r-lg);
|
||||
padding: 30px 28px 28px;
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tierCardHighlighted {
|
||||
background: var(--hive-850);
|
||||
border-color: var(--honey-line);
|
||||
box-shadow: var(--shadow-honey);
|
||||
}
|
||||
|
||||
/* Enterprise/KVARK slot: present but visually subordinate to Solo/Team. */
|
||||
.tierCardQuiet {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.enterpriseBody {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin: 0 0 26px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--honey-500);
|
||||
color: var(--hive-950);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tierName {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.tierTagline {
|
||||
font-size: 13px;
|
||||
color: var(--hive-300);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.priceNote {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 22px;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.bullets {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 26px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.bullet {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--hive-200);
|
||||
margin-bottom: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bulletIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
color: var(--honey-500);
|
||||
}
|
||||
|
||||
.tierCta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 460px;
|
||||
}
|
||||
}
|
||||
253
apps/www/app/_components/Pricing.tsx
Normal file
253
apps/www/app/_components/Pricing.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import { emit, events } from '../_lib/event-taxonomy';
|
||||
import styles from './Pricing.module.css';
|
||||
|
||||
type BillingPeriod = 'monthly' | 'annual';
|
||||
type TierId = 'SOLO' | 'TEAMS';
|
||||
|
||||
interface TierDef {
|
||||
readonly id: TierId;
|
||||
readonly nsKey: 'solo' | 'teams';
|
||||
readonly highlighted: boolean;
|
||||
readonly bulletKeys: readonly string[];
|
||||
readonly ctaType: 'download' | 'stripe';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier content mirrors `packages/shared/src/tiers.ts` (the canonical tier
|
||||
* system): SOLO is free forever with full memory + Harvest, unlimited
|
||||
* workspaces, marketplace, all connectors, and BYO cloud models; TEAMS adds
|
||||
* shared workspaces, WaggleDance, and governance. No bullets beyond what
|
||||
* tiers.ts encodes. The 15-day Team trial lives in the section subhead —
|
||||
* it applies to every install, so listing it as a Solo feature misreads.
|
||||
*/
|
||||
const TIER_DEFS: readonly TierDef[] = [
|
||||
{
|
||||
id: 'SOLO',
|
||||
nsKey: 'solo',
|
||||
highlighted: false,
|
||||
bulletKeys: [
|
||||
'bullet_memory',
|
||||
'bullet_workspaces',
|
||||
'bullet_marketplace',
|
||||
'bullet_models',
|
||||
'bullet_skills',
|
||||
],
|
||||
ctaType: 'download',
|
||||
},
|
||||
{
|
||||
id: 'TEAMS',
|
||||
nsKey: 'teams',
|
||||
highlighted: true,
|
||||
bulletKeys: [
|
||||
'bullet_everything_solo',
|
||||
'bullet_shared',
|
||||
'bullet_dance',
|
||||
'bullet_governance',
|
||||
],
|
||||
ctaType: 'stripe',
|
||||
},
|
||||
];
|
||||
|
||||
const STRIPE_ENDPOINT =
|
||||
(process.env.NEXT_PUBLIC_API_URL ?? '').replace(/\/$/, '') +
|
||||
'/api/stripe/checkout';
|
||||
|
||||
const KVARK_URL = 'https://www.kvark.ai';
|
||||
|
||||
export default function Pricing() {
|
||||
const t = useTranslations('landing.pricing');
|
||||
const [billing, setBilling] = useState<BillingPeriod>('monthly');
|
||||
const [checkoutCancelled, setCheckoutCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setCheckoutCancelled(params.get('checkout') === 'cancelled');
|
||||
}, []);
|
||||
|
||||
const handleBillingChange = useCallback((mode: BillingPeriod) => {
|
||||
setBilling(mode);
|
||||
emit({ name: events.pricingBillingToggle, properties: { mode } });
|
||||
}, []);
|
||||
|
||||
const handleStripeCtaClick = useCallback(
|
||||
(tier: TierId) => {
|
||||
emit({
|
||||
name: events.ctaClick,
|
||||
properties: { section: 'pricing', tier, billing },
|
||||
});
|
||||
},
|
||||
[billing],
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="pricing" className="section" aria-labelledby="pricing-heading">
|
||||
<div className="container-wide">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="pricing-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className="section-lead">{t('subhead')}</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('toggle.aria_group')}
|
||||
className={styles.toggleRow}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBillingChange('monthly')}
|
||||
className={[
|
||||
styles.toggle,
|
||||
billing === 'monthly' ? styles.toggleActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={billing === 'monthly'}
|
||||
>
|
||||
{t('toggle.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBillingChange('annual')}
|
||||
className={[
|
||||
styles.toggle,
|
||||
billing === 'annual' ? styles.toggleActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={billing === 'annual'}
|
||||
>
|
||||
{t('toggle.annual')}
|
||||
<span className={styles.savePill}>{t('toggle.save_pill')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{checkoutCancelled ? (
|
||||
<div role="status" className={styles.checkoutNotice}>
|
||||
<span>{t('notices.cancelled')}</span>
|
||||
<a
|
||||
href={`${STRIPE_ENDPOINT}?tier=teams&billing=${billing}`}
|
||||
onClick={() => handleStripeCtaClick('TEAMS')}
|
||||
className={styles.noticeLink}
|
||||
>
|
||||
{t('notices.retry')}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.grid}>
|
||||
{TIER_DEFS.map((tier) => {
|
||||
const priceKey =
|
||||
billing === 'monthly' ? 'price_monthly' : 'price_annual';
|
||||
const cardClass = [
|
||||
styles.tierCard,
|
||||
tier.highlighted ? styles.tierCardHighlighted : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div key={tier.id} className={cardClass}>
|
||||
{tier.highlighted ? (
|
||||
<span className={styles.badge}>{t('popular_badge')}</span>
|
||||
) : null}
|
||||
<h3 className={styles.tierName}>
|
||||
{t(`tiers.${tier.nsKey}.name`)}
|
||||
</h3>
|
||||
<p className={styles.tierTagline}>
|
||||
{t(`tiers.${tier.nsKey}.tagline`)}
|
||||
</p>
|
||||
|
||||
<p className={styles.price}>
|
||||
{t(`tiers.${tier.nsKey}.${priceKey}`)}
|
||||
</p>
|
||||
<p className={styles.priceNote}>
|
||||
{t(`tiers.${tier.nsKey}.note`)}
|
||||
</p>
|
||||
|
||||
<ul className={styles.bullets}>
|
||||
{tier.bulletKeys.map((bk) => (
|
||||
<li key={bk} className={styles.bullet}>
|
||||
<CheckIcon />
|
||||
<span>{t(`tiers.${tier.nsKey}.${bk}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{tier.ctaType === 'download' ? (
|
||||
<DownloadCTA
|
||||
section="solo-tier"
|
||||
variant={tier.highlighted ? 'primary' : 'ghost'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={`${STRIPE_ENDPOINT}?tier=${tier.id.toLowerCase()}&billing=${billing}`}
|
||||
onClick={() => handleStripeCtaClick(tier.id)}
|
||||
className={[
|
||||
'btn',
|
||||
tier.highlighted ? 'btn-primary' : 'btn-ghost',
|
||||
styles.tierCta,
|
||||
].join(' ')}
|
||||
>
|
||||
{t(`tiers.${tier.nsKey}.cta`)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Enterprise = KVARK sovereign deployment. A quieter third card
|
||||
(description, no checklist) so the grid fills its row without
|
||||
inventing a tier — pricing stays consultative. */}
|
||||
<div className={[styles.tierCard, styles.tierCardQuiet].join(' ')}>
|
||||
<h3 className={styles.tierName}>{t('enterprise.name')}</h3>
|
||||
<p className={styles.tierTagline}>{t('enterprise.tagline')}</p>
|
||||
|
||||
<p className={styles.price}>{t('enterprise.price')}</p>
|
||||
<p className={styles.priceNote}>{t('enterprise.note')}</p>
|
||||
|
||||
<p className={styles.enterpriseBody}>{t('enterprise.text')}</p>
|
||||
|
||||
<a
|
||||
href={KVARK_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={['btn', 'btn-ghost', styles.tierCta].join(' ')}
|
||||
>
|
||||
{t('enterprise.cta')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={styles.bulletIcon}
|
||||
>
|
||||
<path
|
||||
d="M3 8.5 L6.5 12 L13 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
58
apps/www/app/_components/ProblemTurn.module.css
Normal file
58
apps/www/app/_components/ProblemTurn.module.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.beat {
|
||||
padding: 26px 26px 28px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.beatIndex {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.beatTitle {
|
||||
font-size: var(--text-h3);
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.beatBody {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
.turn {
|
||||
border-left: 2px solid var(--honey-500);
|
||||
padding-left: 24px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.turnText {
|
||||
font-size: clamp(1.125rem, 2vw, 1.375rem);
|
||||
line-height: 1.55;
|
||||
font-weight: 500;
|
||||
color: var(--hive-100);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
51
apps/www/app/_components/ProblemTurn.tsx
Normal file
51
apps/www/app/_components/ProblemTurn.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './ProblemTurn.module.css';
|
||||
|
||||
const BEATS = ['reintroduce', 'tabs', 'compound'] as const;
|
||||
|
||||
/**
|
||||
* The problem statement ("every AI session starts from zero") in three
|
||||
* beats, then the turn — Waggle's opposite bet — as a pull-quote with a
|
||||
* honey rule. Strings under `landing.problem.*`.
|
||||
*/
|
||||
export default async function ProblemTurn() {
|
||||
const t = await getTranslations('landing.problem');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="problem"
|
||||
className="section"
|
||||
aria-labelledby="problem-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="problem-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{BEATS.map((beat, i) => (
|
||||
<Reveal key={beat} delay={(i + 1) as 1 | 2 | 3}>
|
||||
<div className={styles.beat}>
|
||||
<span className={styles.beatIndex} aria-hidden="true">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h3 className={styles.beatTitle}>{t(`beats.${beat}.title`)}</h3>
|
||||
<p className={styles.beatBody}>{t(`beats.${beat}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.turn}>
|
||||
<p className={styles.turnText}>{t('turn')}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
111
apps/www/app/_components/ProofBand.module.css
Normal file
111
apps/www/app/_components/ProofBand.module.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.band {
|
||||
background: var(--hive-900);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.header {
|
||||
max-width: 760px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
max-width: 820px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 72px;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.system {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.systemName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-100);
|
||||
}
|
||||
|
||||
.systemDetail {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: block;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: var(--hive-800);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
background: var(--hive-600);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.barHighlight {
|
||||
background: linear-gradient(90deg, var(--honey-600) 0%, var(--honey-500) 100%);
|
||||
box-shadow: var(--shadow-honey);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-100);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.scoreHighlight {
|
||||
color: var(--honey-400);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-muted);
|
||||
max-width: 720px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 56px;
|
||||
grid-template-rows: auto auto;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.system {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
84
apps/www/app/_components/ProofBand.tsx
Normal file
84
apps/www/app/_components/ProofBand.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import { LOCOMO_BARS } from '../_data/proof-points';
|
||||
import styles from './ProofBand.module.css';
|
||||
|
||||
const HIVE_MIND_URL = 'https://github.com/marolinik/hive-mind';
|
||||
|
||||
/**
|
||||
* Benchmark proof band. The chart is honest by construction: bar widths are
|
||||
* raw scores on a 0–100 axis (no truncated baseline), the protocol footnote
|
||||
* names the judge, N, and significance, and both CTAs lead to verification
|
||||
* paths (methodology page, reproducible repo). Data from `_data/proof-points`.
|
||||
*/
|
||||
export default async function ProofBand() {
|
||||
const t = await getTranslations('landing.proof');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="proof"
|
||||
className={`section ${styles.band}`}
|
||||
aria-labelledby="proof-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="proof-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className="section-lead">{t('body')}</p>
|
||||
</header>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.chart} role="img" aria-label={t('chart_aria')}>
|
||||
{LOCOMO_BARS.map((bar) => (
|
||||
<div key={bar.id} className={styles.row}>
|
||||
<span className={styles.system}>
|
||||
<span className={styles.systemName}>{bar.system}</span>
|
||||
<span className={styles.systemDetail}>{bar.detail}</span>
|
||||
</span>
|
||||
<span className={styles.track}>
|
||||
<span
|
||||
className={[
|
||||
styles.bar,
|
||||
bar.highlight ? styles.barHighlight : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ width: `${bar.score}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
styles.score,
|
||||
bar.highlight ? styles.scoreHighlight : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{bar.score.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<p className={styles.footnote}>{t('footnote')}</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
<a href="/docs/methodology" className="btn btn-ghost">
|
||||
{t('cta_methodology')}
|
||||
</a>
|
||||
<a
|
||||
href={HIVE_MIND_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
{t('cta_reproduce')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
86
apps/www/app/_components/Reveal.tsx
Normal file
86
apps/www/app/_components/Reveal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface RevealProps {
|
||||
readonly children: ReactNode;
|
||||
/** Stagger slot 1–5 → transition-delay 60ms steps (see globals.css). */
|
||||
readonly delay?: 1 | 2 | 3 | 4 | 5;
|
||||
readonly as?: 'div' | 'section' | 'li' | 'span';
|
||||
readonly className?: string;
|
||||
readonly style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll-reveal wrapper: fades + lifts children in when they enter the
|
||||
* viewport. Purely presentational — content is in the DOM at SSR (SEO-safe)
|
||||
* and `prefers-reduced-motion` disables the effect entirely via globals.css.
|
||||
*/
|
||||
export default function Reveal({
|
||||
children,
|
||||
delay,
|
||||
as: Tag = 'div',
|
||||
className,
|
||||
style,
|
||||
}: RevealProps) {
|
||||
const nodeRef = useRef<HTMLElement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const setNode = useCallback((node: HTMLElement | null) => {
|
||||
nodeRef.current = node;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = nodeRef.current;
|
||||
if (!node) return;
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -10% 0px', threshold: 0.1 },
|
||||
);
|
||||
observer.observe(node);
|
||||
// Safety net: if a section is never scrolled into view — a crawler, a
|
||||
// social-preview renderer, or a full-page screenshot that paints without
|
||||
// scrolling — the observer never fires and the content would stay stuck at
|
||||
// opacity:0. Reveal it anyway shortly after mount so no section is ever a
|
||||
// headline floating in an empty void. Real users scrolling normally still
|
||||
// trip the observer first and get the entrance animation per section.
|
||||
const fallback = window.setTimeout(() => setVisible(true), 900);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.clearTimeout(fallback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const classes = [
|
||||
'reveal',
|
||||
visible ? 'is-visible' : '',
|
||||
delay ? `reveal-d${delay}` : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<Tag ref={setNode} className={classes} style={style}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
47
apps/www/app/_components/SovereigntyBand.module.css
Normal file
47
apps/www/app/_components/SovereigntyBand.module.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.band {
|
||||
background: var(--hive-900);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 32px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border-top: 2px solid var(--honey-600);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
46
apps/www/app/_components/SovereigntyBand.tsx
Normal file
46
apps/www/app/_components/SovereigntyBand.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './SovereigntyBand.module.css';
|
||||
|
||||
const ITEMS = ['device', 'egress', 'erasure', 'injection'] as const;
|
||||
|
||||
/**
|
||||
* Data-sovereignty band: four specific, verifiable statements about where
|
||||
* data lives and what leaves the machine. Trust through specificity — no
|
||||
* compliance badges we don't hold. Strings under `landing.sovereignty.*`.
|
||||
*/
|
||||
export default async function SovereigntyBand() {
|
||||
const t = await getTranslations('landing.sovereignty');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="trust"
|
||||
className={`section ${styles.band}`}
|
||||
aria-labelledby="trust-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="trust-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{ITEMS.map((item, i) => (
|
||||
<Reveal key={item} delay={((i % 4) + 1) as 1 | 2 | 3 | 4}>
|
||||
<div className={styles.item}>
|
||||
<h3 className={styles.title}>{t(`items.${item}.title`)}</h3>
|
||||
<p className={styles.body}>{t(`items.${item}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a href="/eu-ai-act" className="btn btn-ghost btn-small">
|
||||
{t('cta')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user