# Cardinal

> **Category:** Landing Page
> **Effect:** A precision compass rose with 72 tick marks seeking a wandering bearing with spring damping, behind three pulsing magnetic-field rings.
> **Platform:** cmd-code (`openrouter`)
> **Model:** `stealth/space-bunny-alpha` — Space Bunny Alpha (stealth/space-bunny-alpha)
> **License:** Free / MIT — copy, paste, ship.
> **Self-contained:** yes — one `index.html`, no build step, no external media.

---

## 🎬 Live demo

Open [`public/demos/cardinal/index.html`](../public/demos/cardinal/index.html) — generated by the model
above from the prompt below, with no human edits to the markup.

## ✅ Verification

<!-- VERIFICATION:START -->
_Pending — this block is written by `tools/verify.mjs` after the build run._
<!-- VERIFICATION:END -->

## 📋 Prompt

```text
A. WHAT IS BEING BUILT
This document specifies the complete build order for the "CARDINAL" single-viewport cinematic landing page. The page is a high-precision, dark-mode navigation interface. The document title must be exactly `<title>CARDINAL — Precision Navigation</title>`. The HTML tag must be `<html lang="en">`. The viewport rule is `<meta name="viewport" content="width=device-width, initial-scale=1.0">`. The body must enforce a single viewport on desktop: `height: 100dvh; overflow: hidden;` for screens >=901px, and allow native scrolling (`overflow-y: auto; overflow-x: hidden;`) for screens <=900px. 

Do NOT build: any multi-page routing; any WebGL, Three.js, or canvas-based 3D rendering; any React, Vue, Svelte, or framework code; any Tailwind or CSS frameworks; any GSAP, Lottie, or anime.js; any external raster images (jpg/png/gif), external SVGs, video files, or audio files; any base64 encoded media blobs; any custom font files (woff/woff2/ttf); any horizontal scrollbars at 390px, 768px, 1440px, or 1920px; any scroll-jacking or smooth-scroll libraries.

B. THE BACKDROP
The base background is a deep black: `--bg: #050505`. A subtle radial glow sits at z-index 0: `radial-gradient(circle at 50% 50%, rgba(248, 113, 113, 0.08) 0%, rgba(5, 5, 5, 1) 60%)` applied to a pseudo-element covering 100vw and 100vh. 
The magnetic field rings consist of 3 SVG `<circle>` elements inside an SVG with a `0 0 100 100` viewBox. The radii are 30, 40, and 50 user units (which map to 30vmin, 40vmin, 50vmin when the SVG is scaled). The stroke is `var(--accent)` at `0.3` user units wide, with `fill="none"`. They pulse outward using the `pulse-ring` keyframe: scale from 0.8 to 1.2, opacity from 0.8 to 0. Durations are 4s, 5s, and 6s. Delays are 0s, 1.3s, and 2.6s. The entire ring container drifts with a `drift` animation: `transform: translate(-50%, -50%) rotate(360deg)` over 180s linear infinite.
The canvas resolution trick (`canvas.width = canvas.offsetWidth * devicePixelRatio`) is explicitly bypassed because we are using SVG and CSS gradients instead of `<canvas>`. This guarantees the per-frame cost budget with zero rasterization overhead. The per-frame cost budget is strictly 2 DOM writes: the needle's `transform` and the readout's `textContent`. Zero `getBoundingClientRect` calls are made inside the loop. Zero `ctx.filter` or `ctx.shadowBlur` operations exist.

C. THE LAYER STACK
z-index 0: `.bg-glow` (radial gradient pseudo-element).
z-index 1: `#rings` (SVG container for the 3 pulsing magnetic rings).
z-index 2: `#rose-container` (SVG container for the 72 ticks, 4 labels, and 1 needle).
z-index 3: `header` (logo and navigation).
z-index 4: `.hero` (the H1 and the live bearing readout).
z-index 5: `footer` (status indicator and action buttons).

D. TOKEN TABLE
--bg: #050505;
--accent: #f87171;
--accent-dim: rgba(248, 113, 113, 0.2);
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--font-mono: 'Space Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--rose-size: 70vmin;
--tick-length-cardinal: 2.5;
--tick-length-ordinal: 1.5;
--tick-length-minor: 0.8;
--needle-length: 60;
--h1-size: clamp(2.5rem, 6vw, 5rem);
--readout-size: clamp(1rem, 2vw, 1.5rem);
--header-footer-pad: 2rem 3rem;

E. THE COMPOSITION
Header: `display: grid; grid-template-columns: 1fr auto; padding: var(--header-footer-pad); position: absolute; top: 0; width: 100%; box-sizing: border-box; z-index: 3;`. Left side contains the text "CARDINAL" (`font-family: var(--font-sans); font-weight: 600; letter-spacing: 0.5em; font-size: 1rem; color: var(--text-main);`). Right side contains `<nav>` with three links: "TECHNOLOGY", "FLEET", "CONTACT" (`display: flex; gap: 2rem; font-family: var(--font-mono); font-size: 0.75rem; text-transform: uppercase; color: var(--text-muted); text-decoration: none;`).

Hero: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; z-index: 4; pointer-events: none;`. Contains exactly one `<h1>` with the text "NAVIGATE WITH ABSOLUTE CERTAINTY" (`font-size: var(--h1-size); font-weight: 600; letter-spacing: -0.02em; margin: 0; text-transform: uppercase;`). Below it is a `<div id="readout">` showing "BRG 000°" (`font-family: var(--font-mono); font-size: var(--readout-size); color: var(--accent); margin-top: 1rem; font-weight: 700;`).

Footer: `position: absolute; bottom: 0; width: 100%; box-sizing: border-box; padding: var(--header-footer-pad); display: grid; grid-template-columns: 1fr auto; align-items: center; z-index: 5;`. Left side: "STATUS: ONLINE" (`font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; gap: 0.5rem;`) followed by an 8px dot (`width: 8px; height: 8px; background: var(--accent); border-radius: 50%; animation: blink 2s infinite;`). Right side: `.btn-group { display: flex; gap: 1rem; }` containing two buttons.

F. THE UI LANGUAGE
Primary Button: `.btn-primary { background: linear-gradient(135deg, #f87171 0%, #dc2626 100%); color: #ffffff; border: none; padding: 0.75rem 1.5rem; border-radius: 999px; font-family: var(--font-mono); font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.1em; cursor: pointer; transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s; }`. Hover state: `.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(248, 113, 113, 0.3); }`.
Secondary Button: `.btn-secondary { background: transparent; color: var(--text-main); border: 1px solid rgba(248, 113, 113, 0.3); padding: 0.75rem 1.5rem; border-radius: 999px; font-family: var(--font-mono); font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.1em; cursor: pointer; transition: background 0.2s, border-color 0.2s; }`. Hover state: `.btn-secondary:hover { border-color: var(--accent); background: rgba(248, 113, 113, 0.05); }`.
Focus visible: `:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }`.

G. MOTION
Stagger table:
- `header`: `fade-down`, 0.6s, delay 0.1s.
- `line` (ticks): `fade-in`, 0.5s, delay `calc(var(--i) * 0.01s)`.
- `h1`: `fade-up`, 0.8s, delay 0.2s.
- `#readout`: `fade-up`, 0.8s, delay 0.4s.
- `footer`: `fade-up`, 0.6s, delay 0.6s.
- `circle` (rings): `pulse-ring`, 4s/5s/6s, delay 0s/1.3s/2.6s.
- `#needle`: Spring damping via JS (continuous).

Named keyframes:
- `@keyframes pulse-ring { 0% { transform: scale(0.8); opacity: 0.8; } 100% { transform: scale(1.2); opacity: 0; } }`
- `@keyframes drift { from { transform: translate(-50%, -50%) rotate(0deg); } to { transform: translate(-50%, -50%) rotate(360deg); } }`
- `@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }`
- `@keyframes fade-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }`
- `@keyframes fade-down { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }`
- `@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }`

H. RESPONSIVE TABLE
Base (1440x900): `--rose-size: 70vmin;` `--header-footer-pad: 2rem 3rem;`
@media (max-width: 900px): `body { overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; min-height: 100dvh; }`, `#rose-container { position: relative; top: auto; left: auto; transform: none; margin: 10vh auto; }`, `.hero { position: relative; top: auto; left: auto; transform: none; margin-bottom: 10vh; }`, `footer { position: relative; }`.
@media (max-width: 768px): `--rose-size: 80vmin;` `--h1-size: 3rem;`, `header { grid-template-columns: 1fr; gap: 1rem; text-align: center; }`, `nav { justify-content: center; }`.
@media (max-width: 390px): `--rose-size: 90vmin;` `--h1-size: 2rem;`, `footer { grid-template-columns: 1fr; gap: 1.5rem; text-align: center; }`, `.btn-group { justify-content: center; }`.
@media (max-height: 700px): `--rose-size: 50vmin;`, `header, footer { padding: 1rem 2rem; }`.
@media (max-height: 500px): `--rose-size: 40vmin;`, `--h1-size: 1.5rem;`, `header, footer { padding: 0.5rem 1rem; }`.

I. FAILURE GUARDS
Resting opacity: All base CSS sets `opacity: 1` for text and ticks. The `fade-in` and `fade-up` animations end at `opacity: 1` with `forwards`. If JS fails to execute, CSS animations still play and leave all elements perfectly visible.
Needle resting: Initial CSS `transform: rotate(0deg)` is set on `#needle`. If JS fails, the needle remains visibly rendered at 0 degrees (North).
Two-rAF fallback: The JS loop uses `requestAnimationFrame`. If the tab is backgrounded, rAF naturally pauses and `frames` stops incrementing, which is correct. No custom polyfill is required.
Reduced-motion: The CSS includes `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } }`. The JS checks `window.matchMedia('(prefers-reduced-motion: reduce)').matches`. If true, it sets `currentBearing = 45`, updates the DOM exactly once, sets `ready = true`, and deliberately does NOT start the rAF loop or the `setInterval`. The navigation settles instantly and the page remains completely readable and static.

J. THE EXACT JS
```javascript
window.__DEMO__ = { name: 'cardinal', ready: false, frames: 0, effects: [], errors: [] };
window.addEventListener('error', (e) => window.__DEMO__.errors.push(e.message));

const ticksGroup = document.getElementById('ticks');
const ringsGroup = document.getElementById('rings');
const needle = document.getElementById('needle');
const readout = document.getElementById('readout');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// 1. Generate 72 ticks
for (let i = 0; i < 72; i++) {
  const tick = document.createElementNS('http://www.w3.org/2000/svg', 'line');
  const angle = (i * 5) * (Math.PI / 180);
  const isCardinal = i % 18 === 0;
  const isOrdinal = i % 9 === 0 && !isCardinal;
  const length = isCardinal ? 2.5 : (isOrdinal ? 1.5 : 0.8);
  const strokeWidth = isCardinal ? 0.6 : 0.3;
  const rOuter = 48;
  const rInner = rOuter - length;
  tick.setAttribute('x1', (50 + rOuter * Math.sin(angle)).toString());
  tick.setAttribute('y1', (50 - rOuter * Math.cos(angle)).toString());
  tick.setAttribute('x2', (50 + rInner * Math.sin(angle)).toString());
  tick.setAttribute('y2', (50 - rInner * Math.cos(angle)).toString());
  tick.setAttribute('stroke', 'var(--accent)');
  tick.setAttribute('stroke-width', strokeWidth.toString());
  tick.setAttribute('opacity', isCardinal ? '1' : (isOrdinal ? '0.6' : '0.3'));
  tick.style.setProperty('--i', i);
  if (!prefersReducedMotion) {
    tick.style.animation = 'fade-in 0.5s ease-out forwards';
    tick.style.animationDelay = `calc(var(--i) * 0.01s)`;
  }
  ticksGroup.appendChild(tick);
}

// 2. Generate 4 cardinal labels
const labels = [
  { text: 'N', angle: 0 },
  { text: 'E', angle: 90 },
  { text: 'S', angle: 180 },
  { text: 'W', angle: 270 }
];
labels.forEach(l => {
  const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  const angle = l.angle * (Math.PI / 180);
  const r = 42;
  text.setAttribute('x', (50 + r * Math.sin(angle)).toString());
  text.setAttribute('y', (50 - r * Math.cos(angle) + 1.5).toString());
  text.setAttribute('fill', 'var(--text-main)');
  text.setAttribute('font-size', '4');
  text.setAttribute('font-family', 'var(--font-mono)');
  text.setAttribute('text-anchor', 'middle');
  text.textContent = l.text;
  ticksGroup.appendChild(text);
});

// 3. Generate 3 magnetic rings
for (let i = 0; i < 3; i++) {
  const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  circle.setAttribute('cx', '50');
  circle.setAttribute('cy', '50');
  circle.setAttribute('r', (30 + i * 10).toString());
  circle.setAttribute('fill', 'none');
  circle.setAttribute('stroke', 'var(--accent)');
  circle.setAttribute('stroke-width', '0.3');
  if (!prefersReducedMotion) {
    circle.style.animation = `pulse-ring ${4 + i}s ease-out infinite`;
    circle.style.animationDelay = `${i * 1.3}s`;
  }
  ringsGroup.appendChild(circle);
}

// 4. Spring physics for the needle
let currentBearing = 0;
let targetBearing = 0;
let velocity = 0;
const stiffness = 0.08;
const damping = 0.85;

if (prefersReducedMotion) {
  currentBearing = 45;
  targetBearing = 45;
  needle.style.transform = `rotate(${currentBearing}deg)`;
  readout.textContent = `BRG ${Math.round(currentBearing).toString().padStart(3, '0')}°`;
  window.__DEMO__.ready = true;
} else {
  window.__DEMO__.effects.push('spring-needle');
  window.__DEMO__.effects.push('magnetic-pulse');
  
  setInterval(() => {
    targetBearing = Math.random() * 360;
  }, 3500);

  function loop() {
    window.__DEMO__.frames++;
    let diff = targetBearing - currentBearing;
    // Normalize difference to the shortest path (-180 to 180)
    diff = ((diff + 180) % 360 + 360) % 360 - 180;
    const force = diff * stiffness;
    velocity = (velocity + force) * damping;
    currentBearing = (currentBearing + velocity + 360) % 360;

    needle.style.transform = `rotate(${currentBearing}deg)`;
    readout.textContent = `BRG ${Math.round(currentBearing).toString().padStart(3, '0')}°`;
    
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
  window.__DEMO__.ready = true;
}
```


---

TECHNICAL CONTRACT (non-negotiable):

1. Deliver ONE single file: a complete, valid `index.html`. No build step, no bundler,
   no framework, no package install. It must open correctly by double-clicking the file
   and also over http://.
2. Vanilla only. No React, no Tailwind CDN, no GSAP, no Three.js, no jQuery, no Lottie.
   Plain HTML + CSS + modern JavaScript in <style> and <script> tags.
3. NO external media. No .mp4, no .webm, no .gif, no remote images, no base64 blobs of
   photos. Every visual must be generated procedurally with CSS gradients, SVG, or
   <canvas>. This keeps the page free to host and impossible to break with a dead link.
4. Google Fonts is the ONLY permitted network request, and it must be declared with a
   <link rel="preconnect"> plus a system-font fallback stack so the page still looks
   intentional offline. If you prefer, skip it and use a pure system stack.
5. Responsive and correct at 390x844 (phone), 768x1024 (tablet) and 1440x900 (desktop).
   No horizontal scrollbar at any of those widths. Nothing may overflow its container.
6. Accessibility: exactly one <h1>; real <header>/<main>/<footer>/<nav> landmarks; every
   interactive element is a <button> or <a> with an accessible name; visible :focus-visible
   ring; body text contrast ratio >= 4.5:1 against its background.
7. Motion safety: wrap all continuous motion in
   `@media (prefers-reduced-motion: reduce)` rules that reduce it to a static or
   near-static state. The page must remain fully readable and usable with motion off.
8. Performance: no layout thrash. Cache DOM reads, batch writes inside a single
   requestAnimationFrame loop, use transform/opacity for animation, and never allocate
   inside the hot loop. Target 60fps on a 2019 laptop. Total file under 90 KB.
9. Runtime telemetry hook (used by the automated verifier — implement it exactly):
   at the top of your script define
     window.__DEMO__ = { name: '<slug>', ready: false, frames: 0, effects: [], errors: [] };
   then set `ready = true` after the first paint of your main effect, increment
   `frames` once per animation frame inside your rAF loop, and push the name of each
   implemented effect into `effects`. Add a `window.addEventListener('error', ...)`
   handler that pushes messages into `errors`. Do not rename these fields.
10. Zero console output in the happy path. No console.log left behind. No uncaught
    exceptions, no failed network requests.

RESPONSE FORMAT:
Return ONLY the raw HTML inside a single ```html fenced code block. No explanation, no
preamble, no commentary before or after the fence. The file must be complete and
immediately runnable — never truncate, never write "// rest of code here".
```

---

*LandingPagePrompts Prompt Library — free, verified, model-attributed.*
