motionsites中文站 / Future 3D Portfolio
Future 3D Portfolio
aadmin2026-09-19 发布Site模板1 阅读

Future 3D Portfolio

类型:Site模板 · 分类:未分类 · 收录:2026-09-19 14:02:05 · 源码:会员可见

[hidecontent type="vip1"]
Build a single self-contained HTML file (one index.html, all CSS in one <style> in <head>, all JS in inline <script> tags, no build step, no frameworks, no external CSS/JS libraries) that reproduces the following full-viewport hero section EXACTLY. Every number below is literal — do not round, re-derive, or "improve" any value.

═══════════════════════════════════════════════════
1. CONCEPT
═══════════════════════════════════════════════════
A full-bleed, non-scrolling hero: a dreamy pastel sky (looping video) behind a giant
semi-transparent scrolling wordmark ticker, with a 3D holographic desk-projector render
sitting on the floor of the frame, centered serif title copy above it, and a vertical
nav rail bottom-left. One-shot entrance animation on load.

═══════════════════════════════════════════════════
2. EXACT REMOTE ASSETS (use these URLs verbatim — do NOT download, do NOT use local files)
═══════════════════════════════════════════════════
SKY VIDEO (10.04s, 1920x1080, silent, ~7MB):
https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260912_111243_26a0a351-0bff-4c24-9872-14c77a2726f6.mp4

SKY STILL (the video's exact first frame; used as the CSS fallback layer under the video):
https://d2ol7oe51mr4n9.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/b85a9c31-6e42-483c-b333-be20054ad692.jpg

3D PROJECTOR RENDER (transparent PNG, 1672x941):
https://soft-zoom-63098134.figma.site/_assets/v11/83aecec75c5d8511dbb24e3a6369d7380e3f2278.png

═══════════════════════════════════════════════════
3. FONTS
═══════════════════════════════════════════════════
Load from Google Fonts:
  Playfair Display — 400 normal + 400 italic
  DM Sans — 400 + 500
  Poppins — 400
Alias them in CSS so the rules below read cleanly:
  'PlayD' = Playfair Display (serif fallback)
  'DMS'   = DM Sans (sans-serif fallback)
  'Popp'  = Poppins (sans-serif fallback)
Use font-display:block so no fallback-font flash occurs (the marquee width calibration
depends on real Poppins metrics).

═══════════════════════════════════════════════════
4. THE SCALING SYSTEM — build this FIRST, everything depends on it
═══════════════════════════════════════════════════
The design is authored at exactly 1280x720. At that size 1 design unit == 1 CSS pixel and
the render must be pixel-identical to the reference. Everywhere else it scales.

Three custom properties, all declared on :root:
  --vph : one viewport-height token. Default 100vh.
          Then: @supports (height: 100dvh){ :root{ --vph: 100dvh } }
          CRITICAL: do NOT write a "vh then dvh" declaration pair for anything containing
          var(). var() defers validation past the cascade, so the broken declaration wins
          and the property falls back to its initial value. Use the @supports upgrade only.
  --u : the layout unit  = min(calc(100vw / 1280), calc(var(--vph) / 720))
  --t : the type unit    = var(--u)   (gets a floor on small screens, see §9)
  --m : the marquee unit = var(--u)   (gets multiplied on portrait, see §9)

Every single dimension in this file is written as calc(N * var(--u|--t|--m)). Never use
raw px except where explicitly stated.

Base reset:
  html,body{height:100%;overflow:hidden;background:#2f6ff0}
  body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
       text-rendering:geometricPrecision}
  .stage{position:fixed;inset:0;overflow:hidden;isolation:isolate}

═══════════════════════════════════════════════════
5. DOM ORDER (inside .stage — this exact order = the z-stack)
═══════════════════════════════════════════════════
  .sky              > video#sky-video
  .marquee          > .marquee-track > .marquee-run > span + span
  .scene-assembly   > .skirt + img.scene
  header.intro      > p.hi + h1.name + p.role
  nav.nav           > a.home + a.n1 + a.n2 + a.n3 + .mode

═══════════════════════════════════════════════════
6. LAYER 1 — SKY (video over still)
═══════════════════════════════════════════════════
.sky{position:absolute;inset:0;overflow:hidden;
  background:url(<SKY STILL URL>) center center/cover no-repeat}
.sky video{position:absolute;inset:0;display:block;
  width:100%;height:100%;object-fit:cover;object-position:center center;
  pointer-events:none;opacity:0;transition:opacity .45s linear}
.sky video.is-playing{opacity:1}

Markup:
<video id="sky-video" aria-hidden="true" autoplay muted loop playsinline
       preload="auto" disablepictureinpicture tabindex="-1" src="<SKY VIDEO URL>"></video>

WHY the still stays: the video's first frame IS that still, so the fade only masks the
decoder handing over — never reads as a cross-dissolve. It also means a slow CDN, a
blocked autoplay, a failed fetch, or reduced-motion all degrade to the original static
hero instead of an empty blue box.

VIDEO JS (important, non-obvious — implement exactly):
  - If prefers-reduced-motion: reduce → removeAttribute('autoplay'), pause(),
    removeAttribute('src'), load(). The still shows. Do nothing else.
  - Otherwise:
    * reveal = () => classList.add('is-playing'); call it if readyState>=3, and bind to
      the 'playing' event. On 'error', remove the class (fall back to the still).
    * CUSTOM LOOP — do not rely on the loop attribute. Set sky.loop = false in JS.
      Measured on this exact clip: it opens on the still, then the colour grade warms
      over the first ~2s and holds flat from ~3s to the end (frame-mean blue channel
      229 → 215 → 210). A plain 10s→0 wrap therefore steps the sky ~20/255 bluer in a
      single frame, once every loop — a visible pulse.
      So: play the opening settle ONCE, then loop from LOOP_IN = 3 seconds onward, where
      the grade already matches the last frame (delta drops to ~3/255, imperceptible).
      rewind = () => { try{ sky.currentTime = 3 }catch(e){}; if(sky.paused) sky.play()... }
      Bind rewind to 'ended', AND to 'timeupdate' with the guard
        if (sky.duration && sky.currentTime >= sky.duration - 0.25) rewind();
      (timeupdate only fires ~4x/s, so the 0.25s margin prevents the last frame being
      held; 'ended' is the safety net.)
      Keep the loop attribute in the HTML as the no-JS fallback.
    * Autoplay rejection is EXPECTED on Safari/mobile until the tab is touched. Catch the
      play() promise rejection and retry once on the first 'pointerdown' or 'touchstart'
      ({once:true}), then reveal. Do not log it as an error.

═══════════════════════════════════════════════════
7. LAYER 2 — INFINITE MARQUEE TICKER (the trickiest part; read carefully)
═══════════════════════════════════════════════════
A colossal semi-transparent wordmark band scrolling right-to-left, forever, seamlessly.

Text unit (note the MIDDLE DOT · U+00B7, not a period or bullet):
  "BRANDING·PRODUCT·UI/UX·"
Each <span> contains that unit repeated EXACTLY 5 times (no spaces, no separators).
There are EXACTLY 2 such identical spans, adjacent with no whitespace between them.

.marquee{position:absolute;
  left:calc(50% + -2521.0 * var(--m));
  bottom:calc(200 * var(--m));
  height:calc(420 * var(--m));
  width:calc(6200 * var(--m));
  overflow:hidden;pointer-events:none;
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 14%,
    rgba(0,0,0,.97) 50%,rgba(0,0,0,.82) 64%,rgba(0,0,0,.71) 74%,
    rgba(0,0,0,.52) 85%,rgba(0,0,0,.45) 100%);
  mask-image:linear-gradient(to bottom,#000 0%,#000 14%,
    rgba(0,0,0,.97) 50%,rgba(0,0,0,.82) 64%,rgba(0,0,0,.71) 74%,
    rgba(0,0,0,.52) 85%,rgba(0,0,0,.45) 100%)}

.marquee-track{position:absolute;left:0;top:calc(176 * var(--m));
  display:flex;align-items:flex-start;
  transform:scaleX(0.55151);transform-origin:0 0}

.marquee-run{display:flex;will-change:transform;
  animation:marquee-slide var(--marquee-dur, 82s) linear infinite}

.marquee-run span{white-space:pre;font-family:'Popp',sans-serif;font-weight:400;
  font-size:calc(334.73 * var(--m));
  letter-spacing:calc(-10.93 * var(--m));
  line-height:0;color:rgba(255,255,255,0.5)}

@keyframes marquee-slide{
  from{transform:translate3d(0,0,0)}
  to{transform:translate3d(-50%,0,0)}}

THE CRITICAL CONSTRAINT — do not restructure this:
The 0.55151 horizontal squeeze MUST live on .marquee-track, NOT on the spans. A transform
on the spans shrinks their PAINT but not their LAYOUT width, so the flex row would reserve
the full unsqueezed run and tear open a visible gap mid-loop. Scaling the track keeps
layout and paint in step, which is what makes translateX(-50%) across two identical runs
mathematically exact: -50% is precisely one run's width at every breakpoint.

Calibration facts (verify against these): at 1280x720 one run measures 7771 visual px
against a 6200px window — 1.25x headroom. Because both scale with --m, that ratio holds at
every breakpoint. At 82s the travel is ~95px/s. At t=82000ms the runs sit at exactly the
offsets they held at t=0, so the wrap is pixel-identical and seamless.

═══════════════════════════════════════════════════
8. LAYERS 5–7 — SCENE, COPY, NAV
═══════════════════════════════════════════════════
.scene-assembly{position:absolute;inset:0;pointer-events:none;transform-origin:50% 100%}
  (The render and its plinth extension share ONE assembly so the entrance animation can
   never reveal the two pieces at different times.)

.skirt — the supplied render is cut flat at its own bottom edge, but the plinth must run
off the frame, so its last pixel row is stretched to the floor. Reproduce that row with
this gradient (sampled from the original 1326x1 strip, fully opaque throughout):
.skirt{position:absolute;bottom:0;
  height:calc(13.0 * var(--u));
  left:calc(50% + -438.5 * var(--u));
  width:calc(885.1 * var(--u));
  background:linear-gradient(to right,
    #e9d4ea 0%, #d5bde1 1.1%, #cbb5e1 2.5%, #cbb5e1 85%,
    #ab9cd8 86.4%, #9489d6 88.2%, #a08cdc 89.4%, #a690da 100%);
  pointer-events:none}

.scene{position:absolute;
  left:50%;margin-left:calc(-586.0 * var(--u));
  bottom:calc(10.0 * var(--u));
  width:calc(1116.0 * var(--u));height:auto;
  user-select:none;-webkit-user-drag:none}
  alt="A holographic desk projector showing a design workspace"

.intro{position:absolute;top:0;left:0;width:100%;pointer-events:none}
.intro p,.intro h1{position:absolute;left:50%;color:#fff}

.hi — text "Hi, I’m" (curly apostrophe ’)
.hi{white-space:nowrap;font-family:'PlayD',serif;font-weight:400;font-style:italic;
  font-size:calc(28.5 * var(--t));line-height:1;
  letter-spacing:calc(-1.0 * var(--t));
  top:calc(15.0 * var(--t));
  transform:translateX(calc(-50% + 1.0 * var(--t)))}

.name — <h1> text "Ayush"
.name{white-space:nowrap;font-family:'PlayD',serif;font-weight:400;
  font-size:calc(75.31 * var(--t));line-height:1;
  letter-spacing:calc(7.75 * var(--t));
  top:calc(33.1 * var(--t));
  transform:translateX(calc(-50% + 8.1 * var(--t)))}

.role — two <span>s: "A Product Designer experienced in building products," and
        "with a problem solving and creative mindset."
.role span{display:block}
.role{white-space:nowrap;font-family:'DMS',sans-serif;font-weight:400;
  font-size:calc(14.5 * var(--t));
  line-height:calc(15.0 * var(--t));
  letter-spacing:calc(0.0 * var(--t));
  text-align:center;
  top:calc(116.5 * var(--t));
  transform:translateX(calc(-50% + 7.5 * var(--t)))}

.nav{position:absolute;left:calc(38.0 * var(--u));
  top:calc(532.0 * var(--t));
  font-family:'DMS',sans-serif;font-weight:400;
  font-size:calc(15.0 * var(--t));
  letter-spacing:calc(0.75 * var(--t));line-height:1;
  height:calc(170 * var(--t))}
.nav a{position:absolute;left:0;white-space:nowrap;color:rgba(255,255,255,.82);
  text-decoration:none}
.nav a:focus-visible{color:#fff}
.nav a:focus-visible{outline:calc(2 * var(--u)) solid #fff;
  outline-offset:calc(3 * var(--u));border-radius:calc(2 * var(--u))}
.nav .home{font-family:'PlayD',serif;font-weight:400;color:#fff;
  font-size:calc(19.13 * var(--t));letter-spacing:0;top:calc(-4.0 * var(--t))}
.nav .n1{top:calc(33.5 * var(--t))}
.nav .n2{top:calc(67.0 * var(--t))}
.nav .n3{top:calc(100.5 * var(--t))}
Links: "Home" (href="#", aria-current="page", class home), "Works" (#works, n1),
       "About me" (#about, n2), "Resume" (#resume, n3).

.mode — the label text is literally two lines: "InteractivenMode" using white-space:pre-line
.mode{position:absolute;left:0;top:calc(134.0 * var(--t));
  display:flex;align-items:center;gap:calc(8.0 * var(--t));
  font-family:'DMS',sans-serif;font-weight:400;
  font-size:calc(15.0 * var(--t));
  letter-spacing:calc(0.75 * var(--t));
  line-height:calc(18.0 * var(--t));color:rgba(255,255,255,.82)}
.mode .label{white-space:pre-line;position:relative;top:calc(-3.0 * var(--t))}

Toggle switch — <button class="switch" id="switch" role="switch" aria-checked="true"
aria-label="Interactive mode"> containing <span class="on">ON</span><span class="off">OFF</span><span class="knob"></span>
.switch{position:relative;flex:none;border:0;cursor:pointer;
  width:calc(65.0 * var(--t));height:calc(30.0 * var(--t));
  border-radius:calc(15.0 * var(--t));
  background:linear-gradient(180deg,#6f58b3 0%,#7f66ce 55%,#8f6fd2 100%);
  box-shadow:inset 0 calc(-1 * var(--t)) calc(2 * var(--t)) rgba(255,255,255,.22);
  font:inherit;padding:0;-webkit-tap-highlight-color:transparent}
.switch:focus-visible{outline:calc(2 * var(--u)) solid #fff;outline-offset:calc(3 * var(--u))}
.switch .on{position:absolute;left:calc(12.0 * var(--t));top:50%;
  transform:translateY(-50%);color:#fff;font-family:'DMS',sans-serif;font-weight:500;
  font-size:calc(14 * var(--t));letter-spacing:calc(.3 * var(--t));line-height:1;
  transition:opacity .18s ease}
.switch .knob{position:absolute;top:calc(5 * var(--t));left:calc(5 * var(--t));
  width:calc(20.0 * var(--t));height:calc(20.0 * var(--t));
  border-radius:50%;background:#fff;
  box-shadow:0 calc(1 * var(--t)) calc(3 * var(--t)) rgba(60,40,120,.4);
  transform:translateX(calc(35.0 * var(--t)));
  transition:transform .26s cubic-bezier(.4,1.4,.5,1)}
.switch[aria-checked="false"] .knob{transform:translateX(0)}
.switch[aria-checked="false"] .on{opacity:0}
.switch[aria-checked="false"]{background:linear-gradient(150deg,#a99ad7 0%,#8f7fc4 100%)}
.switch .off{position:absolute;right:calc(15 * var(--t));top:50%;
  transform:translateY(-50%);color:#fff;font-family:'DMS',sans-serif;font-weight:500;
  font-size:calc(14 * var(--t));line-height:1;opacity:0;transition:opacity .18s ease}
.switch[aria-checked="false"] .off{opacity:.9}
JS: on click, flip aria-checked between "true" and "false". That attribute alone drives
all visual state — no extra classes.

═══════════════════════════════════════════════════
9. RESPONSIVE — 5 breakpoints, in this exact source order
═══════════════════════════════════════════════════
The composition holds its proportions; only the ANCHORS change, so nothing is ever
letterboxed. Each block re-declares the units then re-anchors a few elements.

(A) TABLET / SQUARISH — the desktop unit is width-driven on anything squarer than 16:9,
    which opens vertical slack between title and scene. Handover at aspect 1.46 (below
    3:2 laptops, above 11" tablet landscape at 1.43), or 1080px wide for narrow landscape
    windows that keep a wide aspect but starve the type.
@media (min-aspect-ratio: 19/20) and (max-aspect-ratio: 73/50),
       (min-aspect-ratio: 19/20) and (max-width: 1080px){
  :root{
    --u: min(calc(100vw / 1010), calc(var(--vph) / 900));
    --t: max(min(max(var(--u), calc(100vw / 1140)), calc(var(--u) * 1.3)), 0.85px);
    --m: var(--u);
  }
  .intro{top:max(calc(var(--vph) * 0.04),
                 calc(var(--vph) - 638 * var(--u) - 200 * var(--t)))}
  .nav{top:auto;bottom:calc(80 * var(--u) + calc(var(--vph) * 0.025))}
}
  (Anchor the copy to the SCENE, not the viewport top — --u is width-capped here, so a
   fixed top margin strands the title on tall frames. 638*--u is scene height + bottom
   offset; 200*--t is the copy block + gap. The max() keeps the plain top margin on frames
   too short for the scene-relative value to win.)

(B) PORTRAIT TABLETS — scene bleeds past both edges and sits on the floor; copy and nav
    stack above it in the sky.
@media (max-aspect-ratio: 19/20) and (min-width: 700px){
  :root{
    --u: min(calc(100vw / 1020), calc(var(--vph) / 1240));
    --t: min(max(var(--u), calc(100vw / 700)), calc(var(--u) * 1.35));
    --m: calc(var(--u) * 1.5);
  }
  .intro{top:calc(var(--vph) * 0.05)}
  .hi{top:calc(7.0 * var(--t))}
  .name{top:calc(25.1 * var(--t))}
  .role{top:calc(110.5 * var(--t));white-space:normal;
    line-height:calc(18.0 * var(--t));
    width:min(calc(100vw - 96 * var(--t)), calc(520 * var(--t)))}
  .role span{display:inline}
  .scene{bottom:0;margin-left:calc(-560 * var(--u))}
  .nav{top:auto;left:calc(34 * var(--t));
    bottom:calc(628 * var(--u) + calc(var(--vph) * 0.04))}
  .marquee{bottom:calc(calc(var(--vph) * 0.52) - 160 * var(--m));left:calc(50% - 1290 * var(--m))}
}

(C) PHONES PORTRAIT — same composition, tighter units.
@media (max-aspect-ratio: 19/20) and (max-width: 699px){
  :root{
    --u: min(calc(100vw / 980), calc(var(--vph) / 1150));
    --t: clamp(0.83px, min(calc(100vw / 430), calc(var(--vph) / 640)), 0.93px);
    --m: calc(var(--u) * 2.1);
  }
  .intro{top:calc(var(--vph) * 0.04)}
  .hi{top:calc(7.0 * var(--t))}
  .name{top:calc(25.1 * var(--t))}
  .role{top:calc(110.5 * var(--t));white-space:normal;
    line-height:calc(18.0 * var(--t));
    width:min(calc(100vw - 40 * var(--t)), calc(420 * var(--t)))}
  .role span{display:inline}
  .scene{bottom:0;margin-left:calc(-560 * var(--u))}
  .nav{top:auto;left:calc(22 * var(--t));
    bottom:calc(630 * var(--u) + calc(var(--vph) * 0.03))}
  .marquee{bottom:calc(calc(var(--vph) * 0.56) - 160 * var(--m));left:calc(50% - 1290 * var(--m))}
}

(D) LANDSCAPE PHONES — a wide-but-short frame gets a TWO-COLUMN split: copy and nav in a
    left column, scene in the right, so they never compete for the same vertical band.
    Keyed on HEIGHT, because height is what runs out.
@media (min-aspect-ratio: 19/20) and (max-height: 380px){
  :root{
    --u: min(calc(100vw / 1924), calc(var(--vph) / 1013));
    --t: clamp(0.83px, min(calc(100vw / 620), calc(var(--vph) / 420)), 0.95px);
    --m: var(--u);
  }
  .intro{top:calc(var(--vph) * .05);width:44vw}
  .role{white-space:normal;line-height:calc(17 * var(--t));
    width:min(40vw, calc(300 * var(--t)))}
  .role span{display:inline}
  .scene{left:auto;right:calc(-30 * var(--u));margin-left:0;bottom:0}
  .nav{top:auto;left:calc(34 * var(--t));bottom:calc(var(--vph) * .04);
    height:calc(140 * var(--t))}
  .nav .home{top:calc(-5.5 * var(--t))}
  .nav .n1{top:calc(26 * var(--t))}
  .nav .n2{top:calc(52 * var(--t))}
  .nav .n3{top:calc(78 * var(--t))}
  .mode{top:calc(104 * var(--t))}
}

(E) TOUCH — widen hit areas without moving a single visual pixel.
@media (pointer: coarse){
  .nav a::after{content:"";position:absolute;inset:calc(-9 * var(--t)) calc(-14 * var(--t))}
}
  CRITICAL: .nav a is already absolutely positioned so it is its own containing block.
  Do NOT add position:relative here or the links fall out of absolute flow and stagger
  diagonally down the page.

═══════════════════════════════════════════════════
10. ENTRANCE ANIMATION — one-shot, on load
═══════════════════════════════════════════════════
The sky and the marquee are DELIBERATELY EXCLUDED — they are the static stage.

Blocking script in <head> (must run before first paint to prevent a flash of the final
frame): add class 'entrance-pending' to <html>, and set a 2800ms failsafe timeout that
calls window.__ayushFinishEntrance() if defined, else strips 'entrance-pending' and adds
'entrance-complete'.

Pre-state CSS (the opacity rule is one selector list ending in {opacity:0} — keep it intact):
html.entrance-pending .hi,
html.entrance-pending .name,
html.entrance-pending .role,
html.entrance-pending .scene-assembly,
html.entrance-pending .nav a,
html.entrance-pending .mode{opacity:0}

html.entrance-pending .hi{translate:0 calc(7 * var(--t));will-change:opacity,translate}
html.entrance-pending .name{translate:0 calc(15 * var(--t));
  clip-path:inset(100% 0 0 0);will-change:opacity,translate,clip-path}
html.entrance-pending .role{translate:0 calc(8 * var(--t));will-change:opacity,translate}
html.entrance-pending .scene-assembly{translate:0 calc(18 * var(--u));scale:.985;
  will-change:opacity,translate,scale}
html.entrance-pending .nav a,
html.entrance-pending .mode{translate:calc(-7 * var(--t)) 0;will-change:opacity,translate}

Web Animations API timeline (fill:'both', default easing cubic-bezier(.22,1,.36,1)),
fired inside requestAnimationFrame:
  .hi              opacity 0→1, translate '0 7px'→'0 0'    | dur 520,  delay 40
  .name            opacity 0→1, translate '0 15px'→'0 0',
                   clipPath 'inset(100% 0 0 0)'→'inset(0 0 0 0)'
                                                            | dur 760,  delay 135,
                                                              easing cubic-bezier(.16,1,.3,1)
  .role            opacity 0→1, translate '0 8px'→'0 0'     | dur 580,  delay 430
  .scene-assembly  opacity 0→1, translate '0 18px'→'0 0',
                   scale '.985'→'1'                         | dur 980,  delay 390,
                                                              easing cubic-bezier(.16,1,.3,1)
  .nav a, .nav .mode (querySelectorAll, staggered by index)
                   opacity 0→1, translate '-7px 0'→'0 0'    | dur 470,
                                                              delay 690 + index*52
Then Promise.allSettled on every animation's .finished → finishEntrance().
finishEntrance() is idempotent: clears the failsafe timeout, removes 'entrance-pending',
adds 'entrance-complete', cancels all animations, nulls the global hook.
Bail out early (call finishEntrance immediately) if prefers-reduced-motion is set OR
Element.prototype.animate is not a function.

═══════════════════════════════════════════════════
11. REDUCED MOTION
═══════════════════════════════════════════════════
@media (prefers-reduced-motion: reduce){
  *{transition-duration:.001ms !important;animation-duration:.001ms !important}
  .marquee-run{animation:none !important;will-change:auto !important}
  html.entrance-pending .hi,
  html.entrance-pending .name,
  html.entrance-pending .role,
  html.entrance-pending .scene-assembly,
  html.entrance-pending .nav a,
  html.entrance-pending .mode{
    opacity:1 !important;translate:none !important;scale:none !important;
    clip-path:none !important;will-change:auto !important}
}
The explicit .marquee-run override matters: the blanket .001ms above would otherwise leave
an INFINITE animation stuttering on an arbitrary frame rather than stopping it.
The video is also fully unloaded in JS under reduced motion (see §6).

═══════════════════════════════════════════════════
12. HEAD / META
═══════════════════════════════════════════════════
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Ayush - Product Designer</title>
<meta name="description" content="Product designer building products with a problem solving and creative mindset.">

═══════════════════════════════════════════════════
13. ACCEPTANCE CRITERIA — verify all of these
═══════════════════════════════════════════════════
1.  At exactly 1280x720 the layout is pixel-identical to the reference; --u computes to 1px.
2.  Page never scrolls, at any viewport size, in any orientation.
3.  The marquee scrolls right-to-left continuously with NO seam, gap, or jump. Scrub the
    animation to t=82000ms and confirm the run offsets equal those at t=0.
4.  The sky video plays, and after its first pass every loop restarts at 3.0s — never 0.
    Confirm by sampling currentTime across the wrap: it should go ~9.98 → ~3.0, and
    playback must not pause.
5.  Kill the network: the still image shows and the layout is unchanged. Kill both video
    and still: the hero falls back to the #2f6ff0 body colour, nothing collapses.
6.  Entrance animation plays once on load; content is invisible before it starts (add
    'entrance-pending' manually and confirm all six targets compute to opacity 0).
7.  With prefers-reduced-motion: everything is immediately visible and static, the ticker
    is fully stopped, the video is not loaded.
8.  Test at minimum: 1280x720, 1920x1080, 1024x768, 768x1024, 390x844, 375x812, 844x390,
    and 568x320. Nothing letterboxes, overlaps, or strands copy in empty sky.
9.  Keyboard: all four nav links and the toggle are reachable and show visible focus rings.
10. No local asset references anywhere — only the three URLs in §2 plus Google Fonts.
[/hidecontent]
← 返回首页