feat(mascot): 3D mascot as transparent 60 fps clips with a click animation per scene

Every empty-state scene of the TREK mascot now plays a pre-rendered 3D clip
instead of the SVG: a seamless loop per scene and theme plus a one-shot
animation on click that cross-fades in and out.

The clips are plain H.264 MP4 with stacked alpha (colour on top, the mask
below) that a small WebGL shader joins into a transparent image, so the same
file plays in every browser including iOS, decoded in hardware. The videos stay
2 px stand-ins and are drawn onto a canvas, which keeps browser video controls
off the mascot. Each clip is cropped to the area its scene uses and placed back
inside the 88:96 box, so every scene keeps the same scale.

MDancingTrek picks the clip when one exists for the scene and face and falls
back to the SVG under reduced motion, without WebGL, when autoplay is refused,
in tests, or with localStorage trek-mascot-3d set to 0.
This commit is contained in:
Maurice
2026-10-11 15:06:29 +02:00
parent 4a36a60f5a
commit ee1b6a837f
101 changed files with 619 additions and 67 deletions
+211 -67
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useState } from 'react';
import MascotClip, { findClip, mascot3dEnabled } from './trek3d/MascotClip';
/**
* The TREK mascot — an empty-state character that IS the TREK mark: the logo is
@@ -37,38 +38,58 @@ export type TrekScene =
| 'costs'
| 'search'
| 'tasks'
| 'tours'
| 'tours';
export type TrekMood = 'default' | 'happy' | 'sad' | 'sleepy' | 'confused' | 'error'
export type TrekMood = 'default' | 'happy' | 'sad' | 'sleepy' | 'confused' | 'error';
const MARK =
'M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z'
'M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z';
const SCENE_MOOD: Partial<Record<TrekScene, TrekMood>> = {
notifications: 'sleepy',
search: 'confused',
};
type MascotProps = {
size?: number;
scene?: TrekScene;
mood?: TrekMood;
className?: string;
};
/**
* Picks the look: the pre-rendered 3D clip when one exists for this scene and
* face (see `trek3d/MascotClip`), the SVG otherwise, under reduced motion, in
* tests, or when `localStorage['trek-mascot-3d']` is `'0'`.
*/
export default function MDancingTrek(props: MascotProps) {
const { size = 96, scene = 'idle', mood, className = '' } = props;
const sceneMood = SCENE_MOOD[scene] ?? 'default';
const clip =
import.meta.env.MODE !== 'test' && mascot3dEnabled() ? findClip(scene, mood ?? sceneMood, sceneMood) : null;
if (clip)
return (
<MascotClip
clip={clip}
scene={scene}
size={size}
className={className}
fallback={<MDancingTrekSvg {...props} />}
/>
);
return <MDancingTrekSvg {...props} />;
}
export default function MDancingTrek({
size = 96,
scene = 'idle',
mood,
className = '',
}: {
size?: number
scene?: TrekScene
mood?: TrekMood
className?: string
}) {
const face = mood ?? SCENE_MOOD[scene] ?? 'default'
function MDancingTrekSvg({ size = 96, scene = 'idle', mood, className = '' }: MascotProps) {
const face = mood ?? SCENE_MOOD[scene] ?? 'default';
// Ground shadow sits under whatever the mascot stands on this scene.
const shadowCy = scene === 'transport' ? 86 : 73
const shadowCy = scene === 'transport' ? 86 : 73;
// Poke to react: remounting the svg replays the entrance pop-in as a tap bounce.
const [poke, setPoke] = useState(0)
const [poke, setPoke] = useState(0);
return (
<svg
key={poke}
onClick={() => setPoke(p => p + 1)}
onClick={() => setPoke((p) => p + 1)}
width={size}
height={(size * 96) / 88}
viewBox="0 0 88 96"
@@ -96,7 +117,7 @@ export default function MDancingTrek({
<SceneFront scene={scene} />
</g>
</svg>
)
);
}
/* ── Eyes ────────────────────────────────────────────────────────────────────
@@ -111,7 +132,7 @@ function Eyes({ mood }: { mood: TrekMood }) {
<path d="M33.7 34 Q36 31.2 38.3 34" />
<path d="M42.7 34 Q45 31.2 47.3 34" />
</g>
)
);
}
if (mood === 'sad') {
// The mirror of `happy`: the same arcs bent the other way. `sleepy` is a
@@ -122,7 +143,7 @@ function Eyes({ mood }: { mood: TrekMood }) {
<path d="M33.7 32 Q36 34.8 38.3 32" />
<path d="M42.7 32 Q45 34.8 47.3 32" />
</g>
)
);
}
if (mood === 'sleepy') {
return (
@@ -130,7 +151,7 @@ function Eyes({ mood }: { mood: TrekMood }) {
<path d="M33.7 33 Q36 34.4 38.3 33" />
<path d="M42.7 33 Q45 34.4 47.3 33" />
</g>
)
);
}
if (mood === 'error') {
return (
@@ -138,7 +159,7 @@ function Eyes({ mood }: { mood: TrekMood }) {
<path d="M34.4 31.4 L37.6 34.6 M37.6 31.4 L34.4 34.6" />
<path d="M43.4 31.4 L46.6 34.6 M46.6 31.4 L43.4 34.6" />
</g>
)
);
}
if (mood === 'confused') {
// one eye open, one a raised squint — reads puzzled
@@ -146,9 +167,15 @@ function Eyes({ mood }: { mood: TrekMood }) {
<g className="trek-eyes">
<circle className="trek-eye" cx="36" cy="33" r="2.3" />
<circle cx="36.4" cy="33.3" r="1" fill="currentColor" />
<path d="M42.7 32.6 Q45 31.4 47.3 32.6" fill="none" stroke="var(--m-bg)" strokeWidth="1.8" strokeLinecap="round" />
<path
d="M42.7 32.6 Q45 31.4 47.3 32.6"
fill="none"
stroke="var(--m-bg)"
strokeWidth="1.8"
strokeLinecap="round"
/>
</g>
)
);
}
// default — open eyes that blink and glance around
return (
@@ -160,7 +187,7 @@ function Eyes({ mood }: { mood: TrekMood }) {
<circle cx="45.4" cy="33.3" r="1" fill="currentColor" />
</g>
</g>
)
);
}
/* ── Scene props ─────────────────────────────────────────────────────────────
@@ -174,12 +201,23 @@ function Eyes({ mood }: { mood: TrekMood }) {
* mark that gap is the only thing telling the pack from the body behind it.
*/
function SceneOnBody({ scene }: { scene: TrekScene }) {
if (scene !== 'tours') return null
const cut = { stroke: 'var(--m-bg)', strokeWidth: 1.6, paintOrder: 'stroke' as const, strokeLinejoin: 'round' as const }
if (scene !== 'tours') return null;
const cut = {
stroke: 'var(--m-bg)',
strokeWidth: 1.6,
paintOrder: 'stroke' as const,
strokeLinejoin: 'round' as const,
};
return (
<g>
{/* a small smile under the eyes, glad to be out walking */}
<path d="M38.2 38.9 Q40.4 41.2 42.6 38.9" fill="none" stroke="var(--m-bg)" strokeWidth="1.4" strokeLinecap="round" />
<path
d="M38.2 38.9 Q40.4 41.2 42.6 38.9"
fill="none"
stroke="var(--m-bg)"
strokeWidth="1.4"
strokeLinecap="round"
/>
{/* the pack, slung over the left side */}
<g className="trek-pack">
<g transform="translate(2.6 4.2) rotate(-7 17 44)">
@@ -188,9 +226,19 @@ function SceneOnBody({ scene }: { scene: TrekScene }) {
<rect x="11.6" y="25.4" width="1.2" height="5.6" fill="var(--m-bg)" />
<rect x="20.8" y="25.4" width="1.2" height="5.6" fill="var(--m-bg)" />
{/* body of the pack */}
<path d="M10 34 Q10 31.2 13 31.2 L21.6 31.2 Q24.6 31.2 24.6 34 L25 55.4 Q25 59 21.4 59 L12.6 59 Q9 59 9.2 55.4 Z" fill="currentColor" {...cut} />
<path
d="M10 34 Q10 31.2 13 31.2 L21.6 31.2 Q24.6 31.2 24.6 34 L25 55.4 Q25 59 21.4 59 L12.6 59 Q9 59 9.2 55.4 Z"
fill="currentColor"
{...cut}
/>
{/* lid seam and buckle */}
<path d="M10.2 38.4 Q17.4 40.6 24.7 38.4" fill="none" stroke="var(--m-bg)" strokeWidth="1.1" strokeLinecap="round" />
<path
d="M10.2 38.4 Q17.4 40.6 24.7 38.4"
fill="none"
stroke="var(--m-bg)"
strokeWidth="1.1"
strokeLinecap="round"
/>
<rect x="16" y="38.8" width="2.8" height="3.4" rx="0.7" fill="var(--m-bg)" />
{/* front pocket */}
<rect x="12" y="46" width="10.6" height="9.4" rx="2.6" fill="none" stroke="var(--m-bg)" strokeWidth="1.1" />
@@ -198,7 +246,7 @@ function SceneOnBody({ scene }: { scene: TrekScene }) {
</g>
</g>
</g>
)
);
}
function SceneBack({ scene }: { scene: TrekScene }) {
@@ -209,11 +257,22 @@ function SceneBack({ scene }: { scene: TrekScene }) {
<g>
<g className="trek-hills" fill="currentColor">
<path d="M44 73 L62 41 L69.5 52 L74.5 46 L88 73 Z" opacity="0.14" />
<path d="M58.6 47.2 L62 41 L65.6 46.4 L63.4 45.2 L61.6 47.6 Z M72.6 49 L74.5 46 L77 50.2 L75.4 49.4 Z" opacity="0.3" />
<path
d="M58.6 47.2 L62 41 L65.6 46.4 L63.4 45.2 L61.6 47.6 Z M72.6 49 L74.5 46 L77 50.2 L75.4 49.4 Z"
opacity="0.3"
/>
</g>
<path className="trek-trail" d="M10 77.5 H78" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeDasharray="3.4 4.6" opacity="0.35" />
<path
className="trek-trail"
d="M10 77.5 H78"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeDasharray="3.4 4.6"
opacity="0.35"
/>
</g>
)
);
case 'transport':
// skateboard under the body — deck + two wheels spinning in place
return (
@@ -228,7 +287,7 @@ function SceneBack({ scene }: { scene: TrekScene }) {
<circle cx="56" cy="79" r="1.1" fill="var(--m-bg)" />
</g>
</g>
)
);
case 'guide':
// tall staff with a big pennant fluttering near the top
return (
@@ -239,7 +298,7 @@ function SceneBack({ scene }: { scene: TrekScene }) {
<path d="M73 25 L90 30.5 L73 40 Z" fill="currentColor" />
</g>
</g>
)
);
case 'packing':
// rolling suitcase parked beside the body — rocks on its wheels,
// handle telescoping up and down
@@ -258,7 +317,7 @@ function SceneBack({ scene }: { scene: TrekScene }) {
<circle cx="79" cy="73.4" r="2" fill="currentColor" />
</g>
</g>
)
);
case 'links':
// two chain links clicking into each other beside the body. The lower one
// carries a surface-coloured under-stroke so it reads as passing through
@@ -273,7 +332,7 @@ function SceneBack({ scene }: { scene: TrekScene }) {
</g>
</g>
</g>
)
);
case 'polls':
// vote bars gently rising and settling like live tallies
return (
@@ -282,24 +341,36 @@ function SceneBack({ scene }: { scene: TrekScene }) {
<rect className="trek-bar trek-d2" x="73" y="52" width="5" height="20" rx="1.5" fill="currentColor" />
<rect className="trek-bar trek-d3" x="80" y="56" width="5" height="16" rx="1.5" fill="currentColor" />
</g>
)
);
case 'collections':
// a map pin dropping in and bouncing beside the body
return (
<g className="trek-pin">
<path d="M73 48 C 78 48 81.5 51.6 81.5 56.4 C 81.5 61.6 76 66 73 70 C 70 66 64.5 61.6 64.5 56.4 C 64.5 51.6 68 48 73 48 Z" fill="currentColor" />
<path
d="M73 48 C 78 48 81.5 51.6 81.5 56.4 C 81.5 61.6 76 66 73 70 C 70 66 64.5 61.6 64.5 56.4 C 64.5 51.6 68 48 73 48 Z"
fill="currentColor"
/>
<circle cx="73" cy="56.2" r="2.9" fill="var(--m-bg)" />
</g>
)
);
case 'atlas':
// a globe beside the body, meridians drifting
return (
<g className="trek-globe-wrap">
<circle cx="73" cy="60" r="11" fill="currentColor" />
<path d="M63 57 H83 M64.5 64.5 H81.5" stroke="var(--m-bg)" strokeWidth="1.2" strokeLinecap="round" />
<ellipse className="trek-globe" cx="73" cy="60" rx="4.6" ry="11" fill="none" stroke="var(--m-bg)" strokeWidth="1.2" />
<ellipse
className="trek-globe"
cx="73"
cy="60"
rx="4.6"
ry="11"
fill="none"
stroke="var(--m-bg)"
strokeWidth="1.2"
/>
</g>
)
);
case 'costs':
// a stack of coins beside the body, top one bouncing on
return (
@@ -312,7 +383,7 @@ function SceneBack({ scene }: { scene: TrekScene }) {
<rect x="72.7" y="58.6" width="2.6" height="5.8" rx="1.3" fill="var(--m-bg)" />
</g>
</g>
)
);
case 'chat':
// a speech bubble beside the body with three typing dots pulsing in turn
return (
@@ -323,9 +394,9 @@ function SceneBack({ scene }: { scene: TrekScene }) {
<circle className="trek-dot trek-d2" cx="73" cy="51.5" r="1.9" fill="var(--m-bg)" />
<circle className="trek-dot trek-d3" cx="79" cy="51.5" r="1.9" fill="var(--m-bg)" />
</g>
)
);
default:
return null
return null;
}
}
@@ -340,12 +411,18 @@ function SceneFront({ scene }: { scene: TrekScene }) {
<rect x="69.1" y="40.5" width="2.2" height="33.5" rx="1.1" fill="currentColor" />
<rect x="67.9" y="34" width="4.6" height="9" rx="2" fill="currentColor" />
</g>
<path d="M68.2 35.6 Q63.8 37.6 66.6 42" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />
<path
d="M68.2 35.6 Q63.8 37.6 66.6 42"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
/>
<rect x="68.6" y="38.2" width="3.2" height="0.9" rx="0.45" fill="var(--m-bg)" />
<ellipse cx="70.2" cy="69" rx="3.4" ry="1.1" fill="none" stroke="currentColor" strokeWidth="1.2" />
</g>
</g>
)
);
// Files reuse the booking ticket per design — a card getting written on.
case 'bookings':
case 'files':
@@ -360,16 +437,33 @@ function SceneFront({ scene }: { scene: TrekScene }) {
<rect className="trek-write trek-d3" x="64" y="63.5" width="7" height="1.7" rx="0.85" fill="var(--m-bg)" />
</g>
</g>
)
);
case 'notes':
// a paper note (surface colour) with ink lines being written and a
// scribbling pencil — pencil is ink (light in dark theme, dark in light)
return (
<g className="trek-note">
<g transform="rotate(5 70 55)">
<rect x="60" y="44" width="20" height="24" rx="2.5" fill="var(--m-bg)" stroke="currentColor" strokeWidth="1.4" />
<rect
x="60"
y="44"
width="20"
height="24"
rx="2.5"
fill="var(--m-bg)"
stroke="currentColor"
strokeWidth="1.4"
/>
<rect className="trek-write" x="63.5" y="50" width="13" height="1.7" rx="0.85" fill="currentColor" />
<rect className="trek-write trek-d2" x="63.5" y="54.5" width="13" height="1.7" rx="0.85" fill="currentColor" />
<rect
className="trek-write trek-d2"
x="63.5"
y="54.5"
width="13"
height="1.7"
rx="0.85"
fill="currentColor"
/>
<rect className="trek-write trek-d3" x="63.5" y="59" width="8" height="1.7" rx="0.85" fill="currentColor" />
</g>
<g className="trek-pencil">
@@ -381,22 +475,40 @@ function SceneFront({ scene }: { scene: TrekScene }) {
</g>
</g>
</g>
)
);
case 'journey':
// an open book with a fluttering page and a ribbon bookmark
return (
<g className="trek-journal">
<path d="M58 53 Q71 49.5 71 52 L71 69 Q71 66.5 58 70 Z" fill="currentColor" />
<path d="M84 53 Q71 49.5 71 52 L71 69 Q71 66.5 84 70 Z" fill="currentColor" />
<path d="M60.5 56 Q66 54 70 55.5 M60.5 60 Q66 58 70 59.5 M60.5 64 Q66 62 70 63.5" stroke="var(--m-bg)" strokeWidth="1" fill="none" strokeLinecap="round" />
<path d="M76 55.4 Q80 54 83 55.6 M76 59.4 Q80 58 83 59.6" stroke="var(--m-bg)" strokeWidth="1" fill="none" strokeLinecap="round" />
<path
d="M60.5 56 Q66 54 70 55.5 M60.5 60 Q66 58 70 59.5 M60.5 64 Q66 62 70 63.5"
stroke="var(--m-bg)"
strokeWidth="1"
fill="none"
strokeLinecap="round"
/>
<path
d="M76 55.4 Q80 54 83 55.6 M76 59.4 Q80 58 83 59.6"
stroke="var(--m-bg)"
strokeWidth="1"
fill="none"
strokeLinecap="round"
/>
<g className="trek-page">
<path d="M71 52 Q78 50.5 82 52 L82 68.5 Q78 67 71 68.5 Z" fill="currentColor" />
<path d="M74 55.4 Q78 54.2 81 55.6 M74 59.4 Q78 58.2 81 59.6" stroke="var(--m-bg)" strokeWidth="1" fill="none" strokeLinecap="round" />
<path
d="M74 55.4 Q78 54.2 81 55.6 M74 59.4 Q78 58.2 81 59.6"
stroke="var(--m-bg)"
strokeWidth="1"
fill="none"
strokeLinecap="round"
/>
</g>
<rect x="70" y="49.5" width="2.2" height="9" fill="var(--m-bg)" />
</g>
)
);
case 'dashboard':
// a little paper plane looping around, ready for a first trip
return (
@@ -404,16 +516,40 @@ function SceneFront({ scene }: { scene: TrekScene }) {
<path d="M64 53 L87 58 L70 68 L71 60 Z" fill="currentColor" />
<path d="M87 58 L71 60 L70 68" stroke="var(--m-bg)" strokeWidth="0.9" fill="none" strokeLinejoin="round" />
</g>
)
);
case 'notifications':
// all caught up — a stack of Zzz drifting up while the mascot dozes
return (
<g className="trek-zzz" fill="currentColor">
<path className="trek-z trek-z1" d="M63 50 h5 l-5 6 h5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" strokeLinecap="round" />
<path className="trek-z trek-z2" d="M69 42 h6 l-6 7 h6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round" />
<path className="trek-z trek-z3" d="M76 32 h7 l-7 8 h7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" />
<path
className="trek-z trek-z1"
d="M63 50 h5 l-5 6 h5"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path
className="trek-z trek-z2"
d="M69 42 h6 l-6 7 h6"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
strokeLinecap="round"
/>
<path
className="trek-z trek-z3"
d="M76 32 h7 l-7 8 h7"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinejoin="round"
strokeLinecap="round"
/>
</g>
)
);
case 'search':
// a magnifying glass scanning back and forth
return (
@@ -422,7 +558,7 @@ function SceneFront({ scene }: { scene: TrekScene }) {
<circle cx="72" cy="56" r="5.4" fill="var(--m-bg)" />
<rect x="77" y="62.5" width="3" height="10" rx="1.5" fill="currentColor" transform="rotate(-45 78.5 67.5)" />
</g>
)
);
case 'tasks':
// a clipboard checklist with a checkmark ticking on
return (
@@ -436,11 +572,19 @@ function SceneFront({ scene }: { scene: TrekScene }) {
<rect x="69.5" y="56.2" width="9" height="1.7" rx="0.85" fill="var(--m-bg)" />
<rect x="63.5" y="61.6" width="4" height="4" rx="1" fill="var(--m-bg)" />
<rect x="69.5" y="62.8" width="6" height="1.7" rx="0.85" fill="var(--m-bg)" />
<path className="trek-check" d="M64 50.5 l1.3 1.4 l2.2 -2.7" fill="none" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" strokeLinejoin="round" />
<path
className="trek-check"
d="M64 50.5 l1.3 1.4 l2.2 -2.7"
fill="none"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</g>
)
);
default:
return null
return null;
}
}
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import meta from './clips.meta.json';
/**
* The 3D TREK mascot as pre-rendered clips. Each clip is a plain H.264 MP4 with
* "stacked alpha": the colour frame on top, its transparency below as a grey
* mask. A tiny WebGL shader joins the halves into a transparent image, so the
* same file plays everywhere (iOS included) with hardware decoding and no
* runtime 3D. Eyes are holes in the mask, like the SVG's `--m-bg` cut-outs.
*
* One seamless loop per scene and theme plus a one-shot click clip; the click
* cross-fades into the clip and back inside the shader. The <video> elements
* stay 2 px stand-ins: browsers hang their own controls on visible videos.
*
* Clips live next to this file as `<scene>[--<mood>]-<light|dark>-<loop|poke>.mp4`,
* cropped to the area the scene uses; `crop` in clips.meta.json places them back
* inside the full 88:96 box so every scene keeps the same scale. Only URLs are
* bundled; the files load when a mascot actually renders.
*/
const CLIPS = import.meta.glob('./clips/*.mp4', { query: '?url', import: 'default', eager: true }) as Record<
string,
string
>;
const META = meta as Record<string, { poke: number; crop: [number, number, number, number] }>;
const FADE_MS = 140;
// The clips frame the figure with more air than the SVG, so they render at 1.6 times the requested size
const CLIP_SCALE = 1.6;
function clipKey(scene: string, mood: string, sceneMood: string) {
return mood === sceneMood ? scene : `${scene}--${mood}`;
}
export function findClip(scene: string, mood: string, sceneMood: string): string | null {
const key = clipKey(scene, mood, sceneMood);
return CLIPS[`./clips/${key}-light-loop.mp4`] ? key : null;
}
let webgl: boolean | null = null;
function hasWebgl(): boolean {
if (webgl === null) {
try {
webgl = !!document.createElement('canvas').getContext('webgl');
} catch {
webgl = false;
}
}
return webgl;
}
/** Opt-out for comparing against the SVG: localStorage `trek-mascot-3d` = `0`. */
export function mascot3dEnabled(): boolean {
try {
if (localStorage.getItem('trek-mascot-3d') === '0') return false;
} catch {
/* storage blocked: keep the default */
}
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return false;
return hasWebgl();
}
function useIsDark() {
const read = () => document.documentElement.classList.contains('dark');
const [dark, setDark] = useState(read);
useEffect(() => {
const mo = new MutationObserver(() => setDark(read()));
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => mo.disconnect();
}, []);
return dark;
}
const VERT = `attribute vec2 p; varying vec2 uv;
void main(){ uv = vec2((p.x + 1.0) * 0.5, (1.0 - p.y) * 0.5); gl_Position = vec4(p, 0.0, 1.0); }`;
// colour from the top half, alpha from the bottom half; premultiplied for the canvas
const FRAG = `precision mediump float; uniform sampler2D a; uniform sampler2D b; uniform float m; varying vec2 uv;
vec4 px(sampler2D t){ vec3 c = texture2D(t, vec2(uv.x, uv.y * 0.5)).rgb; float al = texture2D(t, vec2(uv.x, 0.5 + uv.y * 0.5)).r; return vec4(c * al, al); }
void main(){ gl_FragColor = mix(px(a), px(b), m); }`;
function setupGl(canvas: HTMLCanvasElement) {
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false });
if (!gl) return null;
const shader = (type: number, src: string) => {
const s = gl.createShader(type);
if (s) {
gl.shaderSource(s, src);
gl.compileShader(s);
}
return s;
};
const prog = gl.createProgram();
const vs = shader(gl.VERTEX_SHADER, VERT),
fs = shader(gl.FRAGMENT_SHADER, FRAG);
if (!prog || !vs || !fs) return null;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'p');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
const tex = (unit: number, name: string) => {
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.uniform1i(gl.getUniformLocation(prog, name), unit);
return t;
};
const texA = tex(0, 'a'),
texB = tex(1, 'b');
const mixLoc = gl.getUniformLocation(prog, 'm');
const upload = (unit: number, t: WebGLTexture | null, v: HTMLVideoElement) => {
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, v);
};
return {
draw(loop: HTMLVideoElement | null, poke: HTMLVideoElement | null, m: number) {
gl.viewport(0, 0, canvas.width, canvas.height);
if (loop && loop.readyState >= 2) upload(0, texA, loop);
if (poke && m > 0 && poke.readyState >= 2) upload(1, texB, poke);
gl.uniform1f(mixLoc, m);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
};
}
const HIDDEN_VIDEO = {
position: 'absolute',
insetInlineStart: 0,
top: 0,
width: 2,
height: 2,
opacity: 0,
pointerEvents: 'none',
} as const;
export default function MascotClip({
clip,
scene,
size,
className = '',
fallback,
}: {
clip: string;
scene: string;
size: number;
className?: string;
/** shown when the device refuses to autoplay (e.g. iOS low power mode) */
fallback: ReactNode;
}) {
const theme = useIsDark() ? 'dark' : 'light';
const loopUrl = CLIPS[`./clips/${clip}-${theme}-loop.mp4`];
const pokeUrl = CLIPS[`./clips/${scene}-${theme}-poke.mp4`];
const pokeMs = (META[scene]?.poke ?? 2) * 1000;
const [x, y, w, h] = META[clip]?.crop ?? [0, 0, 1, 1];
const boxW = size * CLIP_SCALE,
boxH = (boxW * 96) / 88;
const canvasRef = useRef<HTMLCanvasElement>(null);
const loopRef = useRef<HTMLVideoElement>(null);
const pokeRef = useRef<HTMLVideoElement>(null);
const [failed, setFailed] = useState(false);
// 0 = loop, 1 = click clip; the draw loop eases toward the target for the cross-fade
const mix = useRef({ value: 0, target: 0, until: 0 });
useEffect(() => {
const v = loopRef.current;
if (!v) return;
v.play().catch(() => setFailed(true));
}, [loopUrl]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || failed) return;
const gl = setupGl(canvas);
if (!gl) {
setFailed(true);
return;
}
let raf = 0,
visible = true,
last = performance.now();
const io = new IntersectionObserver(([e]) => {
visible = e.isIntersecting;
});
io.observe(canvas);
const frame = (now: number) => {
raf = requestAnimationFrame(frame);
const dt = now - last;
last = now;
if (!visible) return;
const m = mix.current;
if (m.target === 1 && now > m.until) m.target = 0;
const step = dt / FADE_MS;
m.value = m.target > m.value ? Math.min(1, m.value + step) : Math.max(0, m.value - step);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cw = Math.round(canvas.clientWidth * dpr),
ch = Math.round(canvas.clientHeight * dpr);
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw;
canvas.height = ch;
}
gl.draw(loopRef.current, pokeRef.current, m.value);
};
raf = requestAnimationFrame(frame);
// No loseContext() here: StrictMode re-runs this effect on the same canvas, which would then hand back the dead
// context. The browser frees it with the canvas.
return () => {
cancelAnimationFrame(raf);
io.disconnect();
};
}, [failed]);
if (failed) return <>{fallback}</>;
const poke = () => {
const v = pokeRef.current;
if (!v) return;
v.currentTime = 0;
v.play()
.then(() => {
mix.current.target = 1;
mix.current.until = performance.now() + Math.max(300, pokeMs - FADE_MS);
})
.catch(() => {
mix.current.target = 0;
});
};
return (
<span
role="presentation"
onClick={poke}
className={`relative inline-block shrink-0 cursor-pointer ${className}`}
style={{ width: boxW, height: boxH }}
>
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none absolute"
style={{
left: `${x * 100}%` /* rtl-lint-disable: the crop sits where the frame was cut, video is never mirrored */,
top: `${y * 100}%`,
width: `${w * 100}%`,
height: `${h * 100}%`,
}}
/>
<video
key={loopUrl}
ref={loopRef}
src={loopUrl}
autoPlay
loop
muted
playsInline
disablePictureInPicture
aria-hidden
style={HIDDEN_VIDEO}
/>
{pokeUrl && (
<video
key={pokeUrl}
ref={pokeRef}
src={pokeUrl}
muted
playsInline
preload="auto"
disablePictureInPicture
aria-hidden
style={HIDDEN_VIDEO}
/>
)}
</span>
);
}
@@ -0,0 +1,122 @@
{
"idle": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--sleepy": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--happy": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--sad": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--confused": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"idle--error": {
"poke": 2.3,
"crop": [0.14545, 0.11667, 0.72273, 0.74167]
},
"dashboard": {
"poke": 2,
"crop": [0.07273, 0.14583, 0.88182, 0.625]
},
"dashboard--happy": {
"poke": 2,
"crop": [0.07273, 0.14583, 0.88182, 0.625]
},
"transport": {
"poke": 1.5,
"crop": [0.19545, 0.0, 0.64545, 0.75]
},
"transport--sad": {
"poke": 1.5,
"crop": [0.19545, 0.0, 0.64545, 0.75]
},
"transport--happy": {
"poke": 1.5,
"crop": [0.19545, 0.0, 0.64545, 0.75]
},
"bookings": {
"poke": 2.1,
"crop": [0.16364, 0.19167, 0.83636, 0.575]
},
"tours": {
"poke": 2.2,
"crop": [0.08636, 0.11667, 0.81364, 0.65417]
},
"packing": {
"poke": 2.45,
"crop": [0.09091, 0.025, 0.82273, 0.7125]
},
"packing--happy": {
"poke": 2.45,
"crop": [0.09091, 0.025, 0.82273, 0.7125]
},
"tasks": {
"poke": 2,
"crop": [0.13182, 0.1125, 0.85909, 0.80833]
},
"costs": {
"poke": 2.3,
"crop": [0.14091, 0.0, 0.85909, 0.7625]
},
"files": {
"poke": 2.1,
"crop": [0.16364, 0.21667, 0.76818, 0.52917]
},
"notes": {
"poke": 2.6,
"crop": [0.13182, 0.0875, 0.86818, 0.67083]
},
"links": {
"poke": 2.2,
"crop": [0.03636, 0.08333, 0.91364, 0.66667]
},
"polls": {
"poke": 2,
"crop": [0.08636, 0.12917, 0.76364, 0.6125]
},
"chat": {
"poke": 2.2,
"crop": [0.12727, 0.18333, 0.80455, 0.55417]
},
"guide": {
"poke": 2.2,
"crop": [0.10909, 0.1875, 0.85909, 0.55]
},
"journey": {
"poke": 2.2,
"crop": [0.09545, 0.2, 0.86818, 0.57083]
},
"collections": {
"poke": 2.25,
"crop": [0.0, 0.0, 0.95455, 0.75417]
},
"collections--happy": {
"poke": 2.25,
"crop": [0.0, 0.0, 0.95455, 0.75417]
},
"atlas": {
"poke": 2.4,
"crop": [0.12273, 0.20833, 0.70909, 0.52917]
},
"notifications": {
"poke": 2.4,
"crop": [0.00909, 0.05417, 0.86364, 0.6875]
},
"search": {
"poke": 2.2,
"crop": [0.14545, 0.0125, 0.75909, 0.75833]
},
"search--sad": {
"poke": 2.2,
"crop": [0.14545, 0.0125, 0.75909, 0.75833]
}
}

Some files were not shown because too many files have changed in this diff Show More