Dantora
aadmin2026-11-05 发布Site模板7 阅读

Dantora

类型:Site模板 · 收录:2026-11-05 01:12:55 · 源码:会员可见

[hidecontent type="vip1"]
# Recreate this site as a single HTML file: Dantora

You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. ES modules inline in a
`<script type="module">`. **three.js is the only external code**, pulled through an import map;
the spring solver, the shared ticker, the word-split text reveals, the horizontal scrub, the
draggable rail and the cursor trail are all written by hand. Hardcode every value given here.

## What it is

A landing page for **Dantora**, a private medical clinic. The subject of the page is a **WebGL DNA
helix drawn in green ink on white water**: two point clouds — the strand itself and an ink plume it
continuously sheds — both **MULTIPLY-blended**, so the dye *darkens* the pale mint paper rather than
glowing on black. It appears twice, in the hero and behind the contact form.

Six blocks, and the transitions between them are the design as much as the blocks are:

1. **Hero** — a mint panel with a rounded bottom, the headline and lead at the artboard's own
   coordinates on the left, the live helix filling the right, a hairline rule, a trust line and a
   row of frosted service chips.
2. **Why Dantora** — one viewport of plain white between two mint panels. Moving the mouse across it
   **spawns photographs along the cursor's path**, each scaling in where it was dropped and the
   oldest scaling away.
3. **Services** — a **four-viewport scroll runway** whose panel is `sticky`: vertical scroll is
   mapped onto **horizontal travel** of a five-card rail.
4. **About** — carries `margin-top: -100lvh`, so it **rides up over the pinned Services panel** as
   you scroll. A parallax banner, four counting statistics, and a paragraph whose unimportant
   clauses are greyed.
5. **Team** — `sticky`, a **drag-and-glide rail** of doctor cards with a live progress bar.
6. **Contact** — a higher stacking order, so it rides up over Team in turn. The second helix sits
   behind a frosted form panel.

Over all of it: a fixed frosted header, and a **brand-green preloader** whose ring draws clockwise
while the DNA mark fills diagonally, then clips away bottom-to-top.

## Page shell & libraries

- **Import map:**
  ```html
  <script type="importmap">
  {"imports":{
    "three": "https://unpkg.com/three@0.185.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.185.0/examples/jsm/"
  }}
  </script>
  ```
  From `three/addons/` you need `postprocessing/{EffectComposer,RenderPass,ShaderPass}.js`.
- **Font, from Google Fonts** — the design's only typeface, a variable font with the mockup's axes
  pinned (`GRAD 0`, `ROND 0`, `wdth 100`):
  ```html
  <link href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:GRAD,ROND,wdth,wght@0,0,100,100..900&display=swap" rel="stylesheet">
  ```
  Weights used: 300 (Light — nearly everything), 400, 500 (Medium — eyebrows, nav CTA, names).
- **One shared rAF loop.** A `Set` of subscribers, each with its own minimum frame gap read live
  every frame; starts on the first subscriber, `cancelAnimationFrame`s when the last leaves. The two
  scenes, the horizontal scrub, the parallax banner and the rail's glide all subscribe to it.
- `body { background: #ffffff; min-height: 100vh; overflow-x: hidden; }`.
- **Reserve the scrollbar track permanently**: `html { scrollbar-gutter: stable; }`. Without it the
  viewport width changes the moment the page becomes scrollable, which resizes the WebGL canvases
  and visibly re-frames the helix mid-scroll.

### The adaptive grid (root font-size ladder)

```css
html { font-size: 16px; scrollbar-gutter: stable; }
@media (min-width: 1024px) { html { font-size: 1.111111vw; }  /* 16 * 100 / 1440 */ }
```

**Desktop only, and that is deliberate.** From `lg` up the composition is absolutely positioned at
the 1440 artboard's coordinates, so every offset must track the viewport. Below it the page is a
flow layout that reflows by itself and the root stays a plain 16px — scaling there buys nothing and
actively hurt: with a 1024 and a 360 base, an 820px tablet computed a 12.6px root while a 500px
phone computed 21.6px. **Smaller text on the larger device.**

> The project writes the media query as `(min-width: 1024px) and (max-width: 1440px)` and runs an
> `AdaptiveGrid` script above 1440 at `coef: 1`, i.e. `16 * innerWidth / 1440` px. That expression
> **is** `1.111111vw`, so the open-ended query above is identical at every width and needs no script.

**Breakpoints are Tailwind's defaults**: `md` 768, `lg` 1024.

### Cap-height trimming — do not skip this

```css
h1, h2, h3, h4, h5, h6, p, li, dd, dt, label, figcaption, button, a {
  text-box: trim-both cap alphabetic;
}
```

Almost every text node in the mockup carries it, and without it **every block inherits half-leading
it was never designed with**: the hero `h1` measured 132px against Figma's 109, pill buttons 57
against 48, the chip row 108 against 94 — an error that compounds all the way down the page. Add a
`.text-trim` utility carrying the same declaration for a label inside a flex container, where the
bare text node is an anonymous flex item the base rule cannot reach. Browsers without `text-box`
support simply fall back to the untrimmed box.

## Design tokens

```css
:root {
  --white: #ffffff;  --black: #000000;
  /* Dantora brand ramp — Figma "Concept 4" (1518:1970), lifted from layer fills */
  --brand-500: #246f65;   --brand-900: #054038;
  --lime-200: #f8ffb4;    --lime-400: #e5ed9b;
  --mint-50: #eff4f2;     --mint-100: #e5f1ed;

  --background: var(--white);
  --foreground: var(--black);
  /* The page background is WHITE; the mint is a PANEL colour painted by each
     full-bleed section, which is why the Why Dantora band reads white between them. */
  --surface-section: var(--mint-50);
  /* The About/Team panel is a deeper mint — that is what separates it from the
     block it slides over. */
  --surface-section-deep: var(--mint-100);

  --accent: var(--brand-500);
  --action-primary: var(--brand-500);
  --action-primary-border: var(--brand-900);
  --action-primary-foreground: var(--white);
  --action-secondary: var(--lime-200);
  --action-secondary-border: var(--lime-400);
  --action-secondary-foreground: var(--black);

  --surface-glass: rgba(255,255,255,0.8);          /* header shell, logo card */
  --surface-glass-subtle: rgba(255,255,255,0.5);   /* chips over the scene */
  --surface-glass-strong: rgba(255,255,255,0.9);   /* the form panel */
  --border-subtle: rgba(0,0,0,0.1);
  --foreground-muted: rgba(0,0,0,0.4);             /* the greyed About clauses */
  --foreground-subtle: rgba(0,0,0,0.5);            /* roles, consent line */

  --corner-mark: 4px;   --corner-panel: 8px;    --corner-card: 24px;
  --corner-section: 48px; --corner-chip: 37px;  --corner-action: 46px;
  --glass-blur: 10px;
  --duration-fast: 150ms; --duration-normal: 250ms;
  --ease-entrance: cubic-bezier(0.2, 0, 0, 1);
  --leading-display: 1.1;   /* headings and the logotype */
  --leading-body: 1.2;      /* buttons, nav, paragraphs */
}
```

**There is no dark theme, and adding one would erase the scene**: the design is a single light theme
built on a mint page and a MULTIPLY-blended WebGL backdrop that only reads on a light surface.

## The DNA Ink scene

Two point clouds in a spinner group (local-Y rotation) inside a tilted group, so the ink swirls with
the strand. Plus a third, camera-attached cloud of suspended sediment. **All three are
`MultiplyBlending`** — the dye darkens the white water rather than glowing on black.

### Config — every number is a uniform

```js
bgColor "#eff4f2"
atmoColor "#247063", atmoCount 1500, atmoSize 0, atmoSpeed 0.6
helixColorA "#247061", helixColorB "#4ca98a"
inkCore "#81a297", inkMid "#f7fec3", inkEdge "#246f65"
helixCount 40000, inkCount 160000
camDist 12, helixSize 1, inkSize 6, brightness 0.4
helixOpacity 1.54, inkOpacity 0.86, inkGrow 1.8
radius 1.75, height 6.8, twist 0.65, strandThick 0.39, wave 0.5
spin 0, tilt -0.34
emitRate 0.19, spread 0.6, rise -0.2, turbulence 1.6, noiseFreq 1.05, noiseEvolve 0.1
parallax 3, pointerRadius 5, pointerStrength 1.55, maxPixelRatio 1.5
camera: fov 45, near 0.1, far 200; fog far 22
```

- **`spin` is 0**: the helix holds one fixed pose, which is what the design calls for. The reference
  sketch used `0.34`; raising it brings the slow rotation back.
- **`atmoSize` is 0**, so the sediment cloud is built and drawn but invisible. Kept wired.
- **`maxPixelRatio` is not from the sketch**, which rendered at raw DPR. A 3× phone renders 9× the
  fragments for ~200k additive point sprites — the difference between 60 fps and a slideshow.

### Colour handling — do NOT use `Color.setHex`

Write `#rrggbb` into a `THREE.Color` with **`setRGB` on the raw components**. `setHex` treats the
value as sRGB and converts it into the linear working space, but nothing in this pipeline ever
encodes back — the composer's final pass is a custom shader with no `<colorspace_fragment>`, so
whatever lands in the framebuffer is what you see. Raw components keep the clear colour matching the
`hexToVec3` every palette colour reaches the shaders through.

### `premultipliedAlpha: true` is mandatory

The reference sketch ran three r143, where non-premultiplied multiply fell back to
`blendFunc(ZERO, SRC_COLOR)`. **That branch is gone.** Since r16x the renderer logs *"MultiplyBlending
requires material.premultipliedAlpha = true"* and sets **no blend function at all**, so the clouds
draw with whatever state the previous material left behind. The premultiplied path,
`blendFuncSeparate(DST_COLOR, ONE_MINUS_SRC_ALPHA, ZERO, ONE)`, reduces to exactly `dst * src`
because every fragment here writes `alpha = 1.0` — i.e. it reproduces r143 bit-for-bit.

Every cloud is also `transparent: true, depthWrite: false, depthTest: false, frustumCulled: false`.
Frustum culling **must** be off: points are placed entirely in the vertex shader, so their bounding
sphere says nothing about where they end up and culling drops the whole cloud.

### Geometry is a buffer of random seeds

```js
const seeds = new Float32Array(count * 3);
for (let i = 0; i < seeds.length; i++) seeds[i] = Math.random() * 64;
geometry.setAttribute("position", new THREE.Float32BufferAttribute(seeds, 3));
```

The shaders hash each "position" into a point identity — it is a seed, not a place.

### Shaders (verbatim)

**Shared prelude** — 3D simplex noise, a per-point hash, and the helix sampler both clouds are born
from (`rnd1` picks strand vs. base-pair rung, so one seed buffer yields the whole double helix):

```glsl
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
float snoise(vec3 v){
  const vec2 C = vec2(1.0/6.0, 1.0/3.0); const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
  vec3 i = floor(v + dot(v, C.yyy)); vec3 x0 = v - i + dot(i, C.xxx);
  vec3 g = step(x0.yzx, x0.xyz); vec3 l = 1.0 - g;
  vec3 i1 = min(g.xyz, l.zxy); vec3 i2 = max(g.xyz, l.zxy);
  vec3 x1 = x0 - i1 + 1.0 * C.xxx; vec3 x2 = x0 - i2 + 2.0 * C.xxx; vec3 x3 = x0 - 1.0 + 3.0 * C.xxx;
  i = mod(i, 289.0);
  vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
  float n_ = 1.0/7.0; vec3 ns = n_ * D.wyz - D.xzx;
  vec4 j = p - 49.0 * floor(p * ns.z *ns.z);
  vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_);
  vec4 x = x_ *ns.x + ns.yyyy; vec4 y = y_ *ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y);
  vec4 b0 = vec4(x.xy, y.xy); vec4 b1 = vec4(x.zw, y.zw);
  vec4 s0 = floor(b0)*2.0 + 1.0; vec4 s1 = floor(b1)*2.0 + 1.0; vec4 sh = -step(h, vec4(0.0));
  vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy; vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
  vec3 p0 = vec3(a0.xy,h.x); vec3 p1 = vec3(a0.zw,h.y); vec3 p2 = vec3(a1.xy,h.z); vec3 p3 = vec3(a1.zw,h.w);
  vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
  p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
  vec4 m = max(0.5 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); m = m * m;
  return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
float random(vec3 p) { return fract(sin(dot(p, vec3(12.9898, 78.233, 45.164))) * 43758.5453); }
// birth point on the double helix from a per-point hash (rnd1 picks strand/rung)
vec3 helixPoint(float rnd1, float rnd2, float rnd3, float rnd4, float twist, float radius, float height, float thick, float wave, float time) {
  float y = (rnd2 * 2.0 - 1.0) * height;
  vec3 core;
  if (rnd1 < 0.85) {                                   // the two strands
    float strand = step(0.5, rnd1) * 3.14159265;
    float a = y * twist + strand;
    core = vec3(radius * cos(a), y, radius * sin(a));
    core += (vec3(rnd2, rnd3, rnd4) - 0.5) * thick;
  } else {                                             // base-pair rungs
    float rungT = rnd3;
    float dy = floor(y * 2.2) / 2.2;
    float a = dy * twist;
    vec3 p1 = vec3(radius * cos(a), dy, radius * sin(a));
    vec3 p2 = vec3(radius * cos(a + 3.14159265), dy, radius * sin(a + 3.14159265));
    core = mix(p1, p2, rungT) + (vec3(rnd2, rnd4, rnd3) - 0.5) * 0.08;
  }
  // slow breathing sway of the whole strand
  core.x += sin(time * 0.5 + y * 0.6) * wave;
  core.z += cos(time * 0.4 + y * 0.6) * wave;
  return core;
}
```

**Helix vertex** — bright points hugging the double helix:

```glsl
uniform float uTime, uHelixSize, uTwist, uRadius, uHeight, uThick, uWave;
uniform float uPixelScale;
uniform vec3 uHelixA, uHelixB;
uniform vec3 uCursor; uniform float uRepelRadius, uRepelStrength, uActivity;
varying vec3 vColor; varying float vFade;
${SNOISE}
void main() {
  vec3 s = position;
  float rnd1 = random(s), rnd2 = random(s + 1.7), rnd3 = random(s + 3.3), rnd4 = random(s + 5.9);
  vec3 p = helixPoint(rnd1, rnd2, rnd3, rnd4, uTwist, uRadius, uHeight, uThick, uWave, uTime);
  vec3 wp = (modelMatrix * vec4(p, 1.0)).xyz;
  // cursor deform — push points out of a soft world-space ball at the pointer
  vec3 toP = wp - uCursor;
  float fall = smoothstep(uRepelRadius, 0.0, length(toP));
  wp += normalize(toP + vec3(1e-4)) * fall * uRepelStrength * uActivity;
  vec4 mv = viewMatrix * vec4(wp, 1.0);
  vColor = mix(uHelixB, uHelixA, rnd4);
  vFade = 0.65 + 0.35 * rnd2;
  gl_PointSize = uHelixSize * uPixelScale * (12.0 / -mv.z);
  gl_PointSize = max(gl_PointSize, 1.5);
  gl_Position = projectionMatrix * mv;
}
```

**Helix fragment:**

```glsl
uniform float uBrightness, uHelixOpacity, uAppear;
varying vec3 vColor; varying float vFade;
void main() {
  vec2 xy = gl_PointCoord - 0.5;
  float ll = length(xy);
  if (ll > 0.5) discard;
  float soft = smoothstep(0.5, 0.05, ll);
  float cov = clamp(soft * vFade * uHelixOpacity * uAppear * uBrightness, 0.0, 1.0);
  gl_FragColor = vec4(mix(vec3(1.0), vColor, cov), 1.0);   // MULTIPLY: darkens white toward the strand
}
```

**Ink vertex** — position is a pure function of a normalised life:

```glsl
uniform float uTime, uInkSize, uTwist, uRadius, uHeight, uThick, uWave;
uniform float uEmitRate, uSpread, uRise, uTurb, uNoiseFreq, uNoiseEvolve, uInkGrow;
uniform float uPixelScale;
uniform vec3 uInkCore, uInkMid, uInkEdge;
uniform vec3 uCursor; uniform float uRepelRadius, uRepelStrength, uActivity;
varying vec3 vColor; varying float vAlpha;
${SNOISE}
void main() {
  vec3 s = position;
  float rnd1 = random(s), rnd2 = random(s + 1.7), rnd3 = random(s + 3.3), rnd4 = random(s + 5.9);
  float seed = random(s + 9.1);

  // normalized life 0..1, phases spread across the cloud
  float life = fract(seed + uTime * uEmitRate);

  // birth on the helix (frozen at the point's own emission moment)
  float birthTime = uTime - life / max(uEmitRate, 1e-4);
  vec3 birth = helixPoint(rnd1, rnd2, rnd3, rnd4, uTwist, uRadius, uHeight, uThick, uWave, birthTime);

  vec3 outward = normalize(vec3(birth.x, 0.0, birth.z) + vec3(1e-4));

  // evolving simplex flow field — the "swirl through water"
  float e = uTime * uNoiseEvolve;
  vec3 np = birth * uNoiseFreq;
  vec3 flow = vec3(
    snoise(np + vec3(e, 0.0, 0.0)),
    snoise(np + vec3(0.0, e, 0.0) + 11.0),
    snoise(np + vec3(0.0, 0.0, e) + 23.0)
  );

  vec3 disp = outward * life * uSpread
            + flow * pow(life, 1.4) * uTurb
            + vec3(0.0, life * uRise, 0.0);
  vec3 p = birth + disp;

  vec3 wp = (modelMatrix * vec4(p, 1.0)).xyz;
  vec3 toP = wp - uCursor;
  float fall = smoothstep(uRepelRadius, 0.0, length(toP));
  wp += normalize(toP + vec3(1e-4)) * fall * uRepelStrength * uActivity;
  vec4 mv = viewMatrix * vec4(wp, 1.0);

  vec3 c = mix(uInkCore, uInkMid, smoothstep(0.0, 0.4, life));
  c = mix(c, uInkEdge, smoothstep(0.35, 1.0, life));
  vColor = c;

  vAlpha = smoothstep(0.0, 0.06, life) * (1.0 - smoothstep(0.4, 1.0, life));

  float grow = 0.35 + life * uInkGrow;
  gl_PointSize = uInkSize * grow * uPixelScale * (12.0 / -mv.z);
  gl_PointSize = max(gl_PointSize, 1.0);
  gl_Position = projectionMatrix * mv;
}
```

**Ink fragment:**

```glsl
uniform float uBrightness, uInkOpacity, uAppear;
varying vec3 vColor; varying float vAlpha;
void main() {
  vec2 xy = gl_PointCoord - 0.5;
  float ll = length(xy);
  if (ll > 0.5) discard;
  // soft gaussian puff so overlapping dye stays smooth, never aliased
  float soft = exp(-ll * ll * 7.0);
  float cov = clamp(soft * vAlpha * uInkOpacity * uAppear * uBrightness, 0.0, 1.0);
  gl_FragColor = vec4(mix(vec3(1.0), vColor, cov), 1.0);
}
```

**Atmosphere** (invisible at `atmoSize 0`, kept wired) and the **final pass**, which on white paper
simply hands the render through — the clouds are already MULTIPLY-blended onto the scene, so there
is nothing to sum:

```glsl
// atmo vertex
attribute float size; attribute float seed; uniform float uTime; uniform vec2 uRes;
varying float vA;
vec3 warp(vec3 p, float t){ float c=0.9,a=1.9,b=0.02,s=0.05; p*=2.;
  p.x+=c*sin(s*t+a*p.y)+t*b; p.y+=c*cos(s*t+a*p.x); p.y+=c*sin(s*t+a*p.z)+t*b;
  p.z+=c*cos(s*t+a*p.y); p.z+=c*sin(s*t+a*p.x)+t*b; p.x+=c*cos(s*t+a*p.z);
  return cos(p+vec3(1,2,4)); }
void main(){
  vec3 v = position*5.0 + warp(position, uTime)*1.4;
  vec4 mv = modelViewMatrix * vec4(v, 1.0);
  float r = length(v); float farF = 1.0 - smoothstep(6.0, 8.0, r); float nearF = smoothstep(0.0, 0.5, -mv.z);
  vA = farF * nearF;
  gl_PointSize = size * uRes.y / 900.0 / -mv.z; gl_PointSize = max(gl_PointSize, 1.0);
  gl_Position = projectionMatrix * mv;
}
// atmo fragment
uniform vec3 uColor; varying float vA;
void main(){ vec2 p = gl_PointCoord - 0.5; float l = length(p); if (l > 0.5) discard;
  float tex = smoothstep(0.5, 0.0, l); float cov = clamp(tex * vA * 0.4, 0.0, 1.0);
  gl_FragColor = vec4(mix(vec3(1.0), uColor, cov), 1.0); }
// final vertex / fragment
varying vec2 vUv;
void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }
uniform sampler2D tDiffuse; varying vec2 vUv;
void main(){ gl_FragColor = vec4(texture2D(tDiffuse, vUv).xyz, 1.); }
```

> **Only one composer.** The reference sketch also built two bloom composers whose render targets
> were assigned to uniforms the final shader never samples — three full scene renders per frame for
> an image that was thrown away. Keep the composite path alone: identical output, a third of the cost.

### `uPixelScale` — the one that is not obvious

`gl_PointSize` is in **device pixels**, so without normalising against the drawing buffer the same
sprite covers a larger *share* of a small canvas: narrowing the window packed the cloud tighter and
MULTIPLY blending turned that overlap into a **darker, shifted colour**.

```js
const REFERENCE_BUFFER_HEIGHT = 1600;   // 800 design px at a 2× cap
uPixelScale = (height * pixelRatio) / REFERENCE_BUFFER_HEIGHT;
```

`helixSize` and `inkSize` are calibrated against that height. The atmosphere already did the same
thing via `uRes.y / 900.0`.

### The clock

```js
MAX_FRAME_DELTA = 0.05
elapsed += Math.min(MAX_FRAME_DELTA, wallClock - lastFrame)
uTime = elapsed
```

**Never `performance.now()` for `uTime`.** Ink position is a pure function of it
(`life = fract(seed + uTime * emitRate)`), so a wall clock makes the whole cloud **teleport** whenever
the browser drops frames — which is exactly what fast scrolling does. Accumulating clamped deltas
means the worst a stall can cost is one frame of drift and the plume stays continuous.

**The entrance fade is the exception** and must ride the *wall* clock:

```js
APPEAR_DELAY = 0.2; APPEAR_DURATION = 1.6
uAppear = clamp(((now - startedAt)/1000 - 0.2) / 1.6, 0, 1)
```

It is a one-shot intro, and on the frame-accumulated clock a throttled rAF leaves the scene stuck
half-faded — which reads as a small, faint tangle instead of a helix. A jump here is invisible
(the fade is already over); a jump in `uTime` is not.

### Pointer

```js
POINTER_EASE 0.05    // the lazy camera drift
CURSOR_EASE 0.15     // the projected world-space cursor
ACTIVITY_EASE 0.08   // the deform strength ramp
POINTER_IDLE_SECONDS 3
```

- NDC is computed against the **canvas rect** and must be **clamped to ±1**. When that rect is
  scrolled far off screen the raw value reaches ±15 and beyond; fed to the parallax unclamped it
  dragged the camera to y ≈ −45 — looking down the helix axis from triple distance, which is the
  *"small knot at a different angle"* every bug report showed.
- The camera is `position = (pointer.x * parallax, pointer.y * parallax, camDist)`, then
  `lookAt(0,0,0)`.
- The world cursor is the eased NDC **unprojected onto the z = 0 plane**, and the deform is a soft
  ball at that point. After `POINTER_IDLE_SECONDS` of stillness `uActivity` eases back to 0.
- **Skip the listeners entirely on a coarse pointer** — there is no cursor to follow.

### Mounting, visibility and readiness

- **Render only while the canvas is on screen**, measured from `getBoundingClientRect()` **every
  frame** — *not* from an `IntersectionObserver`. The observer version **latched**: its first
  callback fires with whatever the layout says at subscribe time, and if that reads as "not
  intersecting" the scene never draws again, because a static element produces no further callbacks.
  That left `uAppear` at 0 — a small faint knot — recovering only if you happened to scroll away and
  back. One rect read per frame cannot latch.
- **Rewind on the way out** (`resetClock()`): zero the elapsed clock, the spin phase and every
  pointer easing value, so each return to the block opens on the same pose. Leave `uAppear` alone —
  the intro must not replay.
- **Only resize on a whole-pixel change.** `getBoundingClientRect` reports sub-pixel values that
  drift while scrolling (sticky sections, compositor rounding, a scrollbar appearing), and every one
  of those reached `setSize` → new camera aspect → **the helix visibly jumped mid-scroll**.
- The hero instance **holds the preloader open** until it has drawn its first frame — announce
  readiness *after* a frame, never on mount.

## The preloader

A brand-green (`--accent`) full-screen panel, `z-index: 100`, centring a ring and a mark with a
percentage under them.

```js
CREEP_TARGET 0.85, CREEP_CONFIG { tension: 12, friction: 30 }
COMPLETE_CONFIG { tension: 90, friction: 26 }
CURTAIN_CONFIG  { tension: 90, friction: 24 }
HOLD_MS 400            // beat at 100% before the curtain moves
REVEAL_AT_INSET 60     // how far the curtain has travelled when the hero is released
MIN_DURATION_MS 900    // floor: a ring that flashes past reads as a glitch
GATE_TIMEOUT_MS 6000   // ceiling: a gate that never resolves must not trap anyone
RING_SIZE 400, RING_RADIUS 180
```

- **One `progress` spring drives all three parts.** It creeps to 85% on its own, then completes for
  real — a bar that sits at 0 and snaps to 100 tells the viewer nothing.
- The **ring** is an SVG circle rotated −90° with `strokeDasharray = circumference` and
  `strokeDashoffset = circumference * (1 - progress)`, 1.5px lime stroke, round cap.
- The **mark** is the DNA glyph twice: once at `rgba(248,255,180,0.14)` so the fill reads as filling
  *something*, and once in full lime under a mask of
  `linear-gradient(45deg, #000 ${p*100}%, transparent ${p*100}%)` — a **hard stop**, so the edge is a
  straight diagonal sweeping from the lower-left.
- The **curtain clips away bottom-to-top**: `clip-path: inset(0% 0% ${b}% 0%)` with `b` running
  0 → 100, so the page is uncovered from the bottom of the viewport.

**Readiness is not `window.load`.** That fires once the document's own subresources are in, which
says nothing about anything mounted lazily — and the hero's centrepiece is exactly that. So:
`documentReady` (load **and** `document.fonts.ready`, or the curtain lifts onto text that then
reflows) **and** a minimum duration **and** every claimed **gate** released. The hero's scene claims
one and releases it after its first drawn frame. A 6s ceiling lifts the curtain regardless.

**The hero is released mid-lift, not on rest.** Waiting for `onRest` means the reveal *starts* on a
page that is already fully uncovered, so the visitor watches an empty layout for the length of the
stagger. The hero copy sits at 28.55% from the top and is still covered at inset 60 — the animation
begins underneath the last of the panel and is already in flight when the panel clears it.

**Hold the hero's animations until then**, by remounting the subtree on a `key` that flips with
`revealed` **and** keeping it `opacity: 0` until then. Both are needed: without the key the reveal
never plays at all (it fired behind the curtain); without the opacity the first pointless pass still
ends at full opacity, and the frame in which the curtain is removed paints that finished text one
tick before the remount resets it — read, correctly, as the animation playing twice.

Scroll is locked (`body { overflow: hidden }`) until revealed.

## The text motion vocabulary

Copy resolves **out of a blur**, rising slightly, word by word. One vocabulary for the whole page.

```js
BLUR_FROM "blur(12px)", BLUR_TO "blur(0px)"
SOFT_SPRING    { tension: 150, friction: 30 }   // body
DISPLAY_SPRING { tension: 130, friction: 24 }   // headings — brisk, because the
                                                // word stagger already spreads it
LABEL_SPRING   { tension: 220, friction: 28 }   // eyebrows

EYEBROW: wordOut { y: 12, opacity: 0, filter: BLUR_FROM }, stagger 45, LABEL_SPRING
HEADING: wordOut { y: 60, opacity: 0, filter: "blur(20px)" }, stagger 55, DISPLAY_SPRING, columnGap 0.25
BODY:    wordOut { y: 16, opacity: 0, filter: BLUR_FROM }, stagger 26, SOFT_SPRING
ELEMENT: from { opacity: 0, filter: BLUR_FROM, transform: "translate3d(0,1.25rem,0)" }, SOFT_SPRING
```

- **`mode: "always"`, not `"once"`.** `once` loses text: the reveal starts at `opacity: 0`, and if
  the spring is starved when the trigger fires (fast scroll, a busy frame, a throttled tab) it never
  replays, so the copy stays invisible **permanently**. `always` re-triggers on the next entry, so a
  missed reveal heals itself. Losing copy is a correctness bug; replaying an animation is taste.
- **Word gap 0.25em**, not the engine's ~0.3em default, which pushed display headings onto an extra
  line versus the Figma measurement.
- **No `overflow` clip** — the reveal reads as a de-blur and clipping fights the blur's soft edge.
- **Card titles deliberately do not animate.** Five card titles revealing while the Services rail
  scrubs past is noise.

## Layout — desktop (≥1024), the 1440 artboard

### Header (fixed, `top: 1rem`, `z-50`)

`pointer-events: none` on the bar so the 40px gutters don't swallow clicks meant for the scene
beneath; each panel switches them back on. Panels are `--surface-glass` over a 10px backdrop blur,
`border-radius: 8px`.

- **Logo** — `3.375rem × 9.3125rem`, `padding: 0 1rem 0 0.375rem`, `gap 0.75rem`: a `2.625rem`
  accent square with a `4px` radius holding the 24px DNA glyph in lime, then the wordmark at
  `1.25rem`, weight 300.
- **Nav** — `3.375rem × 26.375rem`, `padding-inline: 2.25rem`, links `gap 2.625rem` at `1rem`
  weight 300, hover → `--accent`. Hidden below `lg`.
- **CTA** — the arrow pill, desktop only. It **fades and lifts out (`y: -24`) once the Contact block
  is on screen**, because it is redundant there and sits exactly where that block's form panel now
  reaches. `pointer-events` drop with it.
- **Below `lg`** the links collapse behind a burger: a `3.375rem` square panel whose three 1px bars
  cross on open, and a panel that scales/fades from the top-right corner
  (`{ tension: 260, friction: 30 }`), `width: min(18rem, 100vw - 2.5rem)`. Escape closes, the page
  is scroll-locked while open, every link closes on activation.

### Hero — `height: 100lvh`, `--surface-section`, `border-radius: 0 0 48px 48px`, `overflow: hidden`

Below `lg` it is a flex column with `padding: 7rem 1.25rem 3rem`. From `lg` every block is pinned —
**horizontal offsets in rem, vertical ones as a percentage of the artboard's 800px height**, so the
composition keeps Figma's proportions at any desktop width:

| block | position |
|---|---|
| copy (eyebrow / h1 / lead) | `top: 28.55%; left: 2.5rem; width: 44.75rem` |
| actions | `top: 89.375%; left: 2.5rem` |
| the scene | `inset-y: 0; left: 22.5625rem; width: 71.25rem` |
| rule | `top: 78.625%; right: 2.5rem; left: 2.5625rem` |
| trust line | `top: 83.625%; left: 2.5rem` |
| chips | `top: 83.625%; left: 55.5625rem; width: 31.9375rem` |

The markup is written in **mobile order** and the desktop composition is reconstructed by those
offsets — that avoids a pile of `order-*` overrides. **`z-index: 10` on the copy is load-bearing**:
the scene's canvas comes *after* it in the DOM and would otherwise paint over it.

Type: eyebrow `0.875rem` weight 500 uppercase in `--accent`; `h1` `3.75rem` weight 300 leading 1.1
(`2.5rem` on mobile, `3rem` at `md`); lead `1rem` weight 300 on a `30.875rem` measure. Buttons are
`11.25rem` wide. Chips: `border-radius: 37px`, 1px `--border-subtle`, `--surface-glass-subtle`,
`padding: 1rem 2rem`, backdrop blur.

Entry cascade, in ms, from the reveal: copy `0` (it carries its own word stagger), divider `140`,
trust line `220`, chips `300`, actions `380`. The scene is **not** wrapped — it holds the preloader
open, so by the time anything here animates it is already on screen.

### Why Dantora — `height: 100lvh`, white, `overflow: hidden`

The band between two mint panels; that contrast is the design, not a missing fill. `overflow: hidden`
clips the trail — without it a card spawned near an edge spills over the hero above.

Copy is centred both ways: `width: min(100% - 2.5rem, 44.75rem)`, `gap: 3rem`, heading on a
`39.25rem` measure, lead on `27.625rem`, two buttons. **Pair `text-center` with
`justify-content: center`** — the word engine's container is a flex row, so text-align alone
positions nothing.

**The cursor trail:**

```js
SPAWN_DISTANCE 90     // pointer travel (px) between two spawns
TRAIL_LENGTH 5        // cards alive at once; dropping the oldest plays its exit
MAX_TILT 12           // degrees, either way
ENTER_CONFIG   { tension: 130, friction: 28 }
LEAVE_CONFIG   { tension: 300, friction: 24 }
OPACITY_CONFIG { tension: 900, friction: 40 }
```

Each card is `13.5rem × 17rem`, `border-radius: 24px`, `object-fit: cover`, centred on its spawn
point with a random tilt, cycling the eight images by spawn index. **Only `scale` is animated** —
position is fixed at spawn, so the card grows in place instead of sliding to the cursor:

```js
transform: `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%) rotate(${tilt}deg) scale(${scale})`
```

Opacity is **deliberately near-instant** (that 900-tension config): in the reference the fade is
barely perceptible and the readable motion is the scale. Running opacity on the same soft spring made
every card spend its life half-transparent, which is not the effect. **Ignore any pointer that is not
a mouse** — a coarse pointer has no path to leave and would spawn on every tap — and clear the trail
on `pointerleave`.

### Services — a four-viewport runway with a sticky panel

```js
RUNWAY_VIEWPORTS 4     // pin + scrub + dwell + overlay
OVERLAY_VIEWPORTS 1    // the trailing viewport where About slides over
DWELL_VIEWPORTS 1      // reading time on the final card
SCRUB_CONFIG { tension: 220, friction: 42 }

scrubbable = wrapper.offsetHeight - viewport * (1 + OVERLAY + DWELL)
progress   = clamp(-wrapper.getBoundingClientRect().top / scrubbable, 0, 1)
travel     = max(track.scrollWidth - track.clientWidth, 0)
x          = -progress * travel        // driven through a spring, not raw scroll
```

**`DWELL_VIEWPORTS` is the knob that buys reading time.** Without an explicit dwell the scrub ended
and the overlay began one viewport later no matter how long the runway was — lengthening the runway
just slowed the scrub.

It runs at **every** breakpoint, and that is safe because nothing calls `preventDefault`: the rail is
a pure function of scroll position, so the page still scrolls normally and the gesture is never
stolen. Only the card sizing is responsive.

The sticky panel is `100lvh`, `--surface-section`, `border-radius: 48px 48px 0 0`. Header offsets:
heading `top: 14%; left: 2.5rem; width: 43.5rem`; eyebrow `top: 14%; left: 66.75rem; width: 20.75rem`;
description `top: 21%; left: 66.75rem`. Figma puts them at 8%, but the fixed header occupies the top
8.75% of every screen — unlike the artboard, where it only overlaps the hero.

> **Positioning goes on wrapper divs, never on the text engine itself.** It sets `position: relative`
> as an **inline style**, which beats a `absolute` class — put `absolute` on the engine and its
> `top`/`left` silently become relative offsets. And the heading measure is `43.5rem`, not Figma's
> 42.75: the engine's word boxes are a hair wider than laid-out text, and at 42.75 the heading tipped
> onto a third line the card rail then covered.

The rail is `top: 39%; right: 0; bottom: 2.5rem; left: 2.5rem; width: 85rem; gap: 0.625rem`. Cards
are full-height, `border-radius: 24px`, `padding: 2rem`, three variants:

- **lime** — `35.0625rem` wide, `--action-secondary` with a `--action-secondary-border` outline,
  carrying a list of sub-services separated by hairlines with a diagonal arrow on each row.
- **photo** — `27.9375rem`, a full-bleed cover image, white text.
- **brand** — `27.9375rem`, `--accent`, white text.

Each card: the index at `1.25rem`, the title at `2rem` weight 300, and — on all but the first — a
`Discover` label with a **72px ring** (1px white stroke, no fill; *not* the filled lime disc the
buttons use). Use `align-items: end` on that row: Figma places the label on the row's baseline next
to the ring, and centring drops the word below the line the lime card's last list row sits on.

### About — rides up over Services

`margin-top: -100lvh`, `z-index: 10`, `--surface-section-deep`, `border-radius: 48px 48px 0 0`,
`height: 71.5rem`. It starts one viewport before the Services runway ends, so scrolling lifts it
across. **This only works because the runway also exists at every breakpoint** — the two are a
matched pair, and disabling one on small screens without the other pulls this section up over the
one above.

- **Banner** — `top: 2.5rem; left: 2.5rem; height: 31.25rem; width: 85rem`, `border-radius: 24px`.
  A parallax: the image is `OVERSCAN 0.25` taller than its frame and drifts against the scroll on a
  `{ tension: 180, friction: 40 }` spring, with
  `progress = clamp((innerHeight - rect.top) / (innerHeight + rect.height), 0, 1)` and
  `translateY((progress - 0.5) * 25%)`.
- **Content row** — `top: 37.75rem; left: 2.375rem; width: 85.125rem`, space-between.
  Left column `28.0625rem`: the eyebrow, then a 2×2 `<dl>` of statistics, each with a left border in
  `--accent` and `padding-left: 1.5rem`, the figure at `3.75rem` weight 300 over its label.
  Right column `49.3125rem`: the paragraph at `2.25rem` weight 300, **written in fragments** where
  the muted ones take `--foreground-muted` — the greying is the rhythm — then two buttons (`15rem`
  and `11.25rem`).

**The counters** split `"98%"` / `"24h"` / `"60+"` / `"15"` once into prefix / number / suffix and
animate only the number, so the markup never changes width and the layout never shifts. Spring
`{ tension: 22, friction: 26 }` — long and heavily damped, so it decelerates into the target rather
than ticking like an odometer. It runs on `IntersectionObserver` at threshold 0.2 and **resets when
it leaves**, matching the text presets' `always`. Keep the full string as the accessible name while
the digits move.

### Team — sticky, `100lvh`, `--surface-section-deep`

`padding-top: 6%`, the rail box `86%` of the height. Cards, in the mockup's order: the brand-green
intro card (`27.9375rem`) carrying the eyebrow, a static `2rem` heading and four 64px social icons;
five portrait cards (`20.75rem`) each with a **frosted caption plate** inset 2rem from three sides,
`height: 4rem`, centred, `border-radius: 8px`, `--surface-glass` over the blur, holding the name at
`1rem` weight 500 over the role at `0.875rem` in `--foreground-subtle`; then a lime tail card
(`20.75rem`) with *And other 60+ specialists* and an `Explore all` ring.

> `gap: 0.75rem` inside the caption plate is **load-bearing**: cap-height trimming removes the
> leading that would otherwise separate the two lines, so without it the name and the role collide.

**The rail is native `overflow-x: auto` plus a pointer-capture drag layered on top** — wheel,
trackpad, touch and keyboard come free; a mouse cannot drag it otherwise.

```js
GLIDE 0.18          // fraction of the remaining distance closed each frame
MOMENTUM 14         // frames' worth of release velocity carried past the release
SETTLE_PX 0.5       // below this the glide ends and native scrolling resumes
DRAG_THRESHOLD 4    // px past which a press is a drag, not a click
MIN_FILL 890/1360   // the bar's resting fill (Figma)
PROGRESS_CONFIG { tension: 260, friction: 38 }
```

Set a **target** and let the ticker ease `scrollLeft` toward it — that is what turns a 1:1 grab into
a glide. `preventDefault` on pointer-down (the browser's own image drag silently steals the pointer
stream), `[&_img]{pointer-events:none}`, `touch-action: pan-y`, `scroll-behavior: auto` (smooth
scrolling fights the direct `scrollLeft` writes and feels like rubber), scrollbar hidden, and a
**click swallowed in the capture phase** if the pointer moved, so a drag never opens a card. Skip the
drag entirely for `pointerType === "touch"` — native touch scrolling is better.

**No scroll-snap.** `snap-mandatory` re-snaps whenever the layout shifts, and this section changes
height as it becomes sticky — the rail was silently landing on card two before anyone touched it.

Under the rail, a 2px `--border-subtle` bar with an `--accent` fill scaled by
`MIN_FILL + (1 - MIN_FILL) * (scrollLeft / scrollable)`, `transform-origin: left`.

### Contact — rides up over Team

`z-index: 20`, `100lvh`, `--surface-section`, `border-radius: 48px 48px 0 0`, `overflow: hidden`.

- The **second scene** at `inset-y: 0; left: 7.0625rem; width: 71.125rem` — **hidden below `lg`**: a
  second WebGL context on a phone is a real cost and the form is what matters on that screen.
- **Copy** at `top: 35.25%; left: 2.5625rem; width: 42.75rem`.
- **Form panel** at `top: 1rem; right: 2.5rem; bottom: 2.5rem; width: 35.0625rem`,
  `--surface-glass-strong` over the blur, `border-radius: 24px`, `padding: 2rem`. Figma puts it at
  y 40, but the header CTA used to occupy that corner — it now steps aside, so the panel reaches the
  header's own top edge. Inside: a `2.625rem` accent square with the DNA glyph, a static `2.25rem`
  title, three fields (label in accent uppercase `0.875rem` weight 500; input
  `border-radius: 8px`, 1px `--border-subtle`, transparent, `padding: 1.5rem 2rem`, focus border
  `--accent`), the consent line in `--foreground-subtle` with an underlined link, and the submit
  pinned to the bottom with `margin-top: auto`.

**Height comes from the mockup's 54px frame, not from padding** — padding around a 48px disc lands at
80 and blows the panel out of its box.

### The button

One pill shape. `border-radius: 46px`, 1px border, `1.25rem` weight 300, `height: 3rem`,
`padding-inline: 2rem`; the arrow variant is `3.375rem` tall with `gap: 0.625rem`,
`padding: 0 0.125rem 0 2rem` and a `3rem` lime disc holding a 16px diagonal arrow.
Primary is `--action-primary` on `--action-primary-border`, hover → the border colour. Secondary is
`--action-secondary` / `--action-secondary-border`. It renders a real `<a>` with `href` and a real
`<button>` without one.

## Responsive (below 1024)

Every desktop rule above is behind `min-width: 1024px`. Below it the page is a flow layout:
sections take `padding: 7rem 1.25rem 3rem` (`2.5rem` inline at `md`), the hero is a flex column with
the scene given a fixed `40lvh` slice, headings drop to `2.25rem` (`3rem` at `md`), the Services
cards become `78vw` capped at `22rem` and the Team cards `70vw` capped at `20.75rem`, the About
content stacks, the Contact scene is hidden, and the nav collapses behind the burger. The Services
runway and the About overlay stay — they are a matched pair.

## Copy (verbatim)

**Nav** — `Home → #hero` · `About → #about` · `Services → #services` · `Our team → #team`;
CTA `Contact Us → #contact`. Brand `Dantora`.

**Hero** — eyebrow `About Dantora`. Title: *Medicine that starts with understanding you*.
Lead: *Advanced diagnostics, genetic screening and specialist care in one clinic — with results
explained in language you actually understand.* Actions: `Book a visit → #contact`,
`Our services → #services`. Trust line: *Trusted by 40,000+ patients since 2011* (followed by a
`3.125rem` accent hairline). Chips: `Health Check-ups` · `Diagnostic Imaging` · `Genetic Testing` ·
`Laboratory` · `Specialist Care`.

**Why Dantora** — eyebrow `Why Dantora`. Title: *One clinic, one record, one team behind you*.
Lead: *No referrals across town, no repeated tests. Your doctors share a single medical history and
decide together.* Actions: `Our doctors → #team`, `How we work → #about`.

**Services** — eyebrow `Our Services`. Title: *Everything your diagnosis needs, under one roof*.
Description: *From a routine check-up to a full genomic panel — five directions that cover
prevention, diagnosis and long-term care.* Cards:

| # | title | extra |
|---|---|---|
| 01 | Preventive Health Check-ups | lime; list: Men's health · Women's health · Executive check-up |
| 02 | Diagnostic Imaging | photo `01.png`, `Discover` |
| 03 | Genetic Testing | brand green, `Discover` |
| 04 | Laboratory | photo `02.png`, `Discover` |
| 05 | Specialist Care | photo `03.png`, `Discover` |

**About** — eyebrow `About the clinic`. Statistics: **98%** *Patient satisfaction* · **24h** *Average
lab turnaround* · **60+** *Doctors and specialists* · **15** *Years of practice*. Paragraph, muted
clauses marked *(m)*: *(m)* `Dantora is a 4,000 m² clinic built around a single idea:` ·
`diagnosis and treatment should never be separated.` · *(m)* `Imaging, laboratory and consulting
rooms sit on the same floor,` · `and every result reaches your doctor the same day.`
Actions: `Book a consultation → #contact`, `Price list → #services`.

**Team** — eyebrow `Our Team`. Title: *The doctors you'll actually see, every visit.* Socials:
Instagram · Facebook · LinkedIn · X. Members: **Carter Botosh** (Cardiologist) · **Elena Marsh**
(Radiologist) · **Adrian Kohl** (Medical Geneticist) · **Nils Vance** (Endocrinologist) ·
**Petra Lindqvist** (Neurologist). Tail card: *And other 60+ specialists*, `Explore all → #contact`.

**Contact** — eyebrow `Get in touch`. Title: *Not sure which specialist you need? Ask us.*
Description: *Leave your details and a coordinator will call you back within one working hour to help
you choose a doctor and a time.* Form title: *Request a call back*. Fields: `Your name` / Name,
`Your phone` / Phone, `Your message` / Message (textarea, `rows=1`, no resize). Consent:
*By submitting, you agree to our* **Privacy Policy** *and the processing of your personal data.*
Submit: `Send Request`.

## The two inline SVGs

**DNA mark** — `viewBox="0 0 24 24"`, `fill-rule="evenodd" clip-rule="evenodd"`, `fill="currentColor"`:

```
M15.0223 1.46977C15.3152 1.17693 15.791 1.17689 16.0838 1.46977C16.3763 1.76259 16.3763 2.23751 16.0838 2.53031C14.5974 4.01669 14.5974 6.42675 16.0838 7.91312C17.5702 9.39907 19.9795 9.39914 21.4657 7.91312C21.7585 7.62034 22.2333 7.62044 22.5262 7.91312C22.8189 8.20592 22.8198 8.68078 22.5272 8.97367C21.1685 10.3325 19.2557 10.7992 17.5155 10.376C17.6669 10.8911 17.7499 11.4359 17.7499 12C17.7498 15.1756 15.1754 17.7499 11.9999 17.75C11.4358 17.75 10.8909 17.6671 10.3758 17.5157C10.8003 19.2564 10.3329 21.1707 8.9735 22.5303C8.6808 22.8228 8.20584 22.8235 7.91295 22.5313C7.62011 22.2385 7.62022 21.7627 7.91295 21.4698C9.39891 19.9835 9.39867 17.5743 7.91295 16.0879C6.42657 14.6015 4.01651 14.6015 2.53014 16.0879C2.2374 16.3802 1.7624 16.3801 1.46959 16.0879C1.17675 15.7951 1.17686 15.3193 1.46959 15.0264C2.82903 13.6671 4.7426 13.1988 6.48326 13.6231C6.33203 13.1083 6.24987 12.5638 6.24986 12C6.24986 8.8244 8.82423 6.25004 11.9999 6.25004C12.5622 6.25005 13.1053 6.33195 13.619 6.48246C13.1952 4.74209 13.6633 2.82892 15.0223 1.46977ZM11.9999 7.75004C9.65265 7.75004 7.74986 9.65283 7.74986 12C7.74989 14.3472 9.65267 16.25 11.9999 16.25C14.347 16.25 16.2498 14.3472 16.2499 12C16.2499 9.65288 14.347 7.75013 11.9999 7.75004Z
```

**Diagonal arrow** — `viewBox="0 0 19.0049 19"`, `fill="currentColor"`:

```
M19.0049 1.18457H19.002V18.3564H17.8047V2.21973L0.84668 19L0 18.1621L17.1582 1.18457H0.453125V0H19.0049V1.18457Z
```

## Fixed parameters (bake these in)

```
root ladder      16px · ≥1024 1.111111vw · scrollbar-gutter stable
breakpoints      md 768 · lg 1024
cap trim         text-box: trim-both cap alphabetic — on by default, globally
radii            4 / 8 / 24 / 48 / 37 / 46 px   blur 10px
durations        150 / 250ms, cubic-bezier(0.2, 0, 0, 1)
leading          1.1 display · 1.2 body
scene            40k helix + 160k ink + 1500 atmo points, MULTIPLY, premultipliedAlpha TRUE
                 camDist 12 fov 45 tilt −0.34 spin 0 parallax 3
                 emitRate 0.19 spread 0.6 rise −0.2 turbulence 1.6 noiseFreq 1.05 noiseEvolve 0.1
                 pointerRadius 5 pointerStrength 1.55 maxPixelRatio 1.5
                 REFERENCE_BUFFER_HEIGHT 1600 · MAX_FRAME_DELTA 0.05
                 appear delay 0.2s duration 1.6s (WALL clock) · uTime accumulated CLAMPED deltas
                 pointer ease 0.05 / cursor 0.15 / activity 0.08 / idle 3s · NDC clamped ±1
preloader        creep 0.85 (12/30) · complete (90/26) · curtain (90/24) · hold 400ms
                 reveal at inset 60 · min 900ms · gate timeout 6000ms · ring r180 of 400
text motion      blur 12→0 · eyebrow 45/220-28 · heading 55/130-24 gap 0.25em · body 26/150-30
                 element 1.25rem rise · mode ALWAYS
hero cascade     copy 0 · divider 140 · trust 220 · chips 300 · actions 380
cursor trail     spawn 90px · 5 alive · tilt ±12° · enter 130/28 · leave 300/24 · opacity 900/40
services         runway 4vh · overlay 1 · dwell 1 · scrub 220/42
about            overlay −100lvh · banner overscan 0.25 · drift 180/40 · counter 22/26 · threshold 0.2
team rail        glide 0.18 · momentum 14 · settle 0.5px · drag threshold 4px · bar 260/38 · fill 890/1360
```

## Assets

All fetched from `https://storage.getlayers.ai/assets/dantora-ee8f83f9a3/`. Set that as one
`ASSETS` constant and `encodeURI` every path — several folder names contain spaces.

| file | used by |
|---|---|
| `About/banner.png` | the About parallax banner |
| `Our Services/01.png` · `02.png` · `03.png` | the three photo cards |
| `Our Team/01.png` … `05.png` | the five doctor cards |
| `Why Dantora/01.png` … `08.png` | the cursor trail, cycled by spawn index |
| `icons/instagram.svg` · `facebook.svg` · `linkedin.svg` · `twitter.svg` | the team card's social row |

The DNA mark and the diagonal arrow are **inline SVG**, not files — they inherit `currentColor`,
which is the whole point of them being markup.

**A failed asset load must be visible.** Wrap every fetch in a `.catch` that puts a banner on the
page naming the URL that failed. The scene itself loads no assets, so a bucket outage leaves the
helix running and the photographs missing — say so rather than showing empty boxes.
[/hidecontent]
← 返回首页