# XWUI3DParticleField

Ambient particle-field backdrop. A few hundred to a few thousand soft glowing
points hang in 3D space, drift slowly, and dissolve into haze with distance. The
two tint colours come from theme tokens, so the field re-colours with the active
theme instead of shipping a fixed palette.

It is a **background**: the canvas is `aria-hidden` and pointer-transparent, and
whatever children the host mounted are re-homed into a content layer **above**
it (`preservesChildren`). Put your headline and CTA in the mount point as usual
and they stay clickable.

three.js loads in its own lazy chunk (dynamic import inside `mountScene()`), so
this component costs nothing to a consumer who never mounts it.

## Lifecycle

Rendering stops when the host scrolls out of the viewport (`IntersectionObserver`)
and when the tab is hidden (`visibilitychange`). Under
`prefers-reduced-motion: reduce` it draws exactly **one** frame and never opens a
rAF loop - the field is still there, in its initial layout, simply still.

## Config

| Option | Type | Default | Notes |
|---|---|---|---|
| `colorA` | string | `--accent-primary` | Near gradient colour. A `--custom-property` is resolved from the theme; anything else is a literal. |
| `colorB` | string | `--accent-light` | Far gradient colour, same rules. |
| `particleCount` | number | `800` | 1-20000, capped by `quality` (low 250, medium 1200). Rebuilds the buffer. |
| `size` | number | `9` | Base point size in px, distance-attenuated. |
| `speed` | number | `1` | Drift/rotation multiplier. `0` freezes the field. |
| `depth` | number | `900` | Z-range in scene units; also sets where the far haze starts. |
| `opacity` | number | `0.85` | Peak point alpha. Additive, so overlaps go toward white. |
| `parallax` | number | `0.35` | Pointer-lean strength. Only applies while `interactive`. |
| `twinkle` | boolean | `true` | Per-particle brightness flicker. Dropped on `quality: low`. |
| `blurRadius` | number | `0` | CSS blur on the canvas, in px. |
| `quality` | `'low' \| 'medium' \| 'high'` | `'high'` | Caps pixel ratio + particle count. |
| `interactive` | boolean | `false` | Canvas takes pointer events **and** the lean turns on. |
| `height` | string | `'100%'` | Host height as a CSS length. |
| `className` | string | - | Extra class on the root. |

Data: `seed?: number` - the same seed reproduces the same layout, so two hosts
can show one identical field instead of two random ones.

## Usage

```ts
import { XWUI3DParticleField } from '@exonware/xwui/basic';

const field = new XWUI3DParticleField(
  document.getElementById('hero')!,
  { seed: 7 },
  { particleCount: 1400, speed: 0.8, interactive: true, quality: 'high' }
);

field.logic.on('ready', (e) => console.log(`${e.detail.particleCount} points`));

// After a runtime theme switch, CSS variables change silently - a canvas
// cannot inherit them, so re-read them explicitly.
field.refreshTheme();
```

As a custom element:

```html
<xwui-3d-particle-field particle-count="1200" speed="0.7" height="480px">
  <h1>Build once. Ship everywhere.</h1>
</xwui-3d-particle-field>
```
