diff --git a/client/src/components/FourZero/NoFearBeacon.tsx b/client/src/components/FourZero/NoFearBeacon.tsx
index 889006f41..d34b609d8 100644
--- a/client/src/components/FourZero/NoFearBeacon.tsx
+++ b/client/src/components/FourZero/NoFearBeacon.tsx
@@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
-import { Play, X } from 'lucide-react'
+import { Play } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { noFearCopy } from './noFearLines'
import './fourzero.css'
@@ -8,8 +8,6 @@ import './fourzero.css'
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][] = [
@@ -120,17 +118,13 @@ function TeaserCanvas() {
/**
* 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.
+ * show. Core colors ride inline so no dashboard widget CSS can wash them out.
*/
export default function NoFearBeacon() {
const { language } = useTranslation()
const copy = noFearCopy(language)
- const [dismissed, setDismissed] = useState(() => localStorage.getItem(DISMISS_KEY) === '1')
const [open, setOpen] = useState(false)
- if (dismissed) return null
-
return (
<>
@@ -141,14 +135,6 @@ export default function NoFearBeacon() {
{copy.beaconSub}
-
{ localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) }}
- >
-
-
{open && (
diff --git a/client/src/components/FourZero/NoFearShow.tsx b/client/src/components/FourZero/NoFearShow.tsx
index 9e5d2a1c1..bf968dbbe 100644
--- a/client/src/components/FourZero/NoFearShow.tsx
+++ b/client/src/components/FourZero/NoFearShow.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import ReactDOM from 'react-dom'
import { Volume2, VolumeX, X } from 'lucide-react'
import { placesApi, tripsApi } from '../../api/client'
import apiClient from '../../api/client'
@@ -38,10 +39,8 @@ const CUES: Cue[] = [
{ at: 58.8, line: 'yourStatsCountries' },
{ at: 64.0, line: 'notAnOpinion', hard: true, impact: 1 },
{ at: 71.0, act: 'anthem', line: null, impact: 0.8 },
- { at: 84.0, act: 'end', line: null },
]
const ANTHEM_AT = 71
-const CREDITS_AT = 84
// The three staccato words strobe with the late fear act, just before the cut.
const FLASH_WINDOWS: [number, number][] = [[22.2, 22.85], [23.6, 24.25], [25.0, 25.65]]
@@ -62,7 +61,7 @@ function sceneAt(t: number): SceneState {
warmth: ramp(t, 34, 46),
personalGlow: ramp(t, 55.5, 60.5),
particles: ramp(t, ANTHEM_AT, ANTHEM_AT + 4),
- opacity: (1 - blackout) * (t >= CREDITS_AT ? 0.35 : 1),
+ opacity: 1 - blackout,
}
}
@@ -83,15 +82,13 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
const [cueIdx, setCueIdx] = useState(-1)
const [flashIdx, setFlashIdx] = useState(-1)
const [muted, setMuted] = useState(false)
- const [credits, setCredits] = useState(false)
const [anthem, setAnthem] = useState(false)
const reducedMotion = useMemo(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, [])
const skipToEnd = useCallback(() => {
- startRef.current = performance.now() - CREDITS_AT * 1000
+ startRef.current = performance.now() - ANTHEM_AT * 1000
setAnthem(true)
- setCredits(true)
- audioRef.current?.setAct('end')
+ audioRef.current?.setAct('anthem')
}, [])
// Boot: audio, scene, personal places, chrome side effects.
@@ -175,7 +172,7 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
const ctx = cv?.getContext('2d')
if (reducedMotion) {
setCueIdx(CUES.length - 1)
- if (ctx) sceneRef.current?.draw(ctx, { ...sceneAt(CREDITS_AT), particles: 0 }, CREDITS_AT)
+ if (ctx) sceneRef.current?.draw(ctx, { ...sceneAt(ANTHEM_AT + 8), particles: 0 }, ANTHEM_AT + 8)
return
}
let raf = 0
@@ -193,7 +190,6 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
if (cue.swell) audioRef.current?.swell()
setCueIdx(idx)
if (t >= ANTHEM_AT) setAnthem(true)
- if (t >= CREDITS_AT) setCredits(true)
}
let flash = -1
for (let i = 0; i < FLASH_WINDOWS.length; i++) {
@@ -228,7 +224,10 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
else if (lineKey) lineText = copy.lines[lineKey]
const hard = !!cue?.hard
- return (
+ // Portal to body: the dashboard sidebar is position:sticky and forms its own
+ // stacking context, so an in-place overlay would sit UNDER the fixed navbar
+ // (z-200 at root level) no matter its z-index.
+ return ReactDOM.createPortal(
@@ -249,8 +248,8 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
)}
- {/* Anthem: the big words + the cascade through every TREK language. */}
- {anthem && !credits && (
+ {/* The ending: the big words + the cascade through every TREK language — and it stays. */}
+ {anthem && (
{copy.anthem}
@@ -263,16 +262,6 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
)}
- {/* Credits — stays until dismissed. */}
- {credits && (
-
-
{copy.anthem}
-
{copy.tooBeautiful}
-
{copy.creditTitle}
-
{copy.creditBody}
-
- )}
-
{/* Chrome: mute, skip, close. */}
void }) {
>
{muted ? : }
- {!credits && (
+ {!anthem && (
{copy.skip}
@@ -292,6 +281,7 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
-
+ ,
+ document.body
)
}
diff --git a/client/src/components/FourZero/fourzero.css b/client/src/components/FourZero/fourzero.css
index 2d22aa996..97eea64df 100644
--- a/client/src/components/FourZero/fourzero.css
+++ b/client/src/components/FourZero/fourzero.css
@@ -170,76 +170,6 @@
to { opacity: 0.92; transform: translateY(0); }
}
-/* ── credits ── */
-
-.fz-credits {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 14px;
- padding: 0 8vw;
- text-align: center;
- animation: fzLineIn 1.8s ease-out both;
-}
-
-.fz-credit-lead {
- font-size: clamp(16px, 1.7vw, 23px);
- font-weight: 300;
- font-style: italic;
- opacity: 0.95;
- margin: 6px 0 4px;
- max-width: 680px;
-}
-
-.fz-credit-title {
- font-size: clamp(17px, 1.8vw, 24px);
- font-weight: 700;
- margin: 10px 0 0;
-}
-
-.fz-credit-body {
- font-size: clamp(14px, 1.4vw, 18px);
- font-weight: 300;
- opacity: 0.85;
- margin: 0;
- max-width: 640px;
-}
-
-.fz-credit-link {
- font-size: 13px;
- color: rgba(255, 200, 120, 0.9);
- text-decoration: none;
- border-bottom: 1px solid rgba(255, 200, 120, 0.4);
- padding-bottom: 1px;
- margin-top: 6px;
- pointer-events: auto;
-}
-
-.fz-credit-link:hover { color: #ffd58a; border-color: #ffd58a; }
-
-.fz-close-btn {
- margin-top: 26px;
- padding: 11px 34px;
- border-radius: 999px;
- border: 1px solid rgba(245, 240, 225, 0.35);
- background: transparent;
- color: #f5f2ea;
- font-size: 14px;
- font-weight: 600;
- font-family: inherit;
- letter-spacing: 0.04em;
- cursor: pointer;
- transition: background 0.2s, border-color 0.2s;
-}
-
-.fz-close-btn:hover {
- background: rgba(245, 240, 225, 0.12);
- border-color: rgba(245, 240, 225, 0.7);
-}
-
/* ── chrome ── */
.fz-chrome {
@@ -332,41 +262,50 @@
display: flex;
flex-direction: column;
align-items: center;
- justify-content: flex-end;
+ justify-content: center;
width: 100%;
min-height: 216px;
border: none;
text-align: center;
cursor: pointer;
- padding: 64px 18px 20px;
+ padding: 22px 18px 18px;
font-family: inherit;
color: #f5f2ea;
- /* Cinema scrim: darkness pools at the bottom and in the corners. */
+ /* Cinema scrim: a dark pool behind the centered copy, corners vignetted. */
background:
- radial-gradient(130% 90% at 50% 0%, rgba(7, 8, 13, 0) 40%, rgba(7, 8, 13, 0.5) 100%),
- linear-gradient(180deg, rgba(7, 8, 13, 0) 20%, rgba(7, 8, 13, 0.62) 58%, rgba(7, 8, 13, 0.95) 100%) !important;
+ radial-gradient(120% 105% at 50% 52%, rgba(7, 8, 13, 0.62) 0%, rgba(7, 8, 13, 0.28) 52%, rgba(7, 8, 13, 0.7) 100%) !important;
}
+/* Ivory-to-gold text with a traveling sheen and a slow breathing glow —
+ quiet effects, jewelry-grade, no gimmicks. */
.fz-beacon-title {
display: block;
- font-size: 30px;
+ font-size: 31px;
font-weight: 900;
- letter-spacing: 0.13em;
- text-indent: 0.13em;
+ letter-spacing: 0.14em;
+ text-indent: 0.14em;
line-height: 1.04;
- margin: 0 0 7px;
- background: linear-gradient(100deg, #f7f3e8 0%, #f7f3e8 38%, #ffd98f 50%, #f7f3e8 62%, #f7f3e8 100%);
- background-size: 240% 100%;
+ margin: 0 0 8px;
+ background-image:
+ linear-gradient(110deg, transparent 40%, rgba(255, 244, 214, 0.95) 50%, transparent 60%),
+ linear-gradient(180deg, #fdfaf1 0%, #f4e6c2 52%, #e5c084 100%);
+ background-size: 260% 100%, 100% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
- animation: fzTitleSheen 4.5s ease-in-out infinite;
- filter: drop-shadow(0 0 22px rgba(255, 205, 130, 0.28)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.8));
+ animation:
+ fzTitleSheen 5.5s ease-in-out infinite,
+ fzTitleBreathe 4.5s ease-in-out infinite;
}
@keyframes fzTitleSheen {
- 0%, 25% { background-position: 120% 0; }
- 60%, 100% { background-position: -60% 0; }
+ 0%, 30% { background-position: 130% 0, 0 0; }
+ 62%, 100% { background-position: -70% 0, 0 0; }
+}
+
+@keyframes fzTitleBreathe {
+ 0%, 100% { filter: drop-shadow(0 0 16px rgba(255, 205, 130, 0.18)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.85)); }
+ 50% { filter: drop-shadow(0 0 30px rgba(255, 210, 140, 0.42)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.85)); }
}
.fz-beacon-sub {