feat(dashboard): five signature text moments + widget-matched corners

- The finale condenses out of light: ~1000 particles rise from the world and
  gather into the anthem's letterforms before the crisp DOM text takes over;
  the language cascade waits for the word to settle.
- Replaced fear-act lines decay letter by letter — the words of fear fall
  apart instead of vanishing.
- The climax boom rocks the whole frame for half a second, in sync with the
  sub impact and the border shatter.
- Hope-act lines reveal sentence by sentence, in speech rhythm.
- The standing anthem breathes — a barely-there scale and glow pulse.
- Beacon corners now use the dashboard tool widgets' --r-xl radius.
This commit is contained in:
Maurice
2026-07-24 00:11:18 +02:00
committed by Maurice
parent ffda4de77d
commit 0834282bfd
3 changed files with 238 additions and 8 deletions
+84 -7
View File
@@ -5,6 +5,7 @@ import { placesApi, tripsApi } from '../../api/client'
import apiClient from '../../api/client'
import { useTranslation } from '../../i18n'
import type { Place, Trip } from '../../types'
import { TextAssembly } from './noFearAssembly'
import { NoFearAudio, type NoFearAct } from './noFearAudio'
import { NoFearScene, type SceneState } from './noFearScene'
import { ANTHEM_CASCADE, noFearCopy } from './noFearLines'
@@ -79,10 +80,15 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
const sceneRef = useRef<NoFearScene | null>(null)
const startRef = useRef<number>(performance.now())
const statsRef = useRef<{ line: string } | null>(null)
const assemblyRef = useRef<TextAssembly | null>(null)
const anthemWordRef = useRef<HTMLHeadingElement>(null)
const [cueIdx, setCueIdx] = useState(-1)
const [flashIdx, setFlashIdx] = useState(-1)
const [muted, setMuted] = useState(false)
const [anthem, setAnthem] = useState(false)
// A fear-act line that was just replaced — rendered once more while its
// letters decay away (the words of fear literally fall apart).
const [ghost, setGhost] = useState<{ text: string; id: number } | null>(null)
const reducedMotion = useMemo(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, [])
const skipToEnd = useCallback(() => {
@@ -183,11 +189,25 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
let idx = -1
for (let i = 0; i < CUES.length; i++) if (t >= CUES[i].at) idx = i
if (idx !== lastCue && idx >= 0) {
// A replaced fear-act line decays letter by letter instead of vanishing.
if (lastCue >= 0 && lastCue <= 3) {
const prevKey = CUES[lastCue].line
if (prevKey) {
setGhost({ text: copy.lines[prevKey], id: lastCue })
window.setTimeout(() => setGhost(g => (g?.id === lastCue ? null : g)), 1400)
}
}
lastCue = idx
const cue = CUES[idx]
if (cue.act) audioRef.current?.setAct(cue.act)
if (cue.impact) audioRef.current?.impact(cue.impact)
if (cue.swell) audioRef.current?.swell()
// The climax hit shakes the whole frame with the boom.
if ((cue.impact ?? 0) >= 0.8 && overlayRef.current) {
const el = overlayRef.current
el.classList.add('fz-shake')
window.setTimeout(() => el.classList.remove('fz-shake'), 700)
}
setCueIdx(idx)
if (t >= ANTHEM_AT) setAnthem(true)
}
@@ -200,6 +220,29 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
setFlashIdx(flash)
}
if (ctx) sceneRef.current?.draw(ctx, sceneAt(t), t)
// The finale condenses out of light: once the anthem title is measurable,
// sample its letterforms and gather rising particles into them; the real
// DOM text takes over as they settle.
if (ctx && cv && t >= ANTHEM_AT) {
const word = anthemWordRef.current
if (!assemblyRef.current && word) {
const assembly = new TextAssembly()
const style = window.getComputedStyle(word)
const rect = word.getBoundingClientRect()
assembly.init(
word.textContent ?? '',
`${style.fontWeight} ${style.fontSize} ${style.fontFamily}`,
{ left: rect.left, top: rect.top, width: rect.width, height: rect.height },
cv.clientWidth,
cv.clientHeight,
)
assemblyRef.current = assembly
}
const progress = ramp(t, ANTHEM_AT + 0.4, ANTHEM_AT + 3.6)
const fade = ramp(t, ANTHEM_AT + 3.4, ANTHEM_AT + 4.8)
assemblyRef.current?.draw(ctx, progress, fade, t)
word?.classList.toggle('fz-word-hidden', fade < 0.15)
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
@@ -241,20 +284,54 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
</div>
)}
{/* The lines. */}
{!anthem && lineText && flashIdx < 0 && (
<div className="fz-line-wrap" aria-live="polite">
<p key={lineText} className={`fz-line ${hard ? 'fz-line-hard' : ''} ${cue?.soft ? 'fz-line-soft' : ''}`}>{lineText}</p>
{/* A replaced fear line decays away letter by letter. */}
{!anthem && ghost && (
<div className="fz-line-wrap" aria-hidden>
<p key={`ghost-${ghost.id}`} className="fz-line fz-line-ghost">
{[...ghost.text].map((ch, i) => (
<span
key={i}
className="fz-char-decay"
style={{
'--dx': `${(Math.sin(i * 37.7) * 0.5) * 52}px`,
'--dy': `${-(12 + Math.abs(Math.sin(i * 17.3)) * 34)}px`,
'--rot': `${Math.sin(i * 53.1) * 14}deg`,
animationDelay: `${Math.abs(Math.sin(i * 91.7)) * 0.3}s`,
} as React.CSSProperties}
>
{ch === ' ' ? ' ' : ch}
</span>
))}
</p>
</div>
)}
{/* The ending: the big words + the cascade through every TREK language — and it stays. */}
{/* The lines. Hope-act lines reveal sentence by sentence, like speech. */}
{!anthem && lineText && flashIdx < 0 && (
<div className="fz-line-wrap" aria-live="polite">
<p key={lineText} className={`fz-line ${hard ? 'fz-line-hard' : ''} ${cue?.soft ? 'fz-line-soft' : ''}`}>
{cueIdx >= 6 && cueIdx <= 10 && !hard
? lineText.split(/(?<=[.!?…])\s+/u).map((seg, i) => (
<span key={i} className="fz-seg" style={{ animationDelay: `${i * 0.95}s` }}>{seg}{' '}</span>
))
: lineText}
</p>
</div>
)}
{/* The ending: the big words condense out of rising light, then the
cascade through every TREK language joins — and it all stays. */}
{anthem && (
<div className="fz-anthem">
<h1 className="fz-anthem-word">{copy.anthem}</h1>
<h1
ref={anthemWordRef}
className={`fz-anthem-word ${reducedMotion ? '' : 'fz-word-assembled fz-word-hidden'}`}
>
{copy.anthem}
</h1>
<div className="fz-cascade">
{ANTHEM_CASCADE.filter(c => c.lang !== language).map((c, i) => (
<span key={c.lang} className="fz-cascade-item" style={{ animationDelay: `${0.6 + i * 0.42}s` }}>
<span key={c.lang} className="fz-cascade-item" style={{ animationDelay: `${(reducedMotion ? 0.2 : 4.6) + i * 0.42}s` }}>
{c.text}
</span>
))}
+62 -1
View File
@@ -76,6 +76,49 @@
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
/* A replaced fear line falls apart: every letter drifts off on its own path. */
.fz-line-ghost {
position: absolute;
animation: none;
}
.fz-char-decay {
display: inline-block;
white-space: pre;
animation: fzCharDecay 1.05s cubic-bezier(0.4, 0, 0.9, 0.4) both;
}
@keyframes fzCharDecay {
0% { opacity: 1; transform: translate(0, 0) rotate(0); filter: blur(0); }
100% { opacity: 0; transform: translate(var(--dx, 0), var(--dy, -20px)) rotate(var(--rot, 0)); filter: blur(7px); }
}
/* Hope-act lines arrive sentence by sentence, in speech rhythm. */
.fz-seg {
opacity: 0;
animation: fzSegIn 1.5s cubic-bezier(0.19, 1, 0.22, 1) both;
}
@keyframes fzSegIn {
from { opacity: 0; filter: blur(5px); }
to { opacity: 1; filter: blur(0); }
}
/* The climax boom rocks the whole frame — short, decaying, gone. */
.fz-shake {
animation: fzShake 0.55s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes fzShake {
0% { transform: translate(0, 0); }
12% { transform: translate(-6px, 3px) rotate(-0.25deg); }
28% { transform: translate(5px, -4px) rotate(0.2deg); }
46% { transform: translate(-4px, 2px); }
64% { transform: translate(3px, -1px); }
82% { transform: translate(-1px, 1px); }
100% { transform: translate(0, 0); }
}
/* Staccato words strobing to the heartbeat in the late fear act. */
.fz-flash-wrap {
position: absolute;
@@ -149,6 +192,24 @@
to { opacity: 1; transform: scale(1); letter-spacing: 0.06em; filter: blur(0); }
}
/* Assembly mode: the DOM word skips the zoom-in (the particles ARE the
entrance, and the zoom would skew the measured letterform targets); once the
particles settle it fades in crisp and keeps breathing quietly forever. */
.fz-anthem-word.fz-word-assembled {
animation: fzAnthemBreathe 5.5s ease-in-out 5.5s infinite;
transition: opacity 1.5s ease;
}
.fz-anthem-word.fz-word-hidden {
opacity: 0;
transition: none;
}
@keyframes fzAnthemBreathe {
0%, 100% { transform: scale(1); text-shadow: 0 0 60px rgba(255, 200, 120, 0.35), 0 2px 30px rgba(0, 0, 0, 0.9); }
50% { transform: scale(1.012); text-shadow: 0 0 84px rgba(255, 205, 130, 0.5), 0 2px 30px rgba(0, 0, 0, 0.9); }
}
.fz-cascade {
display: flex;
flex-wrap: wrap;
@@ -214,7 +275,7 @@
display: none;
width: 100%;
min-height: 196px;
border-radius: 16px;
border-radius: var(--r-xl, 16px); /* same corner radius as the dashboard tool widgets */
overflow: hidden;
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);
@@ -0,0 +1,92 @@
// The finale's signature move: "KEINE ANGST" is not faded in — it CONDENSES.
// Hundreds of light particles rise out of the world and gather into the
// letterforms; once they settle, the real DOM title takes over for crispness.
// The targets are sampled from an offscreen raster of the exact same text in
// the exact same font, so the swap is seamless.
interface AssemblyParticle {
sx: number
sy: number
tx: number
ty: number
delay: number
size: number
seed: number
}
const easeOutCubic = (q: number): number => 1 - (1 - q) ** 3
export class TextAssembly {
private particles: AssemblyParticle[] = []
private ready = false
/**
* Samples particle targets from the given text. `font` is a full CSS font
* shorthand measured off the real DOM title; the box is the title's screen
* rect so canvas particles land exactly where the DOM text will appear.
*/
init(text: string, font: string, box: { left: number; top: number; width: number; height: number }, screenW: number, screenH: number): void {
const off = document.createElement('canvas')
// Raster at a capped size — targets only need ~2px precision.
const scale = Math.min(1, 700 / Math.max(box.width, 1))
off.width = Math.max(Math.round(box.width * scale), 1)
off.height = Math.max(Math.round(box.height * scale), 1)
const c = off.getContext('2d')
if (!c) return
c.font = font.replace(/(\d+(?:\.\d+)?)px/, (_, n) => `${Number(n) * scale}px`)
c.textAlign = 'center'
c.textBaseline = 'middle'
c.fillStyle = '#fff'
c.fillText(text, off.width / 2, off.height / 2)
const img = c.getImageData(0, 0, off.width, off.height).data
const step = 3 // sample grid in raster px — ~800-1400 particles for a title
this.particles = []
for (let y = 0; y < off.height; y += step) {
for (let x = 0; x < off.width; x += step) {
if (img[(y * off.width + x) * 4 + 3] > 128) {
const tx = box.left + x / scale
const ty = box.top + y / scale
// Particles rise from below and drift in from the sides — they come
// OUT of the world, not from nowhere.
const fromSide = Math.random() < 0.3
this.particles.push({
sx: fromSide ? (Math.random() < 0.5 ? -30 : screenW + 30) : Math.random() * screenW,
sy: fromSide ? Math.random() * screenH : screenH + 20 + Math.random() * 80,
tx,
ty,
delay: Math.random() * 0.45,
size: 0.9 + Math.random() * 1.2,
seed: Math.random() * 7,
})
}
}
}
this.ready = true
}
isReady(): boolean {
return this.ready
}
/** progress 0→1 gathers, `fade` 0→1 dissolves the particles into the DOM text. */
draw(c: CanvasRenderingContext2D, progress: number, fade: number, t: number): void {
if (!this.ready || fade >= 1) return
c.save()
c.globalCompositeOperation = 'lighter'
for (const p of this.particles) {
const local = easeOutCubic(Math.min(Math.max((progress - p.delay) / (1 - p.delay), 0), 1))
if (local <= 0) continue
const x = p.sx + (p.tx - p.sx) * local
const y = p.sy + (p.ty - p.sy) * local
// In flight the particle glows warm; settled it cools toward the ivory of the text.
const settled = local > 0.98
const flicker = 0.65 + 0.35 * Math.sin(t * 5 + p.seed)
c.globalAlpha = (1 - fade) * (settled ? 0.9 : 0.75 * flicker)
c.fillStyle = settled ? 'rgb(247, 240, 226)' : 'rgb(255, 205, 130)'
c.beginPath()
c.arc(x, y, settled ? p.size * 0.85 : p.size, 0, Math.PI * 2)
c.fill()
}
c.restore()
}
}