mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 19:29:48 +02:00
[eric] onboarding: Arc-style cinematic intro motion spec + standalone preview (warm palette)
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
# Cinematic Intro: build-ready motion spec
|
||||
|
||||
A first-launch cold-open for OpenSwarm, modeled on Arc's welcome cinematic but in our warm
|
||||
palette and compressed to respect "quicker is better." Measured from the real Arc Mac Welcome
|
||||
Intro (digigamer capture `5LcrfMiUvmo`): frame-stepped timings + canvas pixel samples, then
|
||||
re-skinned to our tokens. This is the reference an engineer (or a later coding pass) implements
|
||||
directly. No vibe-coding: every duration, easing, and color below is pinned.
|
||||
|
||||
## North star
|
||||
|
||||
The OpenSwarm orb is born on a warm-black field, blooms into a warm ember gradient, becomes our
|
||||
logo, the headline cascades in word by word, then the whole layer dissolves into the first tile
|
||||
screen ("Tap one and watch."). It plays ONCE on first launch, is always skippable, and doubles as
|
||||
cover for the backend + 9router cold boot (it is renderer-only, so it runs while they come up).
|
||||
|
||||
The emotional beat is Arc's. The skin, the timing, and the payoff are ours. Do not copy Arc's blue;
|
||||
that would read as a clone.
|
||||
|
||||
## Hard constraints (read before building)
|
||||
|
||||
- **In-window, not a desktop overlay.** Arc dims the whole macOS desktop with a borderless
|
||||
always-on-top transparent window. We do NOT. On first launch our window already fills the screen,
|
||||
so the cinematic plays inside a warm-black layer in our own renderer. This is the only choice that
|
||||
works identically on the signed DMG and the EXE, and it sidesteps every transparent-window /
|
||||
multi-monitor / packaged-build footgun. (A mac-only true desktop-dim is a possible later flourish,
|
||||
explicitly out of scope for v1.)
|
||||
- **No new WebGL context.** Our existing `PixelBlast` WebGL2 surface is implicated in the GPU-process
|
||||
crash class (context churn). Cold launch, while the backend is still booting, is the worst moment
|
||||
to light up another GL context. The orb and the gradient are CSS (radial-gradients + transform +
|
||||
filter) or, at most, a cheap `<video>` WebM decode. Never a shader here.
|
||||
- **Renderer-only + gated + skippable.** Mounts before/over the dashboard, gated on a one-time
|
||||
`cinematicShown` flag. Any pointerdown / keydown / Esc jumps straight to the handoff. Hard cap the
|
||||
whole thing at ~5.2s so a returning-feeling user is never trapped.
|
||||
- **Reduced motion is a first-class path,** not an afterthought. `useReducedMotion()` (the existing
|
||||
hook) collapses the entire sequence to a single 220ms fade from black to the headline+tiles, with
|
||||
no orb, no scale, no blur. Pair every animated value with this.
|
||||
- **No em/en dashes anywhere in shipped copy or code.** House rule.
|
||||
|
||||
## Master timeline (ours: ~5.2s, skippable)
|
||||
|
||||
Compressed from Arc's ~9s (one-time brand moments can afford 9s; an app cold-open should not), same
|
||||
proportions and feel. `t` is seconds from mount. Phases overlap on purpose.
|
||||
|
||||
| Phase | t (s) | Layer | What happens | Duration | Easing |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 0 Black hold | 0.00 to 0.20 | canvas | Warm-black fills the window. Nothing else. | 200ms | (none) |
|
||||
| 1 Orb birth | 0.20 to 0.60 | orb | Orb fades in dead-center, tiny (scale 0.18), warm glow. | 400ms | `EASE.out` |
|
||||
| 2 Orb bloom | 0.60 to 2.40 | orb | Orb scales 0.18 -> 1.0, glow intensifies, faint breathing. The slow bloom IS the feeling; do not rush it. | 1800ms | `cubic-bezier(0.33, 0, 0.2, 1)` (gentle, slow-in slow-out) |
|
||||
| 3 Bloom to surface | 2.40 to 3.40 | orb -> card | Orb expands past its own edge and dissolves into the warm ember mesh gradient (the "card"). OpenSwarm logo fades in at center as the orb resolves. | 1000ms | `EASE.out` |
|
||||
| 4 Text cascade | 3.20 to 4.60 | text | Logo cross-fades out as the headline reveals word by word (overlaps phase 3 by 200ms so it never feels sequential). | ~1400ms total | per-word `EASE.out` + micro-spring |
|
||||
| 5 Settle + handoff | 4.60 to 5.20 | whole layer | Headline holds ~400ms, then the entire cinematic layer cross-dissolves into the first tile screen. | 600ms (200ms hold + 400ms dissolve) | `EASE.inOut` |
|
||||
|
||||
Skip at any point: cancel running transitions, jump to phase 5's dissolve from wherever you are
|
||||
(never a hard cut; always the 400ms cross-dissolve so a skip still feels designed).
|
||||
|
||||
## Color: warm palette (mapped from Arc's measured blues)
|
||||
|
||||
Arc's measured values on the left (ground truth from canvas sampling), our warm equivalents on the
|
||||
right. All ours are anchored to `claudeTokens` (`accent.*`, `bg.*`) so they stay on-brand; the few
|
||||
extra ember/gold stops are cinematic-only and live in this component, not in the global tokens.
|
||||
|
||||
| Role | Arc (measured) | Ours (warm) | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| Canvas (dimmed field) | near-black `#151203` | warm-black `#0F0D0B` | cinematic-local |
|
||||
| Orb core | `#5354F2` periwinkle | amber `#F0B070` | cinematic-local |
|
||||
| Orb glow | `#6772F8` | terracotta `#C4633A` | `accent.hover` |
|
||||
| Gradient darkest corner | `#2B36C3` indigo | deep ember `#2B1509` | cinematic-local |
|
||||
| Gradient mid | `#4D57FB` | `#AE5630` | `accent.primary` |
|
||||
| Gradient bright bloom | `#575CEE` | `#D47548` | `accent` dark-hover |
|
||||
| Gradient hottest point | (brightest periwinkle) | warm gold `#E0925A` | cinematic-local |
|
||||
| Headline text | `#FFFFFF` | warm white `#FAF9F5` | `text.primary` (dark) |
|
||||
| CTA / arrow glyph | indigo on frost | `#5C2E18` on `rgba(250,249,245,0.9)` | derived |
|
||||
|
||||
The gradient is a **mesh**, not a linear ramp: deep-ember corners with terracotta + amber light
|
||||
blooms drifting through the middle. Build it as 3 to 4 layered `radial-gradient`s (see snippet),
|
||||
slowly drifting via `transform: translate3d`, so it reads as living light, not a flat fill. A 6 to
|
||||
10s ease-in-out loop on the drift is plenty; it only needs to breathe for the ~2s it is on screen.
|
||||
|
||||
## Layer specs
|
||||
|
||||
### Orb (CSS, no WebGL)
|
||||
|
||||
A single `<div>`: a radial-gradient from `#F0B070` (core) through `#C4633A` (glow) to transparent,
|
||||
plus a `filter: blur(...)` for softness and a `box-shadow` bloom. Animate `scale`, `opacity`, and
|
||||
glow `blur` only (all GPU-cheap, compositor-friendly). No layout, no repaint.
|
||||
|
||||
```ts
|
||||
// orb birth + bloom (framer-motion)
|
||||
const orb = {
|
||||
hidden: { scale: 0.18, opacity: 0, filter: 'blur(8px)' },
|
||||
bloom: {
|
||||
scale: 1.0,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
scale: { duration: 1.8, ease: [0.33, 0, 0.2, 1] }, // gentle slow bloom
|
||||
opacity: { duration: 0.4, ease: EASE_OUT },
|
||||
filter: { duration: 1.0, ease: EASE_OUT },
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
A faint "breathing" while it blooms (scale 1.0 <-> 1.03, 1.5s, `EASE.pulse`) adds life; keep it
|
||||
subtle or it looks like a loading spinner.
|
||||
|
||||
### Surface / mesh gradient (CSS)
|
||||
|
||||
```ts
|
||||
// warm ember mesh: layered radial-gradients on a deep-ember base, slow drift
|
||||
const meshBackground = `
|
||||
radial-gradient(60% 70% at 72% 18%, #E0925A 0%, transparent 60%),
|
||||
radial-gradient(70% 80% at 28% 88%, #D47548 0%, transparent 62%),
|
||||
radial-gradient(90% 90% at 50% 50%, #AE5630 0%, transparent 70%),
|
||||
linear-gradient(140deg, #2B1509 0%, #3A1C0E 100%)
|
||||
`;
|
||||
// drift: animate a wrapper's transform translate3d(±2%, ±2%) over 8s, EASE.inOut, alternate
|
||||
```
|
||||
|
||||
The orb in phase 3 grows past its own radius and its glow becomes the brightest bloom of this mesh,
|
||||
so the dissolve from orb to surface is a continuous brightening, not a swap.
|
||||
|
||||
### Headline text cascade (the signature)
|
||||
|
||||
Arc's signature is a per-word reveal with a **horizontal motion-blur streak** that resolves to
|
||||
sharp. Plain CSS `filter: blur()` is isotropic (blurs all directions equally), which is the cheap,
|
||||
acceptable fallback. For the authentic directional streak, drive an SVG filter whose
|
||||
`feGaussianBlur stdDeviation` animates from `"14 0"` (x-only blur) to `"0 0"`. Decide per the
|
||||
fidelity bar; spec both.
|
||||
|
||||
Split the headline into words (not characters; Arc reveals word-by-word, and per-char is too busy
|
||||
for our shorter line). Each word is a motion child:
|
||||
|
||||
```ts
|
||||
const line = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.09, delayChildren: 3.2 } },
|
||||
};
|
||||
const word = {
|
||||
hidden: { opacity: 0, y: 10, x: -6, filter: 'blur(12px)' },
|
||||
show: {
|
||||
opacity: 1, y: 0, x: 0, filter: 'blur(0px)',
|
||||
transition: {
|
||||
duration: 0.42,
|
||||
ease: EASE_OUT,
|
||||
y: { type: 'spring', stiffness: 320, damping: 26 }, // micro-spring on settle
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- **Stagger:** 90ms per word (`staggerChildren: 0.09`).
|
||||
- **Per-word duration:** 420ms.
|
||||
- **Transform:** rise `y: 10 -> 0`, slight left-drift `x: -6 -> 0` (the horizontal motion), `blur 12 -> 0`, `opacity 0 -> 1`.
|
||||
- **Font:** `font.sans` token ("Anthropic Sans"), weight 700, tight tracking (-0.01em to -0.02em),
|
||||
two centered lines. Color `#FAF9F5`. Generous line-height (1.05) like Arc's display lockup.
|
||||
|
||||
**Copy (proposed, product to confirm):** headline `Meet your AI team.` (two lines:
|
||||
"Meet your" / "AI team."), which sets up the tile screen's "Tap one and watch." Mark as placeholder;
|
||||
copy is a product call, not a motion call.
|
||||
|
||||
### Handoff to tiles (phase 5)
|
||||
|
||||
The cinematic layer is a single absolutely-positioned overlay above the dashboard/welcome screen.
|
||||
On complete (natural or skip): animate the overlay `opacity 1 -> 0` over 400ms `EASE.inOut` while
|
||||
the tile screen underneath is already mounted at `opacity 0 -> 1`. The orb/headline do not animate
|
||||
out individually; the whole layer cross-dissolves as one. Net effect: the headline melts and the
|
||||
tiles are already there.
|
||||
|
||||
## Integration points
|
||||
|
||||
- **New component** `cinematicIntro/CinematicIntro.tsx` (this folder). Props:
|
||||
`{ onComplete: () => void }`. Self-contained: owns its own timeline + skip handling, calls
|
||||
`onComplete` once.
|
||||
- **Mount** from `OnboardingRoot` (or `Main`) on first launch only, rendered as a fixed full-window
|
||||
overlay above everything, before the welcome-draft/tile screen takes focus.
|
||||
- **One-time flag** `cinematicShown: boolean` in `onboardingProgressSlice` (localStorage-persisted,
|
||||
same store as `welcomeShown`). Gate mount on `!cinematicShown`; set true on `onComplete`. A user
|
||||
who has seen it never sees it again (and returning users never do).
|
||||
- **Skip:** a window-level `pointerdown` + `keydown` (Esc / any key) listener installed on mount,
|
||||
removed on complete. Fires the same `onComplete` path (which runs the 400ms dissolve, never a cut).
|
||||
- **onComplete** hands off to the existing welcome surface (the tile screen / welcome-draft). The
|
||||
cinematic does NOT spawn agents or hit the network; it is pure choreography. The first real action
|
||||
is still the user's tile tap, downstream of this.
|
||||
|
||||
## Performance, packaging, accessibility
|
||||
|
||||
- **Cold-boot cover:** the cinematic is renderer-only and starts at first paint, so it plays during
|
||||
the ~2 to 4s backend (`:8324`) + 9router spin-up. This replaces a startup spinner with brand. Do
|
||||
not block the cinematic on any backend readiness; they are independent.
|
||||
- **GPU:** CSS transforms/filters + opacity only; all compositor-friendly. No WebGL, no layout
|
||||
thrash. If a `<video>` WebM is ever introduced for the gradient, it is a decode (cheap), still not
|
||||
a GL context. Default recommendation for v1: pure CSS, zero video assets, crisp at any DPI.
|
||||
- **Packaged build:** because it is in-window web tech with no transparent overlay and no native
|
||||
bits, one implementation runs identically on the DMG and the EXE. Still smoke it on a packaged
|
||||
build per house rule, but there is no dev-vs-prod path divergence by construction.
|
||||
- **Reduced motion** (`useReducedMotion()` true): skip phases 0 to 4 entirely. Render the warm
|
||||
gradient + headline + tiles immediately, fade the layer in over 220ms (`DURATION_MS.standard`),
|
||||
then the same handoff. No orb, no scale, no blur, no per-word stagger.
|
||||
- **Tokens:** reuse `DURATION_MS` / `EASE` from `motionTokens.ts` (`EASE_OUT = EASE.out`,
|
||||
`EASE.inOut`, `EASE.pulse`). The two custom values this spec adds are the gentle bloom curve
|
||||
`cubic-bezier(0.33, 0, 0.2, 1)` and the spring `{ stiffness: 320, damping: 26 }`; keep them local
|
||||
to this component unless they prove reusable.
|
||||
|
||||
## What is measured vs proposed
|
||||
|
||||
- **Measured from Arc (ground truth):** the 5-phase structure, the slow-orb-then-bloom feel, the
|
||||
per-word horizontal-blur cascade, the mesh (not linear) gradient, and the source blue color values
|
||||
used for the warm re-map.
|
||||
- **Proposed (our calls, change freely):** the ~5.2s compressed durations, the exact warm hex stops,
|
||||
the headline copy, word-level (not char-level) split, and the in-window (not desktop-dim) decision.
|
||||
|
||||
## Open questions for product
|
||||
|
||||
1. Headline copy + whether it is one line or two.
|
||||
2. ~5.2s vs even tighter (~4s) for the cold-open. (Recommendation: 5.2s once, skippable.)
|
||||
3. Directional SVG-blur streak (authentic, slightly more code) vs isotropic CSS blur (cheaper).
|
||||
4. Does the orb resolve into the OpenSwarm wordmark, the glyph only, or straight to the headline?
|
||||
@@ -0,0 +1,209 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenSwarm cinematic intro (motion preview)</title>
|
||||
<!--
|
||||
Standalone motion preview for the cinematic first-launch intro.
|
||||
Faithful to cinematicIntro/MOTION_SPEC.md: exact warm colors, phase timings, easings.
|
||||
This is a design reference (pure HTML/CSS/JS, zero deps), NOT the production component.
|
||||
Real font is "Anthropic Sans"; this preview falls back to system sans.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--warm-black: #0F0D0B;
|
||||
--warm-white: #FAF9F5;
|
||||
--orb-core: #F0B070;
|
||||
--orb-glow: #C4633A;
|
||||
--accent: #AE5630;
|
||||
--accent-hover: #C4633A;
|
||||
--ember-deep: #2B1509;
|
||||
--ember-mid: #3A1C0E;
|
||||
--bloom: #D47548;
|
||||
--gold: #E0925A;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-bloom: cubic-bezier(0.33, 0, 0.2, 1);
|
||||
--ease-inout: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; background: var(--warm-black); overflow: hidden; }
|
||||
body {
|
||||
font-family: "Anthropic Sans", -apple-system, "Segoe UI", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
#stage { position: fixed; inset: 0; background: var(--warm-black); }
|
||||
|
||||
/* Cinematic layer */
|
||||
#cinematic { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
#mesh {
|
||||
position: absolute; inset: 0; opacity: 0;
|
||||
background:
|
||||
radial-gradient(60% 70% at 72% 18%, var(--gold) 0%, transparent 60%),
|
||||
radial-gradient(70% 80% at 28% 88%, var(--bloom) 0%, transparent 62%),
|
||||
radial-gradient(90% 90% at 50% 50%, var(--accent) 0%, transparent 70%),
|
||||
linear-gradient(140deg, var(--ember-deep) 0%, var(--ember-mid) 100%);
|
||||
animation: drift 8s var(--ease-inout) infinite alternate;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { transform: translate3d(-1.5%, -1.5%, 0) scale(1.06); }
|
||||
100% { transform: translate3d(1.5%, 1.5%, 0) scale(1.06); }
|
||||
}
|
||||
|
||||
#orb {
|
||||
position: absolute; width: 42vmin; height: 42vmin; border-radius: 50%;
|
||||
opacity: 0; transform: scale(0.18);
|
||||
background: radial-gradient(circle at 50% 46%,
|
||||
var(--orb-core) 0%, var(--orb-glow) 42%, rgba(196,99,58,0.25) 66%, transparent 78%);
|
||||
filter: blur(6px);
|
||||
box-shadow: 0 0 18vmin 4vmin rgba(224,146,90,0.28);
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
#headline {
|
||||
position: relative; z-index: 2; text-align: center; color: var(--warm-white);
|
||||
font-weight: 700; font-size: 6.4vmin; line-height: 1.05; letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 40px rgba(0,0,0,0.25);
|
||||
}
|
||||
#headline .line { display: block; }
|
||||
#headline .w { display: inline-block; opacity: 0; filter: blur(12px); transform: translate(-6px, 10px); will-change: transform, opacity, filter; }
|
||||
|
||||
/* Tile handoff screen */
|
||||
#tiles {
|
||||
position: absolute; inset: 0; opacity: 0; pointer-events: none;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
background: var(--warm-black); color: var(--warm-white); gap: 3.2vmin;
|
||||
}
|
||||
#tiles h2 { font-weight: 600; font-size: 3.4vmin; letter-spacing: -0.01em; opacity: 0.96; }
|
||||
#tileGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.6vmin; width: min(760px, 74vmin); }
|
||||
.tile {
|
||||
display: flex; align-items: center; gap: 1.4vmin; padding: 2.4vmin 2.6vmin;
|
||||
border-radius: 14px; cursor: pointer; font-size: 2.2vmin; font-weight: 500;
|
||||
background: rgba(250,249,245,0.045);
|
||||
border: 1px solid rgba(250,249,245,0.09);
|
||||
transition: background 160ms var(--ease-out), border-color 160ms var(--ease-out), transform 160ms var(--ease-out);
|
||||
}
|
||||
.tile .ic { font-size: 2.8vmin; }
|
||||
.tile:hover {
|
||||
background: rgba(196,99,58,0.14);
|
||||
border-color: rgba(196,99,58,0.55);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
#surprise { font-size: 2vmin; color: rgba(250,249,245,0.55); cursor: pointer; transition: color 160ms var(--ease-out); }
|
||||
#surprise:hover { color: var(--warm-white); }
|
||||
|
||||
/* Preview chrome (not part of the product) */
|
||||
#controls { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); z-index: 10; display: flex; gap: 10px; }
|
||||
#controls button {
|
||||
font: inherit; font-size: 13px; color: var(--warm-white); background: rgba(250,249,245,0.08);
|
||||
border: 1px solid rgba(250,249,245,0.18); border-radius: 999px; padding: 7px 16px; cursor: pointer;
|
||||
}
|
||||
#controls button:hover { background: rgba(250,249,245,0.16); }
|
||||
#hint { position: fixed; top: 14px; left: 50%; transform: translateX(-50%); z-index: 10; color: rgba(250,249,245,0.4); font-size: 12px; letter-spacing: 0.02em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="cinematic">
|
||||
<div id="mesh"></div>
|
||||
<div id="orb"></div>
|
||||
<h1 id="headline">
|
||||
<span class="line"><span class="w">Meet</span> <span class="w">your</span></span>
|
||||
<span class="line"><span class="w">AI</span> <span class="w">team.</span></span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div id="tiles">
|
||||
<h2>Tap one and watch.</h2>
|
||||
<div id="tileGrid">
|
||||
<div class="tile"><span class="ic">🔎</span> Find something</div>
|
||||
<div class="tile"><span class="ic">🧩</span> Build something</div>
|
||||
<div class="tile"><span class="ic">🗂️</span> Handle busywork</div>
|
||||
<div class="tile"><span class="ic">🌐</span> Do something online</div>
|
||||
</div>
|
||||
<div id="surprise">✨ surprise me</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="hint">click or press Esc to skip</div>
|
||||
<div id="controls"><button id="replay">Replay</button></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const T = 5200; // master duration (ms), per spec
|
||||
const orb = document.getElementById('orb');
|
||||
const mesh = document.getElementById('mesh');
|
||||
const words = [...document.querySelectorAll('#headline .w')];
|
||||
const cinematic = document.getElementById('cinematic');
|
||||
const tiles = document.getElementById('tiles');
|
||||
let anims = [];
|
||||
|
||||
const track = (a) => { anims.push(a); return a; };
|
||||
|
||||
function reset() {
|
||||
anims.forEach(a => { try { a.cancel(); } catch (e) {} });
|
||||
anims = [];
|
||||
tiles.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
function play() {
|
||||
reset();
|
||||
|
||||
// Orb: birth (200ms hold) -> gentle bloom (to 2400) -> dissolve into mesh (to 3400)
|
||||
track(orb.animate([
|
||||
{ offset: 0, opacity: 0, transform: 'scale(0.18)', filter: 'blur(6px)', easing: 'var(--ease-out)' },
|
||||
{ offset: 0.038, opacity: 0, transform: 'scale(0.18)', filter: 'blur(6px)', easing: 'cubic-bezier(0.33,0,0.2,1)' },
|
||||
{ offset: 0.115, opacity: 1, transform: 'scale(0.30)', filter: 'blur(4px)', easing: 'cubic-bezier(0.33,0,0.2,1)' },
|
||||
{ offset: 0.462, opacity: 1, transform: 'scale(1)', filter: 'blur(2px)', easing: 'ease-out' },
|
||||
{ offset: 0.654, opacity: 0.32, transform: 'scale(2.4)', filter: 'blur(9px)', easing: 'ease-out' },
|
||||
{ offset: 1, opacity: 0, transform: 'scale(2.7)', filter: 'blur(11px)' },
|
||||
], { duration: T, fill: 'both' }));
|
||||
|
||||
// Mesh: fades in as the orb blooms past its edge (2400 -> 3400)
|
||||
track(mesh.animate([
|
||||
{ offset: 0, opacity: 0 },
|
||||
{ offset: 0.40, opacity: 0, easing: 'cubic-bezier(0.16,1,0.3,1)' },
|
||||
{ offset: 0.654, opacity: 1 },
|
||||
{ offset: 1, opacity: 1 },
|
||||
], { duration: T, fill: 'both' }));
|
||||
|
||||
// Headline: per-word cascade, stagger 90ms, starts at 3200ms, micro-spring settle
|
||||
words.forEach((w, i) => {
|
||||
track(w.animate([
|
||||
{ opacity: 0, transform: 'translate(-6px, 10px)', filter: 'blur(12px)' },
|
||||
{ opacity: 1, transform: 'translate(0, 0)', filter: 'blur(0px)' },
|
||||
], { delay: 3200 + i * 90, duration: 480, easing: 'cubic-bezier(0.34,1.56,0.64,1)', fill: 'both' }));
|
||||
});
|
||||
|
||||
// Handoff: hold ~200ms then cross-dissolve the whole layer into the tiles (4800 -> 5200)
|
||||
track(cinematic.animate(
|
||||
[{ opacity: 1 }, { opacity: 0 }],
|
||||
{ delay: 4800, duration: 400, easing: 'cubic-bezier(0.4,0,0.2,1)', fill: 'both' }));
|
||||
const reveal = track(tiles.animate(
|
||||
[{ opacity: 0 }, { opacity: 1 }],
|
||||
{ delay: 4800, duration: 400, easing: 'cubic-bezier(0.4,0,0.2,1)', fill: 'both' }));
|
||||
reveal.onfinish = () => { tiles.style.pointerEvents = 'auto'; };
|
||||
}
|
||||
|
||||
// Skip: jump straight to the tiles with a quick dissolve (never a hard cut)
|
||||
function skip() {
|
||||
reset();
|
||||
track(cinematic.animate([{ opacity: getComputedStyle(cinematic).opacity }, { opacity: 0 }],
|
||||
{ duration: 260, easing: 'cubic-bezier(0.4,0,0.2,1)', fill: 'both' }));
|
||||
const r = track(tiles.animate([{ opacity: getComputedStyle(tiles).opacity }, { opacity: 1 }],
|
||||
{ duration: 260, easing: 'cubic-bezier(0.4,0,0.2,1)', fill: 'both' }));
|
||||
r.onfinish = () => { tiles.style.pointerEvents = 'auto'; };
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') skip(); });
|
||||
document.getElementById('cinematic').addEventListener('click', skip);
|
||||
document.getElementById('replay').addEventListener('click', play);
|
||||
|
||||
play();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user