<!-- source: openspec/changes/add-tactical-globe-hero/design.md -->

# Design — add-tactical-globe-hero

> How the globe is built, where the boundaries sit, and which values are load-bearing.
> Companion to `proposal.md`. The reference implementation in
> `/tmp/globe-zip/openspec-globe/reference/` is the source of truth for exact
> geometry math and tuning constants — lift from it. Where this file conflicts
> with the bundle prototype, this file wins.

## Boundary: engine vs. component

Two layers, deliberately split so the geometry is testable without React and
portable to a deck or snapshot later.

```
packages/ui/src/marketing/globe/
├── globe-engine.ts        # framework-agnostic; owns three.js scene + RAF loop
├── globe-engine.types.ts  # GlobeOptions, GlobePalette
├── palette.ts             # GLOBE_PALETTE_HEX const + resolvePalette(surface)
├── TacticalGlobe.tsx      # 'use client' React wrapper; canvas ref + drag + resize + IO
└── index.ts               # re-exports TacticalGlobe only
```

`packages/ui/package.json` gains a single exports entry:

```jsonc
"./marketing/globe": "./src/marketing/globe/index.ts"
```

The flat `"./marketing/*"` wildcard would only expose top-level `.tsx`; the nested
folder needs an explicit subpath so consumers import `@rapoport-studio/ui/marketing/globe`
and the engine / types / palette stay internal.

The engine never imports React. The component never imports `three` directly —
it only calls the engine's methods.

### `globe-engine` public surface

```ts
export interface GlobeEngine {
  init(canvas: HTMLCanvasElement, opts: GlobeOptions): void;
  setRunning(running: boolean): void;  // gated by IntersectionObserver in the wrapper
  resize(): void;
  dispose(): void;                     // cancels RAF, frees geometries/materials, removes mq listener
}
```

There is **no `setOptions`**. Production passes a fixed set of props at mount; the
diff-aware rebuild machinery from the reference prototype would be dead code.
If late-bound prop changes ever become a requirement, add `setOptions` then —
with the dispose-then-rebuild discipline the reference's `rebuildAll()` already
demonstrates.

## GlobeOptions — the prop surface

These map 1:1 to the prototype's tweak keys. Production passes a fixed set; the
defaults below are the **chosen** hero configuration.

| Option | Type | Default | Notes |
|---|---|---|---|
| `surface` | `'light' \| 'dark'` | `'light'` | marketing content surface = light. Indexes `GLOBE_PALETTE_HEX`. |
| `accents` | `'mono' \| 'duo' \| 'ink'` | `'duo'` | `duo` = blueprint grid + terracotta marks (chosen). |
| `secPerRot` | `number` | `60` | seconds per full Y rotation. |
| `tilt` | `number` (deg) | `23.4` | axis tilt on Z. |
| `density` | `number` | `24` | meridian count; parallels = `density/2`. |
| `scale` | `number` | `1` | root group scalar. |
| `markers` | `boolean` | `true` | city dots + halos + arcs. |
| `particles` | `boolean` | `true` | fibonacci point-field. |
| `autorotate` | `boolean` | `true` | suspended under reduced-motion. |

## Palette: hex constants, no oklch reader

`THREE.Color` cannot parse `oklch()`. The bundle's first pass tried to read the
computed CSS custom properties and run a hand-rolled OKLCH → sRGB converter at
`init` time. **Rejected.** CSS Color Module 4 conversion needs proper
gamut-mapping for the studio's P3-ready tokens; the naïve impl gives
slightly-wrong colours or clipping. The marketing content surface ships
single-mode (light); the converter would carry runtime risk for a feature we
never observe in v1.

`palette.ts` ships a `GLOBE_PALETTE_HEX` const with light + dark variants
(dark stored as a forward hedge, never indexed in v1). Values mirror the
canonical hex comments next to oklch tokens in `packages/ui/src/styles/globals.css`.
Each entry carries a code comment pointing back to the token block it came from:

```ts
// Light — synced with packages/ui/src/styles/globals.css :root block.
// If oklch values change, update both files together.
export const GLOBE_PALETTE_HEX = {
  light: {
    blueprint:      0x4a7fb5,   // --color-blueprint       /* #4A7FB5 */
    blueprintLight: 0x6b9fd0,   // --color-blueprint-light /* #6B9FD0 */
    accent:         0xf97316,   // --color-accent          /* #F97316 */
    grid:           0xc9c6bd,   // --color-grid            /* derived from --border */
    ink:            0x1c1c1e,   // --color-ink             /* #1C1C1E */
    dot:            0x9aa6b2,   // dot tint                /* derived; cool-gray */
  },
  dark: { /* mirrored; unused in v1, stored as forward hedge */ },
} as const;
```

`resolvePalette(surface: 'light' | 'dark'): GlobePalette` is a trivial index.

The point-field uses a desaturated cool-gray derived from grid/ink, never a new
brand color. No gradients anywhere — flat `LineBasicMaterial` / `PointsMaterial`
with per-material opacity only.

## Geometry (lift exact math from the reference)

- **lat/lon → vec3:** `phi = (90 - lat)·π/180`, `theta = (lon + 180)·π/180`,
  `x = -r·sinφ·cosθ`, `y = r·cosφ`, `z = r·sinφ·sinθ`, `r = 1`.
- **Meridians:** `density` lines, 128 segments pole-to-pole. Every 3rd at base
  opacity, the rest at 55% of it. **Parallels:** `density/2` circles; the equator
  drawn at base opacity for definition.
- **Point-field:** 1400 points on a fibonacci sphere at `r·1.002`, `PointsMaterial`
  size ~0.011, opacity ~0.5 (light) / ~0.55 (dark), `sizeAttenuation: true`.
- **Markers:** `SphereGeometry` dots at `r·1.004`; hubs 0.022 radius + a
  `RingGeometry` halo (0.03→0.045) that `lookAt`s the surface normal and pulses.
- **Arcs:** `QuadraticBezierCurve3(p0, mid, p1)` where `mid` is the normalized
  midpoint lifted to `r·(1 + 0.18 + dist·0.18)`; 60-point polyline + one
  `SphereGeometry` pulse dot travelling `t ∈ [0,1)` at ~0.06–0.11 /s.

Use **named three imports** (`import { Scene, WebGLRenderer, PerspectiveCamera, Group, BufferGeometry, BufferAttribute, LineBasicMaterial, Line, Points, PointsMaterial, SphereGeometry, MeshBasicMaterial, Mesh, RingGeometry, DoubleSide, QuadraticBezierCurve3, Color, Clock, Vector3 } from 'three'`) instead of `import * as THREE`. Minor cleanliness; the real LCP protection is `dynamic({ ssr: false })` further down, not tree-shaking — don't over-promise bundle reduction.

### Geography (the studio's real footprint)

Hubs (halo + arc origins): **Chișinău** 47.01, 28.86 · **Bucharest** 44.43, 26.10.
Spokes: London, Berlin, Paris, New York, Singapore. Arcs originate from the two
hubs only. Coordinates are real; do not invent cities. Order matters only for the
`LINKS` index pairs — see reference.

## Motion

- Auto-spin advances `manualY` by `2π / secPerRot` per second (delta-timed, not
  per-frame-fixed).
- Pointer drag adds to `manualY` / `manualX` with inertia (`vel *= 0.93`/frame);
  vertical drag clamped to ±0.6 rad so the poles never flip past the horizon.
- Halo rings pulse scale+opacity on a ~2.2 rad/s sine; arc dots interpolate the
  curve. All three suspended when `prefers-reduced-motion: reduce` — engine reads
  the media query at `init` and on `change`, renders exactly one frame, parks the RAF.

## Reduced-motion + visibility + DPR

| Concern | Owner | Mechanism |
|---|---|---|
| `prefers-reduced-motion: reduce` | engine | `matchMedia('(prefers-reduced-motion: reduce)')` at `init` + `change` listener. When `true`, render one frame and skip RAF scheduling. |
| Hero scrolled out of view | wrapper | `IntersectionObserver` on the host `<div>`; calls `engine.setRunning(visible)`. Engine ANDs reduced-motion ∧ visible. |
| `devicePixelRatio` | engine | `renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))` at `init` and `resize`. |

Unmount: wrapper's `useEffect` cleanup calls `engine.dispose()`. Engine cancels RAF, removes the mq listener, disposes every `Geometry`/`Material`/`Texture`, calls `renderer.dispose()`. Wrapper separately disconnects its own `ResizeObserver` and `IntersectionObserver`.

## Hero integration (governance-bound)

### G1 — strict text-free zone (`design-system.md:106`)

The studio rule reads: *"No decorative imagery behind text. No images and no patterns under text content; the canvas behind content is always flat."* The globe satisfies this by never painting under `HeroC`'s text glyphs.

CSS contract on the globe-stage `<div>`:

```css
position: absolute;
top: 0; bottom: 0; right: 0; left: auto;
width: clamp(360px, 55vw, 720px);
z-index: 0;
```

The stage is right-anchored; HeroC's text column lives in the left band. At the matchMedia gate's narrowest mount width (1100 px), the stage is ≈ 605 px on the right + HeroC's text column (`max-w-[60ch]` ≈ 480–540 px including padding) on the left = a comfortable gap, not a touch-line.

The verification step (`verification.md`) runs a runtime DOM-overlay measurement: every `HeroC` text node (`<h1>`, `<p>`, button) bounding rect MUST NOT intersect the canvas bounding rect. Any pixel overlap fails the verification.

### G2 — homepage budget carve-out `D-WB-N`

`web.md:435` reads: *"Total page weight (homepage, gzipped): < 250 KB. Client components only for: nav (sticky behavior), sticky CTA, intake form, locale switcher."*

`three` is ~150 KB gzipped. Even with `dynamic({ ssr: false })` removing it from the LCP critical path, it counts toward homepage total weight. Pre-empted carve-out `D-WB-N`:

- Homepage `/` budget: **~400 KB gzipped** (route-specific exception).
- All other marketing routes: 250 KB gzipped (unchanged).
- Allowlist amendment at archive: add `hero decorative background` as a sanctioned client-component category.

Phase 0 baseline (clean `origin/main`) is measured and recorded in the change folder for documentation, not as a go/no-go gate. After implementation the homepage weight is re-measured and reported in the PR body.

### G3 — no cockpit clichés (`design-system.md:29 / 106`)

The studio rule reads: *"no neon-on-black 'command center' styling, no decorative data-viz, no glow. ... no decorative telemetry, gauge dials, glow."* The bundle prototype's mono HUD (coordinates, `OPEN`, "drag to rotate" caption) is **stripped**. The wrapper renders only the canvas.

Halo ring pulse + travelling arc dots remain. Rationale: the prohibition targets ornamental signal-mimicking (gauges, glow, fake-data ribbons). Halo pulse on a real city marker and dot motion along a real geographic arc are kinetic structure — motion-on-structure, not theatrical glow.

### Mounting

`apps/web/src/components/home/hero-globe-background-c.tsx` is a new `'use client'` component:

```tsx
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';

const TacticalGlobe = dynamic(
  () => import('@rapoport-studio/ui/marketing/globe').then(m => m.TacticalGlobe),
  { ssr: false }
);

export function HeroGlobeBackgroundC() {
  const [enabled, setEnabled] = useState(false);
  useEffect(() => {
    setEnabled(window.matchMedia('(min-width: 1100px) and (pointer: fine)').matches);
  }, []);
  if (!enabled) return null;
  return (
    <div
      aria-hidden="true"
      style={{ position: 'absolute', top: 0, bottom: 0, right: 0, left: 'auto', width: 'clamp(360px, 55vw, 720px)', zIndex: 0 }}
    >
      <TacticalGlobe surface="light" accents="duo" secPerRot={60} density={24} tilt={23.4} markers particles autorotate />
    </div>
  );
}
```

Mount edit in `apps/web/src/app/[locale]/(marketing)/page.tsx`:

```tsx
// existing JSX (unchanged HeroC):
<div style={{ position: 'relative' }}>
  <HeroGlobeBackgroundC />
  <HeroC />
</div>
```

HeroC's source file is not modified. HeroC's existing `<TacticalFrame tone="primary" size="md">` around its content stays.

**Mandatory:** the wrapper's dynamic import has `{ ssr: false }`. `THREE.WebGLRenderer`'s constructor touches `document` and `WebGL2RenderingContext`; without `ssr: false` the Cloudflare Workers SSR pass crashes. This protection is what keeps `three`'s ~150 KB chunk off the critical-path JS — not tree-shaking.

The matchMedia gate at 1100 px is set ABOVE the smallest desktop viewport (900–1024 px) deliberately. At 1100 px the right-anchored globe sits comfortably outside HeroC's text column. At < 1100 px or `pointer: coarse`, the component returns `null` BEFORE the dynamic import resolves — mobile and touch viewports never download the `three` chunk.

## Performance & SSR

- Client component; canvas renders only after mount and only `≥ 1100 px ∧ pointer: fine`.
- Server sends the type-only HeroC (full LCP content); the globe layer hydrates in after.
- `IntersectionObserver` pauses the RAF when the hero scrolls out of view.
- `devicePixelRatio` capped at 2.
- Single renderer, `alpha: true`, `setClearColor(0x000000, 0)` so the ivory/near-black page shows through.
- `dispose()` on unmount frees every geometry/material and cancels the RAF.

## Known limitations

- **Single-instance only.** Engine relies on module-private state; mounting two `<TacticalGlobe>` instances on one page would corrupt the second. Not a concern today (only HeroC uses it). Documented for the future.
- **No live theme-switch.** Engine reads palette at `init`. The marketing content surface ships single-mode (light); the favicon branches on `prefers-color-scheme: dark` but the page chrome itself does not. If a content-surface theme toggle ever lands, palette refresh becomes a follow-up.
