|
|
|
@@ -46,6 +46,10 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
|
|
|
|
|
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
|
|
|
|
|
|
|
|
|
|
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
|
|
|
|
|
// Strokes use vector-effect=non-scaling-stroke so a line is ~1px whether the
|
|
|
|
|
// tile is stretched across a 2000px plane (preserveAspectRatio=none would
|
|
|
|
|
// otherwise fatten the verticals ~4x) or squeezed into the 76px settings
|
|
|
|
|
// swatch (where scaled strokes disappeared entirely).
|
|
|
|
|
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
|
|
|
|
|
// lines fanning toward a top-center vanishing point. The plane is then placed
|
|
|
|
|
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
|
|
|
|
@@ -58,7 +62,7 @@ function gridDataUri(): string {
|
|
|
|
|
rows.forEach((y) => {
|
|
|
|
|
lines.push(
|
|
|
|
|
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
|
|
|
|
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
|
|
|
|
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
// Vertical lines fanning out from the top-center vanishing point.
|
|
|
|
@@ -67,7 +71,7 @@ function gridDataUri(): string {
|
|
|
|
|
const botX = 300 + i * 95; // wide at the foreground
|
|
|
|
|
lines.push(
|
|
|
|
|
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
|
|
|
|
|
`stroke-width='1.4' stroke-opacity='0.8'/>`,
|
|
|
|
|
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const svg =
|
|
|
|
@@ -105,6 +109,13 @@ const RESTING_SPARKLES: ReadonlyArray<{
|
|
|
|
|
|
|
|
|
|
const GRID_URI = gridDataUri();
|
|
|
|
|
|
|
|
|
|
// HUD text size: 11px on any real viewport, 0px (invisible) inside anything
|
|
|
|
|
// narrower than ~330px. `cqw` resolves against the nearest size container —
|
|
|
|
|
// the settings swatch (`SeasonalPreview` sets container-type) — and falls back
|
|
|
|
|
// to the viewport width when there is no container, i.e. the full-screen
|
|
|
|
|
// overlay. clamp(0, 100cqw - 320px, 11px) → 76px swatch: 0px; 1440px app: 11px.
|
|
|
|
|
const HUD_FONT_SIZE = 'clamp(0px, calc(100cqw - 320px), 11px)';
|
|
|
|
|
|
|
|
|
|
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
// Deterministic sparkle field, computed ONCE. No per-frame state.
|
|
|
|
|
const sparkles = useMemo<Sparkle[]>(() => {
|
|
|
|
@@ -134,9 +145,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
inset: 0,
|
|
|
|
|
backgroundImage: [
|
|
|
|
|
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
|
|
|
|
|
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
|
|
|
|
|
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
|
|
|
|
|
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.12) 0%, transparent 55%)',
|
|
|
|
|
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.16) 0%, transparent 60%)',
|
|
|
|
|
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)',
|
|
|
|
|
].join(','),
|
|
|
|
|
contain: 'layout paint style',
|
|
|
|
|
}}
|
|
|
|
@@ -147,41 +158,59 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
a vanishing point at the top (the horizon). It lives in the lower
|
|
|
|
|
half of the screen — the "floor". The inner plane scrolls upward by
|
|
|
|
|
one tile via transform translateY, which reads as the grid flowing
|
|
|
|
|
toward the viewer. Pure transform; never background-position. */}
|
|
|
|
|
toward the viewer. Pure transform; never background-position.
|
|
|
|
|
|
|
|
|
|
Two masks are nested (multiple mask-images on one element union by
|
|
|
|
|
default, and `mask-composite: intersect` isn't universal yet): the
|
|
|
|
|
outer wrapper fades the lattice through the central column where the
|
|
|
|
|
message timeline lives, so it frames the chat instead of striping
|
|
|
|
|
the text; the inner box fades it in from the horizon. */}
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
left: '-25%',
|
|
|
|
|
right: '-25%',
|
|
|
|
|
bottom: 0,
|
|
|
|
|
height: '62%',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
perspective: '280px',
|
|
|
|
|
perspectiveOrigin: '50% 0%',
|
|
|
|
|
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
|
|
|
|
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
|
|
|
|
opacity: reduced ? 0.5 : 0.62,
|
|
|
|
|
inset: 0,
|
|
|
|
|
maskImage:
|
|
|
|
|
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
|
|
|
|
|
WebkitMaskImage:
|
|
|
|
|
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
|
|
|
|
|
opacity: reduced ? 0.4 : 0.46,
|
|
|
|
|
contain: 'layout paint style',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
top: 0,
|
|
|
|
|
height: '200%',
|
|
|
|
|
transformOrigin: 'top center',
|
|
|
|
|
transform: 'rotateX(74deg)',
|
|
|
|
|
backgroundImage: GRID_URI,
|
|
|
|
|
backgroundRepeat: 'repeat-y',
|
|
|
|
|
backgroundSize: '100% 50%',
|
|
|
|
|
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
|
|
|
|
willChange: reduced ? undefined : 'transform',
|
|
|
|
|
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
|
|
|
|
left: '-25%',
|
|
|
|
|
right: '-25%',
|
|
|
|
|
bottom: 0,
|
|
|
|
|
height: '62%',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
perspective: '280px',
|
|
|
|
|
perspectiveOrigin: '50% 0%',
|
|
|
|
|
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
|
|
|
|
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
|
|
|
|
contain: 'layout paint style',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
top: 0,
|
|
|
|
|
height: '200%',
|
|
|
|
|
transformOrigin: 'top center',
|
|
|
|
|
transform: 'rotateX(74deg)',
|
|
|
|
|
backgroundImage: GRID_URI,
|
|
|
|
|
backgroundRepeat: 'repeat-y',
|
|
|
|
|
backgroundSize: '100% 50%',
|
|
|
|
|
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))',
|
|
|
|
|
willChange: reduced ? undefined : 'transform',
|
|
|
|
|
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
|
|
|
|
@@ -197,7 +226,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
height: '34%',
|
|
|
|
|
transform: 'translate(-50%, -50%)',
|
|
|
|
|
backgroundImage:
|
|
|
|
|
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
|
|
|
|
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.16) 0%, oklch(0.65 0.18 330 / 0.08) 40%, transparent 72%)',
|
|
|
|
|
contain: 'layout paint style',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
@@ -209,8 +238,10 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
right: '12%',
|
|
|
|
|
top: '38%',
|
|
|
|
|
height: '1.5px',
|
|
|
|
|
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
|
|
|
|
|
opacity: 0.55,
|
|
|
|
|
// Bright at the flanks, dropped out through the centre column so the
|
|
|
|
|
// rule frames the timeline rather than underlining a message.
|
|
|
|
|
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 14%, oklch(0.92 0.10 320 / 0.95) 22%, transparent 34%, transparent 66%, oklch(0.92 0.10 320 / 0.95) 78%, ${NEON_CYAN} 86%, transparent 100%)`,
|
|
|
|
|
opacity: 0.4,
|
|
|
|
|
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
@@ -273,7 +304,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
inset: 0,
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
mixBlendMode: 'multiply',
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
opacity: 0.32,
|
|
|
|
|
contain: 'layout paint style',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
@@ -285,7 +316,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
top: '-8px',
|
|
|
|
|
bottom: '-8px',
|
|
|
|
|
backgroundImage:
|
|
|
|
|
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
|
|
|
|
|
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.45) 0px, oklch(0.10 0.04 300 / 0.45) 1px, transparent 1px, transparent 4px)',
|
|
|
|
|
willChange: reduced ? undefined : 'transform',
|
|
|
|
|
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
|
|
|
|
}}
|
|
|
|
@@ -309,51 +340,54 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
|
|
|
|
Static scene shows it steady (no blink). */}
|
|
|
|
|
{/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN"
|
|
|
|
|
blip, stacked bottom-right. That corner is the one spot that is
|
|
|
|
|
clear in every layout (below the members list, or the empty right
|
|
|
|
|
end of the read-receipt strip) — top-left collided with the space
|
|
|
|
|
bar and bottom-centre sat on the composer. Static scene shows both
|
|
|
|
|
steady (no blink). The font-size clamp collapses the text to nothing
|
|
|
|
|
when the host is narrower than ~330px, so the 76px settings swatch
|
|
|
|
|
never shows clipped glyphs. */}
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: '5%',
|
|
|
|
|
left: '50%',
|
|
|
|
|
transform: 'translateX(-50%)',
|
|
|
|
|
right: '14px',
|
|
|
|
|
bottom: '8px',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
alignItems: 'flex-end',
|
|
|
|
|
gap: '3px',
|
|
|
|
|
fontFamily: '"Courier New", monospace',
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
fontSize: HUD_FONT_SIZE,
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
letterSpacing: '0.32em',
|
|
|
|
|
color: NEON_CYAN,
|
|
|
|
|
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
userSelect: 'none',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
opacity: reduced ? 0.6 : undefined,
|
|
|
|
|
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
INSERT COIN
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
|
|
|
|
|
very low opacity so it reads as ambient chrome, not UI. */}
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '2.5%',
|
|
|
|
|
left: '2%',
|
|
|
|
|
fontFamily: '"Courier New", monospace',
|
|
|
|
|
fontSize: '10px',
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
letterSpacing: '0.18em',
|
|
|
|
|
color: NEON_MAGENTA,
|
|
|
|
|
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
|
|
|
|
userSelect: 'none',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
opacity: reduced ? 0.5 : undefined,
|
|
|
|
|
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
letterSpacing: '0.18em',
|
|
|
|
|
color: NEON_MAGENTA,
|
|
|
|
|
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
|
|
|
|
opacity: reduced ? 0.5 : undefined,
|
|
|
|
|
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
letterSpacing: '0.32em',
|
|
|
|
|
color: NEON_CYAN,
|
|
|
|
|
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
|
|
|
|
opacity: reduced ? 0.6 : undefined,
|
|
|
|
|
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
INSERT COIN
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
|
|
|
|
|