feat(brand): AnimatedLogo component + Space Grotesk display font
- src/app/layout.tsx: load Space_Grotesk via next/font/google as --font-display (weights 500/600/700, self-hosted, display:swap). - src/components/AnimatedLogo.tsx: standalone animated brand mark. Single-A monogram in #2563EB->#1E3A8A tile, Space Grotesk 700 wordmark, Inter tagline with accent rule. Stroke-draw + cyan signal pulse + sheen sweep + text fade. Pure SVG/CSS, no animation libs. Respects prefers-reduced-motion. Variants: onLight | onDark. - src/app/preview/animated-logo/page.tsx: preview route at /preview/animated-logo (noindex). Existing AvBeatLogo untouched — Header/Footer wiring unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Lora, Inter } from 'next/font/google';
|
import { Lora, Inter, Space_Grotesk } from 'next/font/google';
|
||||||
import '../styles/tailwind.css';
|
import '../styles/tailwind.css';
|
||||||
|
|
||||||
// Self-hosted at build time by next/font — no runtime CDN fetch, no FOIT,
|
// Self-hosted at build time by next/font — no runtime CDN fetch, no FOIT,
|
||||||
@@ -20,6 +20,15 @@ const inter = Inter({
|
|||||||
variable: '--font-sans',
|
variable: '--font-sans',
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Brand display face for the AnimatedLogo wordmark + future display
|
||||||
|
// typography. Self-hosted by next/font, same pipeline as Lora/Inter.
|
||||||
|
const spaceGrotesk = Space_Grotesk({
|
||||||
|
subsets: ['latin'],
|
||||||
|
weight: ['500', '600', '700'],
|
||||||
|
variable: '--font-display',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
// SystemStatusBar removed — the "articles indexed / events tracked / [v2.0.0]"
|
// SystemStatusBar removed — the "articles indexed / events tracked / [v2.0.0]"
|
||||||
// strip was eating vertical space without serving readers.
|
// strip was eating vertical space without serving readers.
|
||||||
import AskBBAI from '@/components/AskBBAI';
|
import AskBBAI from '@/components/AskBBAI';
|
||||||
@@ -100,7 +109,7 @@ export default function RootLayout({
|
|||||||
|
|
||||||
}: Readonly<{children: React.ReactNode;}>) {
|
}: Readonly<{children: React.ReactNode;}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${lora.variable} ${inter.variable}`}>
|
<html lang="en" className={`${lora.variable} ${inter.variable} ${spaceGrotesk.variable}`}>
|
||||||
<head>
|
<head>
|
||||||
{/* hreflang entries removed pending real i18n routes (Phase D). */}
|
{/* hreflang entries removed pending real i18n routes (Phase D). */}
|
||||||
|
|
||||||
|
|||||||
61
src/app/preview/animated-logo/page.tsx
Normal file
61
src/app/preview/animated-logo/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import AnimatedLogo from '@/components/AnimatedLogo';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'AnimatedLogo preview — AV Beat',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone preview for /preview/animated-logo. Renders the new
|
||||||
|
* AnimatedLogo component in both light and dark variants at several
|
||||||
|
* sizes so you can eyeball the animation before any wiring decisions.
|
||||||
|
*/
|
||||||
|
export default function AnimatedLogoPreviewPage() {
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#F8FAFC', padding: '48px 32px', fontFamily: 'var(--font-sans)' }}>
|
||||||
|
<h1 style={{ fontSize: 32, fontWeight: 800, color: '#0F172A', marginBottom: 8 }}>AnimatedLogo preview</h1>
|
||||||
|
<p style={{ fontSize: 14, color: '#475569', marginBottom: 40 }}>
|
||||||
|
Standalone — not wired into Header or Footer. Reload the page to replay the mount animation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 40 }}>
|
||||||
|
<div style={{ background: '#FFFFFF', border: '1px solid #DCE6F2', borderRadius: 8, padding: 32 }}>
|
||||||
|
<h2 style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#475569', marginBottom: 24 }}>
|
||||||
|
onLight · default sizes
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||||
|
<AnimatedLogo size={48} variant="onLight" />
|
||||||
|
<AnimatedLogo size={64} variant="onLight" />
|
||||||
|
<AnimatedLogo size={96} variant="onLight" />
|
||||||
|
<AnimatedLogo size={128} variant="onLight" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: '#0F172A', border: '1px solid #1E293B', borderRadius: 8, padding: 32 }}>
|
||||||
|
<h2 style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#94A3B8', marginBottom: 24 }}>
|
||||||
|
onDark · default sizes
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
||||||
|
<AnimatedLogo size={48} variant="onDark" />
|
||||||
|
<AnimatedLogo size={64} variant="onDark" />
|
||||||
|
<AnimatedLogo size={96} variant="onDark" />
|
||||||
|
<AnimatedLogo size={128} variant="onDark" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: '#FFFFFF', border: '1px solid #DCE6F2', borderRadius: 8, padding: 32 }}>
|
||||||
|
<h2 style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.18em', textTransform: 'uppercase', color: '#475569', marginBottom: 24 }}>
|
||||||
|
no tagline · favicon-style
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
|
||||||
|
<AnimatedLogo size={48} variant="onLight" showTagline={false} />
|
||||||
|
<AnimatedLogo size={64} variant="onLight" showTagline={false} />
|
||||||
|
<AnimatedLogo size={96} variant="onLight" showTagline={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
src/components/AnimatedLogo.tsx
Normal file
277
src/components/AnimatedLogo.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useId } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnimatedLogo
|
||||||
|
* ============
|
||||||
|
* Standalone preview component — NOT yet wired into Header or Footer.
|
||||||
|
* The existing AvBeatLogo (static A+V monogram) keeps the production
|
||||||
|
* surfaces. This component is the experimental brand mark.
|
||||||
|
*
|
||||||
|
* • Single-A monogram inside a rounded-square tile with the brand
|
||||||
|
* gradient (#2563EB → #1E3A8A).
|
||||||
|
* • Wordmark in Space Grotesk 700, loaded via next/font/google as
|
||||||
|
* --font-display in app/layout.tsx.
|
||||||
|
* • Tagline in Inter (var(--font-sans)) with an accent-rule (a short
|
||||||
|
* blue dash) preceding the text.
|
||||||
|
*
|
||||||
|
* Animations (pure CSS, no library):
|
||||||
|
* • Stroke-draw on mount ~1.1s on the A path
|
||||||
|
* • Cyan signal pulse ~3.2s loop traversing the A
|
||||||
|
* • Subtle sheen sweep ~4.4s loop diagonal across the tile
|
||||||
|
* • Wordmark + tagline fade in ~0.9s, delayed until stroke finishes
|
||||||
|
*
|
||||||
|
* All animations halt under `prefers-reduced-motion: reduce`.
|
||||||
|
*
|
||||||
|
* Props
|
||||||
|
* size — emblem edge length in px (default 64). Wordmark scales.
|
||||||
|
* variant — 'onLight' (default) | 'onDark' — chooses text colors.
|
||||||
|
* showTagline (default true) — toggle the "Inside the Future of Pro
|
||||||
|
* AV" line.
|
||||||
|
*
|
||||||
|
* Accessibility: role="img" with aria-label="AV Beat" on the wrapper.
|
||||||
|
*/
|
||||||
|
export type AnimatedLogoVariant = 'onLight' | 'onDark';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
variant?: AnimatedLogoVariant;
|
||||||
|
showTagline?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnimatedLogo({
|
||||||
|
size = 64,
|
||||||
|
variant = 'onLight',
|
||||||
|
showTagline = true,
|
||||||
|
className = '',
|
||||||
|
}: Props) {
|
||||||
|
// useId for stable, SSR-safe unique IDs (the page can render two of
|
||||||
|
// these and the gradients won't collide).
|
||||||
|
const uid = useId().replace(/[:]/g, '');
|
||||||
|
const tileGradId = `al-tile-${uid}`;
|
||||||
|
const sheenGradId = `al-sheen-${uid}`;
|
||||||
|
const wordmarkColor = variant === 'onDark' ? '#FFFFFF' : '#0F172A';
|
||||||
|
const taglineColor = variant === 'onDark' ? '#CBD5E1' : '#475569';
|
||||||
|
const accentColor = '#1D4ED8';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label="AV Beat"
|
||||||
|
className={`avb-anim-logo avb-anim-logo--${variant} ${className}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Math.round(size * 0.22),
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── EMBLEM ───────────────────────────────────────────────── */}
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
style={{ display: 'block', overflow: 'visible' }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={tileGradId} x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stopColor="#2563EB" />
|
||||||
|
<stop offset="100%" stopColor="#1E3A8A" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={sheenGradId} x1="-1" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
|
||||||
|
<stop offset="0%" stopColor="#FFFFFF" stopOpacity="0" />
|
||||||
|
<stop offset="50%" stopColor="#FFFFFF" stopOpacity="0.32" />
|
||||||
|
<stop offset="100%" stopColor="#FFFFFF" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
{/* Clip the sheen to the rounded tile so the gradient sweep
|
||||||
|
never bleeds outside the corner radius. */}
|
||||||
|
<clipPath id={`al-clip-${uid}`}>
|
||||||
|
<rect x="0" y="0" width="64" height="64" rx="14" ry="14" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Tile */}
|
||||||
|
<rect x="0" y="0" width="64" height="64" rx="14" ry="14" fill={`url(#${tileGradId})`} />
|
||||||
|
|
||||||
|
{/* Sheen sweep — a tall skewed band that travels left→right. */}
|
||||||
|
<g clipPath={`url(#al-clip-${uid})`}>
|
||||||
|
<rect
|
||||||
|
className="al-sheen"
|
||||||
|
x="-32"
|
||||||
|
y="-8"
|
||||||
|
width="32"
|
||||||
|
height="80"
|
||||||
|
fill={`url(#${sheenGradId})`}
|
||||||
|
transform="skewX(-18)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Single-A monogram. Built as two strokes so the stroke-draw
|
||||||
|
animation cleanly traces the legs of the A and the crossbar.
|
||||||
|
pathLength=100 normalizes the dash math regardless of unit.
|
||||||
|
*/}
|
||||||
|
{/* Left leg + right leg in one continuous stroke for a clean
|
||||||
|
single dash-draw. The path goes (bottom-left → apex →
|
||||||
|
bottom-right). */}
|
||||||
|
<path
|
||||||
|
className="al-stroke"
|
||||||
|
d="M 14 50 L 32 14 L 50 50"
|
||||||
|
fill="none"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeWidth="5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
pathLength={100}
|
||||||
|
/>
|
||||||
|
{/* Crossbar — a second short stroke, dashed-drawn in sequence. */}
|
||||||
|
<path
|
||||||
|
className="al-stroke al-stroke--bar"
|
||||||
|
d="M 21 36 L 43 36"
|
||||||
|
fill="none"
|
||||||
|
stroke="#FFFFFF"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
pathLength={100}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Cyan signal pulse — a small dot that rides along the same
|
||||||
|
path as the A's outline. We re-use the left-leg-to-apex-to-
|
||||||
|
right-leg path via <animateMotion> attached to a path
|
||||||
|
reference. Falls back gracefully when reduce-motion is on.
|
||||||
|
*/}
|
||||||
|
<circle r="2.6" fill="#7DD3FC" className="al-pulse" cx="14" cy="50">
|
||||||
|
<animateMotion
|
||||||
|
dur="3.2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
rotate="0"
|
||||||
|
keyTimes="0;1"
|
||||||
|
keyPoints="0;1"
|
||||||
|
>
|
||||||
|
<mpath href={`#al-path-${uid}`} />
|
||||||
|
</animateMotion>
|
||||||
|
</circle>
|
||||||
|
{/* Hidden path the pulse rides along. Same geometry as visible
|
||||||
|
A so the dot tracks the silhouette. */}
|
||||||
|
<path id={`al-path-${uid}`} d="M 14 50 L 32 14 L 50 50" fill="none" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* ── WORDMARK + TAGLINE ───────────────────────────────────── */}
|
||||||
|
<span
|
||||||
|
className="al-text"
|
||||||
|
style={{ display: 'inline-flex', flexDirection: 'column', gap: Math.round(size * 0.08), minWidth: 0 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-display), "Space Grotesk", Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: Math.round(size * 0.5),
|
||||||
|
letterSpacing: '-0.012em',
|
||||||
|
color: wordmarkColor,
|
||||||
|
lineHeight: 0.95,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AV BEAT
|
||||||
|
</span>
|
||||||
|
{showTagline && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Math.round(size * 0.12),
|
||||||
|
fontFamily: 'var(--font-sans), -apple-system, BlinkMacSystemFont, sans-serif',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: Math.round(size * 0.165),
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
color: taglineColor,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: Math.round(size * 0.28),
|
||||||
|
height: 2,
|
||||||
|
background: accentColor,
|
||||||
|
borderRadius: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Inside the Future of Pro AV
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Component-scoped CSS. Keeping it inline (style jsx-free) so the
|
||||||
|
component is portable to any Next.js page without globals leak.
|
||||||
|
prefers-reduced-motion blanket-disables every keyframe.
|
||||||
|
*/}
|
||||||
|
<style>{`
|
||||||
|
.avb-anim-logo .al-stroke {
|
||||||
|
stroke-dasharray: 100;
|
||||||
|
stroke-dashoffset: 100;
|
||||||
|
animation: al-draw 1.1s 0.05s cubic-bezier(0.65, 0, 0.35, 1) forwards;
|
||||||
|
}
|
||||||
|
.avb-anim-logo .al-stroke--bar {
|
||||||
|
animation-delay: 0.85s;
|
||||||
|
animation-duration: 0.55s;
|
||||||
|
}
|
||||||
|
.avb-anim-logo .al-sheen {
|
||||||
|
animation: al-sheen 4.4s 1.1s cubic-bezier(0.55, 0.05, 0.45, 0.95) infinite;
|
||||||
|
}
|
||||||
|
.avb-anim-logo .al-pulse {
|
||||||
|
opacity: 0;
|
||||||
|
animation: al-pulse-fade 0.6s 1.05s ease-out forwards,
|
||||||
|
al-pulse-glow 3.2s 1.65s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.avb-anim-logo .al-text {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
animation: al-fade-in 0.9s 1.0s cubic-bezier(0.2, 0, 0, 1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes al-draw {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
@keyframes al-sheen {
|
||||||
|
0% { transform: translateX(-30px) skewX(-18deg); }
|
||||||
|
55% { transform: translateX(110px) skewX(-18deg); }
|
||||||
|
100% { transform: translateX(110px) skewX(-18deg); }
|
||||||
|
}
|
||||||
|
@keyframes al-pulse-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes al-pulse-glow {
|
||||||
|
0%, 100% { filter: drop-shadow(0 0 1px rgba(125, 211, 252, 0.6)); }
|
||||||
|
50% { filter: drop-shadow(0 0 6px rgba(125, 211, 252, 0.95)); }
|
||||||
|
}
|
||||||
|
@keyframes al-fade-in {
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.avb-anim-logo .al-stroke {
|
||||||
|
stroke-dashoffset: 0 !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.avb-anim-logo .al-sheen,
|
||||||
|
.avb-anim-logo .al-pulse,
|
||||||
|
.avb-anim-logo .al-text {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.avb-anim-logo .al-text { opacity: 1; transform: none; }
|
||||||
|
.avb-anim-logo .al-pulse { opacity: 1; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user