mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(dashboard): cinematic beacon — living arc teaser, sheen title, scrim
The beacon is now a dark cinema card: a small canvas paints a breathing night-world with golden travel arcs on loop, colored glows drift over it, the title carries a gold sheen sweep and the copy sits on a bottom scrim. Core colors ride inline so no dashboard widget CSS can wash the card out again.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { Play, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { noFearCopy } from './noFearLines'
|
||||
@@ -10,10 +10,111 @@ const NoFearShow = lazy(() => import('./NoFearShow'))
|
||||
|
||||
const DISMISS_KEY = 'trek-fourzero-dismissed'
|
||||
|
||||
// Abstract night-world for the teaser: light points loosely shaped like the
|
||||
// inhabited continents (relative coords), between which golden arcs travel.
|
||||
const TEASER_POINTS: [number, number][] = [
|
||||
[0.46, 0.28], [0.5, 0.22], [0.53, 0.3], [0.48, 0.38], [0.55, 0.42], [0.44, 0.5],
|
||||
[0.58, 0.55], [0.52, 0.62], [0.62, 0.3], [0.68, 0.4], [0.74, 0.32], [0.8, 0.45],
|
||||
[0.84, 0.58], [0.88, 0.36], [0.2, 0.3], [0.14, 0.4], [0.24, 0.52], [0.3, 0.66],
|
||||
[0.26, 0.78], [0.34, 0.35], [0.9, 0.72], [0.66, 0.68], [0.1, 0.26], [0.4, 0.72],
|
||||
]
|
||||
|
||||
interface TeaserArc { a: number; b: number; born: number }
|
||||
|
||||
/** The living background of the beacon: pulsing lights, traveling golden arcs. */
|
||||
function TeaserCanvas() {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
useEffect(() => {
|
||||
const cv = ref.current
|
||||
const c = cv?.getContext('2d')
|
||||
if (!cv || !c) return
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
|
||||
const fit = () => {
|
||||
cv.width = Math.round(cv.clientWidth * dpr)
|
||||
cv.height = Math.round(cv.clientHeight * dpr)
|
||||
c.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
fit()
|
||||
const ro = new ResizeObserver(fit)
|
||||
ro.observe(cv)
|
||||
const arcs: TeaserArc[] = []
|
||||
let last = 0
|
||||
let raf = 0
|
||||
const draw = (now: number) => {
|
||||
const t = now / 1000
|
||||
const w = cv.clientWidth
|
||||
const h = cv.clientHeight
|
||||
c.clearRect(0, 0, w, h)
|
||||
// A new arc every ~1.6s between two far-apart lights.
|
||||
if (t - last > 1.6 && arcs.length < 7) {
|
||||
last = t
|
||||
const a = Math.floor(Math.random() * TEASER_POINTS.length)
|
||||
let b = Math.floor(Math.random() * TEASER_POINTS.length)
|
||||
for (let tries = 0; tries < 6; tries++) {
|
||||
b = Math.floor(Math.random() * TEASER_POINTS.length)
|
||||
if (Math.abs(TEASER_POINTS[a][0] - TEASER_POINTS[b][0]) > 0.22) break
|
||||
}
|
||||
arcs.push({ a, b, born: t })
|
||||
}
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
// Arcs: grow 1.4s, glow, fade until 4.5s.
|
||||
for (let i = arcs.length - 1; i >= 0; i--) {
|
||||
const arc = arcs[i]
|
||||
const age = t - arc.born
|
||||
if (age > 4.5) { arcs.splice(i, 1); continue }
|
||||
const grow = Math.min(age / 1.4, 1)
|
||||
const fade = age < 3 ? 1 : 1 - (age - 3) / 1.5
|
||||
const [ax, ay] = TEASER_POINTS[arc.a]
|
||||
const [bx, by] = TEASER_POINTS[arc.b]
|
||||
const x1 = ax * w; const y1 = ay * h
|
||||
const x2 = bx * w; const y2 = by * h
|
||||
const mx = (x1 + x2) / 2
|
||||
const my = (y1 + y2) / 2 - Math.min(Math.abs(x2 - x1) * 0.35, h * 0.34)
|
||||
c.beginPath()
|
||||
const steps = 22
|
||||
const upto = Math.max(2, Math.round(steps * grow))
|
||||
for (let s = 0; s <= upto; s++) {
|
||||
const q = s / steps
|
||||
const ix = (1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2
|
||||
const iy = (1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2
|
||||
if (s === 0) c.moveTo(ix, iy)
|
||||
else c.lineTo(ix, iy)
|
||||
}
|
||||
c.strokeStyle = `rgba(255, 180, 95, ${0.12 * fade})`
|
||||
c.lineWidth = 3.4
|
||||
c.stroke()
|
||||
c.strokeStyle = `rgba(255, 208, 130, ${0.55 * fade})`
|
||||
c.lineWidth = 1.1
|
||||
c.stroke()
|
||||
}
|
||||
// Lights: soft breathing points.
|
||||
for (let i = 0; i < TEASER_POINTS.length; i++) {
|
||||
const [px, py] = TEASER_POINTS[i]
|
||||
const breathe = 0.55 + 0.45 * Math.sin(t * 1.4 + i * 1.7)
|
||||
c.fillStyle = `rgba(255, 214, 150, ${0.5 * breathe})`
|
||||
c.beginPath()
|
||||
c.arc(px * w, py * h, 1.4 + breathe * 0.8, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
c.fillStyle = `rgba(255, 190, 110, ${0.1 * breathe})`
|
||||
c.beginPath()
|
||||
c.arc(px * w, py * h, 6 + breathe * 3, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
if (!reduced) raf = requestAnimationFrame(draw)
|
||||
}
|
||||
raf = requestAnimationFrame(draw)
|
||||
return () => { cancelAnimationFrame(raf); ro.disconnect() }
|
||||
}, [])
|
||||
return <canvas ref={ref} className="fz-beacon-canvas" aria-hidden />
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard trigger for the 4.0.0 "KEINE ANGST" show: a dark, pulsing card
|
||||
* at the top of the right sidebar (desktop only). One click starts the show;
|
||||
* the small × dismisses the beacon for good on this device.
|
||||
* The dashboard trigger for the 4.0.0 "KEINE ANGST" show: a dark cinematic card
|
||||
* with a living web of golden travel arcs (desktop only). One click starts the
|
||||
* show; the small × dismisses the beacon for good on this device.
|
||||
* Core colors ride inline so no dashboard widget CSS can wash them out.
|
||||
*/
|
||||
export default function NoFearBeacon() {
|
||||
const { language } = useTranslation()
|
||||
@@ -25,11 +126,13 @@ export default function NoFearBeacon() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fz-beacon">
|
||||
<button type="button" className="fz-beacon-play" onClick={() => setOpen(true)}>
|
||||
<div className="fz-beacon" style={{ background: '#07080d', color: '#f5f2ea' }}>
|
||||
<TeaserCanvas />
|
||||
<div className="fz-beacon-glow" aria-hidden />
|
||||
<button type="button" className="fz-beacon-play" style={{ background: 'transparent', color: '#f5f2ea' }} onClick={() => setOpen(true)}>
|
||||
<p className="fz-beacon-eyebrow">TREK 4.0.0</p>
|
||||
<p className="fz-beacon-title">{copy.beaconTitle}</p>
|
||||
<p className="fz-beacon-sub">{copy.beaconSub}</p>
|
||||
<p className="fz-beacon-title" style={{ color: '#f7f3e8' }}>{copy.beaconTitle}</p>
|
||||
<p className="fz-beacon-sub" style={{ color: 'rgba(245, 240, 225, 0.72)' }}>{copy.beaconSub}</p>
|
||||
<span className="fz-beacon-cta"><Play size={12} fill="currentColor" /> {copy.beaconCta}</span>
|
||||
</button>
|
||||
<span className="fz-beacon-pulse" aria-hidden />
|
||||
|
||||
@@ -252,40 +252,103 @@
|
||||
/* ── beacon (dashboard trigger) ── */
|
||||
|
||||
/* Wrapper is a div so the dismiss can be a real sibling <button> — a button
|
||||
may not contain another interactive element. */
|
||||
may not contain another interactive element. Core colors also ride inline in
|
||||
the component so no dashboard widget CSS can wash them out. */
|
||||
.fz-beacon {
|
||||
position: relative;
|
||||
display: none;
|
||||
width: 100%;
|
||||
min-height: 196px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(120% 90% at 20% 0%, rgba(235, 60, 55, 0.16), transparent 55%),
|
||||
radial-gradient(130% 100% at 85% 100%, rgba(255, 180, 90, 0.14), transparent 60%),
|
||||
#0a0c12;
|
||||
color: #f5f2ea;
|
||||
isolation: isolate;
|
||||
box-shadow: 0 12px 34px -14px rgba(0, 0, 0, 0.55), inset 0 0 0 1px rgba(255, 190, 110, 0.14);
|
||||
transition: box-shadow 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
.fz-beacon:hover {
|
||||
box-shadow: 0 16px 44px -14px rgba(220, 80, 50, 0.35), inset 0 0 0 1px rgba(255, 190, 110, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.fz-beacon { display: block; }
|
||||
}
|
||||
|
||||
/* The living night-world painted by TeaserCanvas. */
|
||||
.fz-beacon-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Slow-breathing colored glows drifting over the canvas. */
|
||||
.fz-beacon-glow {
|
||||
position: absolute;
|
||||
inset: -30%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(45% 40% at 22% 18%, rgba(235, 60, 55, 0.2), transparent 70%),
|
||||
radial-gradient(50% 45% at 82% 78%, rgba(255, 170, 80, 0.16), transparent 70%);
|
||||
animation: fzGlowDrift 9s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes fzGlowDrift {
|
||||
from { transform: translate(-2%, -2%) scale(1); opacity: 0.8; }
|
||||
to { transform: translate(2%, 3%) scale(1.08); opacity: 1; }
|
||||
}
|
||||
|
||||
.fz-beacon-play {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 196px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
padding: 18px 16px 16px;
|
||||
padding: 78px 18px 16px;
|
||||
font-family: inherit;
|
||||
/* Scrim so the copy always sits on darkness, whatever the canvas draws. */
|
||||
background: linear-gradient(180deg, rgba(7, 8, 13, 0) 0%, rgba(7, 8, 13, 0.55) 46%, rgba(7, 8, 13, 0.92) 100%) !important;
|
||||
}
|
||||
|
||||
.fz-beacon-eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.16em;
|
||||
color: rgba(255, 150, 110, 0.95);
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
|
||||
.fz-beacon-title {
|
||||
font-size: 27px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.045em;
|
||||
line-height: 1.05;
|
||||
margin: 0 0 6px;
|
||||
background: linear-gradient(100deg, #f7f3e8 0%, #f7f3e8 38%, #ffd98f 50%, #f7f3e8 62%, #f7f3e8 100%);
|
||||
background-size: 240% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: fzTitleSheen 4.5s ease-in-out infinite;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes fzTitleSheen {
|
||||
0%, 25% { background-position: 120% 0; }
|
||||
60%, 100% { background-position: -60% 0; }
|
||||
}
|
||||
|
||||
.fz-beacon-pulse {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
inset-inline-end: 16px;
|
||||
z-index: 2;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
@@ -310,44 +373,32 @@
|
||||
to { transform: scale(3.6); opacity: 0; }
|
||||
}
|
||||
|
||||
.fz-beacon-eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(255, 170, 120, 0.85);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.fz-beacon-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.fz-beacon-sub {
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
color: rgba(245, 240, 225, 0.68);
|
||||
color: rgba(245, 240, 225, 0.72);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fz-beacon-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
gap: 7px;
|
||||
margin-top: 13px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
color: #ffcf8e;
|
||||
transition: color 0.2s, gap 0.25s;
|
||||
}
|
||||
|
||||
.fz-beacon:hover .fz-beacon-cta { color: #ffe3b3; }
|
||||
.fz-beacon:hover .fz-beacon-cta { color: #ffe6b8; gap: 10px; }
|
||||
|
||||
.fz-beacon-dismiss {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
inset-inline-start: 8px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
padding: 5px;
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user