# LOCUS

> INVOKE THIS SKILL when: making AI-generated images interactive, mapping clickable zones onto generated artwork, warping HTML content onto perspective surfaces in images, defining freeform polygon hotspots, creating hover/click states for elements in AI art, or performing surgical edits on specific regions of generated images. Contains four original techniques: CSI, IQM, HQW, and ADT.

- Skill: `jord0-cmd/locus` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jord0-cmd/locus`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jord0-cmd/locus/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: jord0-cmd (https://skillmd.com/u/jord0-cmd)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jord0-cmd/locus

---


# LOCUS — Interactive Image Toolkit

*Four original techniques for making static AI-generated images interactive.*

Developed by [jord0-cmd](https://github.com/jord0-cmd). These techniques were invented to solve real problems that existing tools don't address: how do you add hover states to AI art? How do you map HTML onto a perspective-distorted screen in a generated image? How do you define clickable zones on irregular shapes? LOCUS answers all of these.

**Requirements**: React 18+ environment. Components use hooks, refs, and modern React patterns. Works with any React framework (Next.js, Vite, Remix, etc.) or standalone React.

---

## Usage

Describe what you want to make interactive — hover states, perspective-warped content, polygon hotspots, or before/after comparisons. LOCUS provides the four techniques (CSI, IQM, HQW, ADT) and the collaborative human-in-the-loop workflow to map coordinates visually.

---

## How This Works — The Human-in-the-Loop

**CRITICAL**: LOCUS is a collaborative toolkit. You (Claude) build the pages and components. The **user** does the visual work in their browser. Do NOT guess coordinates — they will be wrong.

### The Core Loop

```
1. USER describes what they want ("overlay text on that monitor screen")
2. YOU build a tuner/tool page with the right LOCUS component
3. YOU start a dev server and give the user the URL
4. USER opens browser, visually positions/draws/drags things
5. USER copies the output (JSON coordinates) and pastes it back to you
6. YOU wire those exact coordinates into the production components
7. REPEAT until everything looks right
8. YOU ask how they want the final page saved and deliver it
```

**The user's eyes are the instrument.** Pixel-level alignment on AI-generated images cannot be done programmatically — perspective distortion, barrel lens effects, and artistic irregularity make every surface unique. The IQM DebugTuner and ADT drawing tools exist specifically so the human can do what code cannot: look at it and drag it until it's right.

### Dev Server — Required Dependency

Every LOCUS project needs a running dev server so the user can interact with the tools in their browser. When starting a LOCUS workflow:

```bash
# If the project doesn't exist yet, scaffold it:
npx create-next-app@latest locus-project --typescript --tailwind --app --src-dir --use-npm
cd locus-project
npm install dompurify @types/dompurify

# Start the dev server:
npm run dev
# → http://localhost:3000
```

**Always tell the user the URL.** Say: *"I've set up the tuner at http://localhost:3000/tuner — open that in your browser, drag the corners to match the screen edges, then click COPY POSITIONS and paste the JSON back to me."*

If the user doesn't have a project yet, scaffold one. If they do, add the tuner page to their existing project. The dev server is not optional — it is the interface.

### What You Build vs What They Do

| Step | Who | What |
|------|-----|------|
| Scaffold project & install deps | **You** | Create Next.js project, install packages |
| Build tuner/tool pages | **You** | Write DebugTuner, ADT tool, or CSI preview pages |
| Start dev server | **You** | `npm run dev`, give user the URL |
| Drag corners / draw polygons / tune positions | **User** | Visual work in the browser |
| Copy coordinates | **User** | Click COPY, paste JSON back to chat |
| Wire coordinates into production code | **You** | Build HQW warps, CSI overlays, ADT hit zones |
| Visual review | **User** | Check it looks right, request adjustments |
| Final delivery | **You** | Ask format preference, save and clean up |

---

## Security — Read This First

These techniques involve DOM injection, coordinate transforms, and image processing. Before shipping:

- **Sanitize all DOM content** injected via CSI overlays (use DOMPurify)
- **Validate all coordinate inputs** — must be numeric, 0-100 range for percentages
- **Never `eval()` coordinate data** from external sources — use `JSON.parse()` only
- **Validate `matrix3d` inputs** — never pass unsanitized user input to CSS transforms
- **Prevent path traversal** in image file references — no `../` in dynamic paths

Full security checklist at the end of this skill. These are non-negotiable.

---

## When to Invoke This Skill

- Adding hover/click/scroll states to elements within AI-generated images
- Mapping interactive content onto perspective-distorted surfaces (screens, monitors, signs)
- Defining clickable zones on irregular shapes in generated artwork
- Performing surgical edits on specific regions of AI images without affecting the rest
- Fitting HTML/text content into frames, screens, or panels within AI art
- Building interactive portfolios, landing pages, or experiences from AI-generated scene images
- Any time you need to make a static generated image feel alive

---

## The Four Techniques — Quick Reference

| Technique | Full Name | Purpose | Output |
|-----------|-----------|---------|--------|
| **CSI** | Contextual State Injection | AI-generated hover/click/scroll states | State overlay images |
| **IQM** | Interactive Quad Mapping | Visual coordinate tuner — drag corners to map zones | Quad coordinates (%) |
| **HQW** | Homography Quad Warp | Map rectangular content onto perspective surfaces | CSS `matrix3d()` transform |
| **ADT** | Area Drawing Tool | Freeform polygon hotspot definition | Polygon vertices (%) |

**Typical workflow**: IQM maps the TV screen quad -> HQW warps terminal text onto it -> CSI generates the hover glow state -> ADT defines the irregular smoke boundary for particle effects. IQM also positions CSI rectangular zones — same DebugTuner, multiple named quads.

---

## CSI — Contextual State Injection

*Originated in the jord0-cmd workshop, 2026. The technique that makes AI art interactive.*

CSI creates perfectly-integrated hover/click/scroll states for elements within AI-generated images. Instead of manual compositing, you feed the element back to an AI image generator as a reference image with instructions for the target state. The AI handles lighting, color grading, grain, and physical consistency automatically — because the new state is *derived from* the original.

### The CSI Pipeline

**Step 1 — Target**: Identify a static element in the image (switch, monitor, dial, light, gauge, text, sign).

**Step 2 — Extract**: Crop with 10% buffer. Record percentage coordinates relative to canvas:

```json
{
  "id": "panel_indicator_light",
  "source": "scene_base.png",
  "crop": { "top": "34.2%", "left": "18.7%", "width": "8.5%", "height": "12.1%" },
  "states": ["dim", "glow", "flicker"]
}
```

**Step 3 — Generate**: Feed the crop to your AI image generator as a reference image. Prompt for the target state change only — the reference forces consistent lighting, grain, and color:

```
Prompt: "Same element, same angle, same lighting. [STATE: The indicator light is now glowing bright amber]"
Reference: cropped_element.png
Aspect ratio: match the crop
```

This works for ANY visual change — lights, screens, mechanical parts, weather, damage states, time-of-day shifts. The reference image is the key innovation: it constrains the generator to produce only the state change, not a new interpretation.

**Step 4 — Integrate**: Position the overlay at the original coordinates. See the CSIOverlay component below.

**Step 5 — Blend**: Match black levels and color temperature to the base plate. Apply post-generation color grading if the generated state drifts slightly from the original palette.

### The CSIOverlay Component

```tsx
'use client'

import { useState, useCallback } from 'react'
import Image from 'next/image'
import DOMPurify from 'dompurify'

interface CSIOverlayProps {
  /** Path to the default/inactive state image */
  base: string
  /** Path to the active state image */
  active: string
  /** Position as percentage coordinates relative to the parent container */
  position: { top: string; left: string; width: string; height: string }
  /** Interaction trigger type */
  trigger: 'hover' | 'click' | 'scroll' | 'proximity'
  /** CSS mix-blend-mode for compositing */
  blendMode?: string
  /** Fade duration in seconds */
  transition?: number
  /** Optional accessible label for the interactive zone */
  ariaLabel?: string
  /** Optional HTML content to overlay (will be sanitized) */
  overlayContent?: string
}

/**
 * Sanitize any HTML content before injecting into the DOM.
 * Uses DOMPurify to strip scripts, event handlers, and dangerous attributes.
 */
function sanitizeContent(html: string): string {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['span', 'div', 'p', 'strong', 'em', 'br'],
    ALLOWED_ATTR: ['class', 'style'],
  })
}

export function CSIOverlay({
  base,
  active,
  position,
  trigger,
  blendMode = 'normal',
  transition = 0.3,
  ariaLabel,
  overlayContent,
}: CSIOverlayProps) {
  const [isActive, setIsActive] = useState(false)

  const handlers =
    trigger === 'hover'
      ? {
          onMouseEnter: () => setIsActive(true),
          onMouseLeave: () => setIsActive(false),
        }
      : trigger === 'click'
        ? { onClick: () => setIsActive((prev) => !prev) }
        : {}

  return (
    <div
      className="absolute cursor-pointer"
      style={{ ...position }}
      role="button"
      tabIndex={0}
      aria-label={ariaLabel}
      aria-pressed={trigger === 'click' ? isActive : undefined}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault()
          setIsActive((prev) => !prev)
        }
      }}
      {...handlers}
    >
      <Image src={base} alt="" fill className="object-cover" />
      <Image
        src={active}
        alt=""
        fill
        className="object-cover"
        style={{
          opacity: isActive ? 1 : 0,
          transition: `opacity ${transition}s ease`,
          mixBlendMode: blendMode as React.CSSProperties['mixBlendMode'],
        }}
      />
      {overlayContent && (
        <div
          className="absolute inset-0 flex items-center justify-center"
          dangerouslySetInnerHTML={{
            __html: sanitizeContent(overlayContent),
          }}
        />
      )}
    </div>
  )
}
```

### CSI Coordinate Mapping

**Critical — The Aspect-Ratio Container Pattern**: Do NOT use `object-fit: contain` on the base image for CSI overlays. The `object-contain` approach creates a gap between the container bounds and the rendered image area — CSS absolute positioning is relative to the *container*, not the letterboxed image, so all CSI zone positions will be offset.

**The correct pattern**: Wrap the image in a container that matches the image's aspect ratio. The image fills the container with `w-full h-full` (no object-fit needed). CSI zones then position correctly because the container IS the image bounds.

```tsx
{/* CORRECT — container matches image, zones align */}
<div className="relative max-w-full max-h-full" style={{ aspectRatio: '1376/768' }}>
  <img src="/scene.png" alt="Scene" className="w-full h-full" draggable={false} />

  {/* CSI zones — positioned relative to the container = image bounds */}
  <div className="absolute" style={{ top: '25%', left: '30%', width: '8%', height: '10%' }}>
    {/* zone content */}
  </div>
</div>

{/* WRONG — object-contain creates gap, zones drift */}
<div className="relative w-full h-full">
  <img src="/scene.png" className="w-full h-full object-contain" />
  <div className="absolute" style={{ top: '25%', left: '30%' }}>
    {/* THIS WILL BE OFFSET — positioned relative to container, not image */}
  </div>
</div>
```

**Note**: The DebugTuner uses `object-contain` internally because its SVG viewBox and percentage calculations account for it. But when building production CSI pages with CSS absolute positioning, always use the aspect-ratio container pattern instead.

### Using DebugTuner for CSI Zone Positioning

The DebugTuner is the universal positioning tool for CSI zones — not just HQW quads. A rectangular CSI zone is just a quad where all four corners form an axis-aligned rectangle. Load multiple named quads with distinct colors:

```tsx
// app/tuner/page.tsx — CSI Zone Tuner
'use client'
import { DebugTuner } from '@/components/locus/DebugTuner'

const INITIAL_QUADS = {
  lamp: {
    tl: [20, 22] as [number, number],  // Rough rectangles —
    tr: [32, 22] as [number, number],  // user drags to position
    bl: [20, 42] as [number, number],
    br: [32, 42] as [number, number],
  },
  gauges: {
    tl: [40, 5] as [number, number],
    tr: [74, 5] as [number, number],
    bl: [40, 23] as [number, number],
    br: [74, 23] as [number, number],
  },
  phone: {
    tl: [68, 62] as [number, number],
    tr: [78, 62] as [number, number],
    bl: [68, 78] as [number, number],
    br: [78, 78] as [number, number],
  },
}

export default function CSITunerPage() {
  return (
    <DebugTuner
      imageSrc="/scene.png"
      aspectRatio="1376/768"
      initialQuads={INITIAL_QUADS}
      colors={{ lamp: '#ffaa00', gauges: '#00ffff', phone: '#ff00ff' }}
    />
  )
}
```

The user drags each zone into position, clicks COPY POSITIONS, and pastes the JSON back. Then convert the quad corners to CSS rect positioning:

```typescript
// Convert quad tl/br corners to CSS positioning
// quad.tl = top-left corner, quad.br = bottom-right corner
function quadToCSS(quad: { tl: [number, number]; br: [number, number] }) {
  return {
    top: `${quad.tl[1]}%`,
    left: `${quad.tl[0]}%`,
    width: `${quad.br[0] - quad.tl[0]}%`,
    height: `${quad.br[1] - quad.tl[1]}%`,
  }
}

// Example: user pastes { lamp: { tl: [30.2, 25.2], tr: [38.1, 25.2], bl: [30.2, 34.9], br: [38.1, 34.9] } }
// → quadToCSS gives: { top: '25.2%', left: '30.2%', width: '7.9%', height: '9.7%' }
```

Wire these into CSI overlay `<div>` elements positioned absolutely within the aspect-ratio container.

### What CSI Can Generate

| Category | Example States |
|----------|---------------|
| **Lights** | off, dim, glow, flicker, alarm, color-shift |
| **Screens** | blank, boot sequence, running code, error, screensaver |
| **Mechanical** | lever up/down, dial positions, valve open/closed |
| **Text/Signage** | blank panel, stenciled warning, illuminated text |
| **Damage/Wear** | clean, dirty, damaged, repaired |
| **Weather** | clear, fog, rain on glass, frost |
| **Time-of-Day** | dawn, noon, dusk, night on the same scene |

### Security: DOM Sanitization

Any text content injected via CSI overlays must be sanitized before DOM insertion. The `sanitizeContent` utility above uses DOMPurify to strip scripts, event handlers, and dangerous attributes. Never use `dangerouslySetInnerHTML` with unsanitized input.

```typescript
// Sanitization utility — use for ANY dynamic content in overlays
import DOMPurify from 'dompurify'

export function sanitizeOverlayContent(html: string): string {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['span', 'div', 'p', 'strong', 'em', 'br', 'code'],
    ALLOWED_ATTR: ['class', 'style'],
    FORBID_ATTR: ['onclick', 'onerror', 'onload', 'onmouseover'],
  })
}
```

### Analogue Fork — CSI Without AI Generation

Not every user has access to an AI image generator. The Analogue Fork provides CSS-only state changes for CSI overlays:

```css
/* CSS filter approach — no AI generation needed */
.csi-zone {
  transition: filter 0.3s ease, opacity 0.3s ease;
}

.csi-zone:hover {
  filter: brightness(1.4) contrast(1.1) saturate(1.3);
}

.csi-zone[data-state="glow"] {
  filter: brightness(1.6) saturate(1.5) drop-shadow(0 0 8px var(--color-primary, #39ff14));
}

.csi-zone[data-state="dim"] {
  filter: brightness(0.4) saturate(0.3);
}

.csi-zone[data-state="alarm"] {
  filter: brightness(1.2) hue-rotate(340deg) saturate(2);
}
```

When a user says "I don't have OPTIC / Midjourney / DALL-E (or any AI image generator)" — use CSS filters on the cropped region instead. Less photorealistic than AI-generated states, but functional and immediate. Works with any static image.

---

## IQM — Interactive Quad Mapping

*Originated in the jord0-cmd workshop, February 2026. The technique that makes coordinate mapping drag-and-drop.*

### The Problem IQM Solves

AI-generated images have elements (monitors, screens, panels, signs, windows) that are almost never perfectly rectangular. They have subtle perspective distortion, barrel distortion, or artistic skew. Rectangular `{top, left, width, height}` overlays never fit. You need **quads** — four independent corners that can form any quadrilateral.

Before IQM, mapping coordinates meant: guess percentages, screenshot, compare, nudge, repeat — sometimes 4-5 rounds of trial and error. IQM eliminates this by rendering draggable corner handles directly on the image.

### When to Use IQM

- Mapping screen overlays onto AI-generated monitors
- **Positioning CSI click/hover zones** — the DebugTuner is the universal positioning tool for CSI rectangular zones too (see "Using DebugTuner for CSI Zone Positioning" below)
- Placing interactive content over any AI-generated image
- Mapping clickable zones onto concept art
- Fitting content into perspective-distorted frames
- Defining crop regions for surgical inpainting

### The DebugTuner Component (Drop-In)

Self-contained React component. The **universal positioning tool** for both HQW quad mapping AND CSI rectangular zone placement. Two interaction modes: **drag corners** to reshape individual quad vertices, and **drag inside a quad** to move it as a whole (all four corners shift together). Supports multiple named quads with distinct colors — use one quad per HQW surface or CSI zone. Set `DEBUG_CROP = true` to activate the tuner during development. Set `false` for production.

```tsx
'use client'

import { useCallback, useRef, useState } from 'react'

/** Quad type: each corner is [x%, y%] relative to the image container */
type Quad = {
  tl: [number, number] // top-left
  tr: [number, number] // top-right
  bl: [number, number] // bottom-left
  br: [number, number] // bottom-right
}

interface DebugTunerProps {
  /** Path to the image being mapped */
  imageSrc: string
  /** Aspect ratio of the image (e.g. '1792/592') */
  aspectRatio: string
  /** Initial quad positions — start with rough estimates */
  initialQuads: Record<string, Quad>
  /** Optional color per zone for visual distinction */
  colors?: Record<string, string>
}

export function DebugTuner({
  imageSrc,
  aspectRatio,
  initialQuads,
  colors = {},
}: DebugTunerProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const [quads, setQuads] = useState<Record<string, Quad>>(initialQuads)
  const [dragging, setDragging] = useState<{
    screen: string
    corner: keyof Quad
  } | null>(null)
  const [moving, setMoving] = useState<{
    screen: string
    lastPct: [number, number]
  } | null>(null)
  const [copied, setCopied] = useState(false)

  /** Convert mouse event coordinates to percentage position */
  const getPct = useCallback(
    (clientX: number, clientY: number): [number, number] | null => {
      if (!containerRef.current) return null
      const rect = containerRef.current.getBoundingClientRect()
      return [
        Math.round(((clientX - rect.left) / rect.width) * 1000) / 10,
        Math.round(((clientY - rect.top) / rect.height) * 1000) / 10,
      ]
    },
    [],
  )

  const handleMouseDown =
    (screen: string, corner: keyof Quad) => (e: React.MouseEvent) => {
      e.preventDefault()
      e.stopPropagation()
      setDragging({ screen, corner })
    }

  /** Start whole-quad move when clicking inside a polygon */
  const handleQuadMouseDown = useCallback(
    (screen: string, clientX: number, clientY: number) => {
      const pct = getPct(clientX, clientY)
      if (pct) {
        setMoving({ screen, lastPct: pct })
      }
    },
    [getPct],
  )

  const handleMouseMove = useCallback(
    (e: React.MouseEvent) => {
      const pct = getPct(e.clientX, e.clientY)
      if (!pct) return

      if (dragging) {
        // Corner drag — reshape one vertex
        setQuads((prev) => ({
          ...prev,
          [dragging.screen]: {
            ...prev[dragging.screen],
            [dragging.corner]: [
              Math.max(0, Math.min(100, pct[0])),
              Math.max(0, Math.min(100, pct[1])),
            ],
          },
        }))
      } else if (moving) {
        // Whole-quad move — shift all four corners by the same delta
        const dx = pct[0] - moving.lastPct[0]
        const dy = pct[1] - moving.lastPct[1]
        setQuads((prev) => {
          const q = prev[moving.screen]
          return {
            ...prev,
            [moving.screen]: {
              tl: [q.tl[0] + dx, q.tl[1] + dy] as [number, number],
              tr: [q.tr[0] + dx, q.tr[1] + dy] as [number, number],
              bl: [q.bl[0] + dx, q.bl[1] + dy] as [number, number],
              br: [q.br[0] + dx, q.br[1] + dy] as [number, number],
            },
          }
        })
        setMoving((prev) =>
          prev ? { ...prev, lastPct: pct } : null,
        )
      }
    },
    [dragging, moving, getPct],
  )

  const handleMouseUp = useCallback(() => {
    setDragging(null)
    setMoving(null)
  }, [])

  const copyPositions = () => {
    const text = JSON.stringify(quads, null, 2)
    navigator.clipboard.writeText(text)
    setCopied(true)
    setTimeout(() => setCopied(false), 2000)
    console.log('QUAD_POSITIONS =', text)
  }

  const defaultColor = '#84cc16'

  return (
    <div
      className="relative w-full h-full bg-black flex flex-col items-center justify-center select-none"
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseUp}
    >
      <div
        ref={containerRef}
        className="relative w-full max-w-[95vw] max-h-[80vh]"
        style={{ aspectRatio }}
      >
        {/* Base image */}
        <img
          src={imageSrc}
          alt="IQM Tuner"
          className="absolute inset-0 w-full h-full object-contain"
          draggable={false}
        />

        {/* SVG polygon outlines — interactive for whole-quad move */}
        <svg
          className="absolute inset-0 w-full h-full"
          viewBox="0 0 100 100"
          preserveAspectRatio="none"
          style={{ pointerEvents: 'none' }}
        >
          {Object.entries(quads).map(([key, q]) => {
            const c = colors[key] ?? defaultColor
            return (
              <polygon
                key={key}
                points={`${q.tl[0]},${q.tl[1]} ${q.tr[0]},${q.tr[1]} ${q.br[0]},${q.br[1]} ${q.bl[0]},${q.bl[1]}`}
                fill={`${c}15`}
                stroke={c}
                strokeWidth="0.3"
                style={{ pointerEvents: 'all', cursor: 'move' }}
                onMouseDown={(e) => {
                  e.preventDefault()
                  handleQuadMouseDown(key, e.clientX, e.clientY)
                }}
              />
            )
          })}
        </svg>

        {/* Draggable corner handles */}
        {Object.entries(quads).map(([screenKey, q]) => {
          const c = colors[screenKey] ?? defaultColor
          return Object.entries(q).map(([cornerKey, [x, y]]) => (
            <div
              key={`${screenKey}-${cornerKey}`}
              className="absolute z-50 cursor-crosshair"
              style={{
                left: `${x}%`,
                top: `${y}%`,
                transform: 'translate(-50%, -50%)',
              }}
              onMouseDown={handleMouseDown(
                screenKey,
                cornerKey as keyof Quad,
              )}
            >
              <div
                className="w-4 h-4 rounded-full border-2 border-black"
                style={{ background: c }}
              />
              <span
                className="absolute left-5 top-[-4px] text-[9px] font-mono whitespace-nowrap px-1 pointer-events-none"
                style={{ color: c, background: 'rgba(0,0,0,0.8)' }}
              >
                {screenKey[0].toUpperCase()}.{cornerKey} [{x.toFixed(1)},{' '}
                {y.toFixed(1)}]
              </span>
            </div>
          ))
        })}

        {/* Zone labels at center of each quad */}
        {Object.entries(quads).map(([key, q]) => {
          const cx = (q.tl[0] + q.tr[0]) / 2
          const cy = (q.tl[1] + q.bl[1]) / 2
          const c = colors[key] ?? defaultColor
          return (
            <div
              key={`label-${key}`}
              className="absolute font-mono text-xs font-bold pointer-events-none"
              style={{
                left: `${cx}%`,
                top: `${cy}%`,
                transform: 'translate(-50%, -50%)',
                color: c,
                textShadow: '0 0 8px rgba(0,0,0,0.9)',
              }}
            >
              {key.toUpperCase()}
            </div>
          )
        })}
      </div>

      <div className="mt-4 flex gap-4 font-mono text-xs">
        <button
          onClick={copyPositions}
          className="px-4 py-2 bg-lime-500 text-black font-bold tracking-wider cursor-pointer hover:bg-lime-400 transition-colors"
        >
          {copied ? 'COPIED!' : 'COPY POSITIONS'}
        </button>
        <span className="text-gray-500 self-center">
          Drag corners to reshape. Drag inside a quad to move it. Click COPY when done.
        </span>
      </div>
    </div>
  )
}
```

### Using the Tuner Output

The tuner exports JSON with percentage coordinates:

```json
{
  "left": {
    "tl": [4.3, 12.1],
    "tr": [32.6, 15.0],
    "bl": [5.0, 83.7],
    "br": [32.7, 78.1]
  }
}
```

Convert to a positioned element with `clip-path: polygon()`:

```tsx
export interface ScreenQuad {
  tl: [number, number]
  tr: [number, number]
  bl: [number, number]
  br: [number, number]
}

/** Calculate the axis-aligned bounding box of a quad (in %) */
function quadBounds(q: ScreenQuad) {
  const xs = [q.tl[0], q.tr[0], q.bl[0], q.br[0]]
  const ys = [q.tl[1], q.tr[1], q.bl[1], q.br[1]]
  const left = Math.min(...xs)
  const top = Math.min(...ys)
  const right = Math.max(...xs)
  const bottom = Math.max(...ys)
  return { left, top, width: right - left, height: bottom - top }
}

/** Convert quad corners to a CSS clip-path polygon relative to the bounding box */
function quadClipPath(
  q: ScreenQuad,
  bounds: ReturnType<typeof quadBounds>,
) {
  const toLocal = ([x, y]: [number, number]) => {
    const lx = ((x - bounds.left) / bounds.width) * 100
    const ly = ((y - bounds.top) / bounds.height) * 100
    return `${lx.toFixed(2)}% ${ly.toFixed(2)}%`
  }
  return `polygon(${toLocal(q.tl)}, ${toLocal(q.tr)}, ${toLocal(q.br)}, ${toLocal(q.bl)})`
}

// Usage: position with bounding box, clip to exact quad shape
const bounds = quadBounds(quad)
const clipPath = quadClipPath(quad, bounds)

<div
  style={{
    position: 'absolute',
    top: `${bounds.top}%`,
    left: `${bounds.left}%`,
    width: `${bounds.width}%`,
    height: `${bounds.height}%`,
    clipPath,
  }}
>
  {/* Your overlay content — images, video, interactive elements */}
</div>
```

### Expanding Quads Programmatically

If overlays are slightly too small (showing slivers of the underlying image at the edges), push all corners outward from the centroid:

```tsx
function expandQuad(q: ScreenQuad, amount: number): ScreenQuad {
  const cx = (q.tl[0] + q.tr[0] + q.bl[0] + q.br[0]) / 4
  const cy = (q.tl[1] + q.tr[1] + q.bl[1] + q.br[1]) / 4
  const push = ([x, y]: [number, number]): [number, number] => {
    const dx = x - cx
    const dy = y - cy
    const len = Math.sqrt(dx * dx + dy * dy)
    if (len === 0) return [x, y]
    return [x + (dx / len) * amount, y + (dy / len) * amount]
  }
  return {
    tl: push(q.tl),
    tr: push(q.tr),
    bl: push(q.bl),
    br: push(q.br),
  }
}
```

### The Full IQM Pipeline — Collaborative Workflow

IQM is a **human-interactive** tool. You build the tuner page; the user does the visual positioning. Do NOT hardcode quad coordinates by guessing — they will not align.

**Step 1: BUILD THE TUNER PAGE**

You create a dedicated route with the DebugTuner component, the user's image, and rough initial quad estimates:

```tsx
// app/tuner/page.tsx — You build this
'use client'
import { DebugTuner } from '@/components/locus/DebugTuner'

const INITIAL_QUADS = {
  screen_left: {
    tl: [15, 10] as [number, number],   // Rough guesses — the user
    tr: [35, 10] as [number, number],   // will drag these to the
    bl: [15, 45] as [number, number],   // correct positions
    br: [35, 45] as [number, number],
  },
}

export default function TunerPage() {
  return (
    <DebugTuner
      imageSrc="/scene.png"
      aspectRatio="1376/768"
      initialQuads={INITIAL_QUADS}
      colors={{ screen_left: '#39ff14' }}
    />
  )
}
```

**Step 2: START THE SERVER & DIRECT THE USER**

```bash
npm run dev
```

Tell the user exactly what to do:

> *"I've built the quad tuner at **http://localhost:3000/tuner** — open that in your browser. You'll see green dots on the image. Drag each corner so the quad outline sits exactly on the edges of the monitor screen. When it looks right, click **COPY POSITIONS** and paste the JSON back to me."*

Be specific. Name the URL. Explain what they'll see. Explain what to do.

**Step 3: USER TUNES IN THE BROWSER**

The user opens the URL, sees the image with draggable neon corner handles, and visually aligns the quad to the target surface. This is the step that makes LOCUS precise — no amount of coordinate guessing replaces the human eye.

**Step 4: USER PASTES COORDINATES BACK**

The user clicks COPY POSITIONS and pastes JSON like this into the chat:

```json
{
  "screen_left": {
    "tl": [17.2, 8.4],
    "tr": [34.8, 7.9],
    "bl": [17.5, 43.2],
    "br": [34.6, 42.8]
  }
}
```

**Step 5: YOU WIRE THE COORDINATES INTO PRODUCTION CODE**

Take the user's exact coordinates and build HQW warps, CSI overlays, clip-path elements — whatever the project needs.

**Step 6: REVIEW & ITERATE**

The user views the result on the dev server. If something's off, update the tuner with new quads and repeat. Each round gets closer. Usually 1-2 rounds is enough.

**Why this works**: The image IS the interface. The human eye does what pixel analysis and verbal descriptions cannot. Trying to skip the human step produces overlays that are always slightly wrong — and "slightly wrong" on a perspective surface looks terrible.

The `DebugTuner` component is reused for both steps: mapping source crops AND aligning final overlays. Same tool, different images, different purposes.

---

## HQW — Homography Quad Warp

*Originated in the jord0-cmd workshop, February 2026. The technique that makes text look painted on the glass.*

IQM maps coordinates and clips to shape. HQW goes further — it **warps the actual content** (text, HTML, anything) to match an arbitrary quadrilateral using CSS `matrix3d()`. Same math as OpenCV's `getPerspectiveTransform()`, but running in the browser with zero dependencies.

### When to Use HQW vs IQM

| Technique | Use When | Content Behavior |
|-----------|----------|------------------|
| **IQM + clip-path** | Overlaying images/video onto a quad | Content stays rectangular, clipped to quad shape |
| **HQW + matrix3d** | Overlaying text/HTML onto a surface with perspective | Content distorts to match the surface — text narrows, slopes, follows the plane |

If the TV screen narrows to the right and slopes downward, `clip-path` just crops — the text stays rectangular inside the crop. HQW makes the text perspective-match the surface, as if projected onto the glass.

### The Core Function (Zero Dependencies)

```javascript
/**
 * Compute CSS matrix3d() that warps a rectangle to an arbitrary quad.
 * Uses homography via Direct Linear Transform (DLT).
 *
 * @param {number} w - Source rectangle width (pixels)
 * @param {number} h - Source rectangle height (pixels)
 * @param {Array<{x:number, y:number}>} dst - Four destination corners: [TL, TR, BR, BL]
 * @returns {string} CSS transform value: "matrix3d(...)"
 *
 * CRITICAL: Set transform-origin: 0 0 on the element.
 * SECURITY: Validate all coordinate inputs are finite numbers before calling.
 */
function computeQuadWarp(w, h, dst) {
  // Validate inputs
  if (
    typeof w !== 'number' || typeof h !== 'number' ||
    !Number.isFinite(w) || !Number.isFinite(h) ||
    w <= 0 || h <= 0
  ) {
    console.error('computeQuadWarp: invalid source dimensions')
    return ''
  }
  for (const pt of dst) {
    if (!Number.isFinite(pt.x) || !Number.isFinite(pt.y)) {
      console.error('computeQuadWarp: non-finite coordinate detected')
      return ''
    }
  }

  const src = [
    { x: 0, y: 0 },
    { x: w, y: 0 },
    { x: w, y: h },
    { x: 0, y: h },
  ]

  function solve3x3(A, b) {
    const det =
      A[0] * (A[4] * A[8] - A[5] * A[7]) -
      A[1] * (A[3] * A[8] - A[5] * A[6]) +
      A[2] * (A[3] * A[7] - A[4] * A[6])
    if (det === 0) return null
    const invDet = 1 / det
    const adj = [
      (A[4] * A[8] - A[5] * A[7]) * invDet,
      (A[2] * A[7] - A[1] * A[8]) * invDet,
      (A[1] * A[5] - A[2] * A[4]) * invDet,
      (A[5] * A[6] - A[3] * A[8]) * invDet,
      (A[0] * A[8] - A[2] * A[6]) * invDet,
      (A[2] * A[3] - A[0] * A[5]) * invDet,
      (A[3] * A[7] - A[4] * A[6]) * invDet,
      (A[1] * A[6] - A[0] * A[7]) * invDet,
      (A[0] * A[4] - A[1] * A[3]) * invDet,
    ]
    return [
      adj[0] * b[0] + adj[1] * b[1] + adj[2] * b[2],
      adj[3] * b[0] + adj[4] * b[1] + adj[5] * b[2],
      adj[6] * b[0] + adj[7] * b[1] + adj[8] * b[2],
    ]
  }

  function adj3x3(m) {
    return [
      m[4] * m[8] - m[5] * m[7],
      m[2] * m[7] - m[1] * m[8],
      m[1] * m[5] - m[2] * m[4],
      m[5] * m[6] - m[3] * m[8],
      m[0] * m[8] - m[2] * m[6],
      m[2] * m[3] - m[0] * m[5],
      m[3] * m[7] - m[4] * m[6],
      m[1] * m[6] - m[0] * m[7],
      m[0] * m[4] - m[1] * m[3],
    ]
  }

  function mult3x3(a, b) {
    const c = []
    for (let i = 0; i < 3; i++)
      for (let j = 0; j < 3; j++) {
        let val = 0
        for (let k = 0; k < 3; k++) val += a[3 * i + k] * b[3 * k + j]
        c[3 * i + j] = val
      }
    return c
  }

  function basisToPoints(p1, p2, p3, p4) {
    const m = [p1.x, p2.x, p3.x, p1.y, p2.y, p3.y, 1, 1, 1]
    const s = solve3x3(m, [p4.x, p4.y, 1])
    if (!s) return null
    return [
      m[0] * s[0], m[1] * s[1], m[2] * s[2],
      m[3] * s[0], m[4] * s[1], m[5] * s[2],
      m[6] * s[0], m[7] * s[1], m[8] * s[2],
    ]
  }

  const srcBasis = basisToPoints(src[0], src[1], src[2], src[3])
  const dstBasis = basisToPoints(dst[0], dst[1], dst[2], dst[3])
  if (!srcBasis || !dstBasis) return ''

  const H = mult3x3(dstBasis, adj3x3(srcBasis))
  for (let i = 0; i < 9; i++) H[i] /= H[8]

  // Column-major for CSS matrix3d()
  return `matrix3d(${[
    H[0], H[3], 0, H[6],
    H[1], H[4], 0, H[7],
    0, 0, 1, 0,
    H[2], H[5], 0, H[8],
  ].join(',')})`
}
```

### The Oversized Source Trick

The destination quad on screen might only be ~150x170 pixels. If you set the source div to that size, text overflows and `overflow: hidden` clips it before the transform applies.

**Solution**: Render the source element LARGE (e.g. 500x600px), then let `matrix3d()` scale it down to fit the quad. The text renders comfortably in the large box, and the GPU downsamples it into the destination shape.

```css
.warped-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 500px;          /* Large source — NOT the destination size */
  height: 600px;
  transform-origin: 0 0; /* MANDATORY for homography math */
  background: transparent;
  overflow: hidden;
  /* GPU quality hints */
  backface-visibility: hidden;
  will-change: transform;
  -webkit-font-smoothing: antialiased;
  /* Use comfortable font sizes — matrix3d handles the scaling */
  font-size: 28px;
  padding: 40px 50px;
}
```

### Applying the Warp (with object-fit:contain handling)

When the background image uses `object-fit: contain`, you must calculate where the image actually renders within the container:

```javascript
/**
 * Apply the quad warp to a target element, accounting for object-fit: contain.
 *
 * @param {string} imageSelector - CSS selector for the background image element
 * @param {string} targetSelector - CSS selector for the element to warp
 * @param {ScreenQuad} quad - The quad coordinates (% of image)
 * @param {number} sourceW - Width of the oversized source element
 * @param {number} sourceH - Height of the oversized source element
 */
function applyQuadWarp(imageSelector, targetSelector, quad, sourceW, sourceH) {
  const imgEl = document.querySelector(imageSelector)
  const target = document.querySelector(targetSelector)
  const frame = imgEl.parentElement
  const fw = frame.offsetWidth
  const fh = frame.offsetHeight

  // Get actual image dimensions
  const natW = imgEl.naturalWidth || 1
  const natH = imgEl.naturalHeight || 1
  const imgAspect = natW / natH
  const frameAspect = fw / fh

  // object-fit:contain — calculate actual rendered image position
  let imgW, imgH, imgLeft, imgTop
  if (frameAspect > imgAspect) {
    imgH = fh
    imgW = fh * imgAspect
    imgLeft = (fw - imgW) / 2
    imgTop = 0
  } else {
    imgW = fw
    imgH = fw / imgAspect
    imgLeft = 0
    imgTop = (fh - imgH) / 2
  }

  // Convert quad % to pixel coords relative to container
  const q = quad
  const dst = [
    { x: imgLeft + (q.tl[0] / 100) * imgW, y: imgTop + (q.tl[1] / 100) * imgH },
    { x: imgLeft + (q.tr[0] / 100) * imgW, y: imgTop + (q.tr[1] / 100) * imgH },
    { x: imgLeft + (q.br[0] / 100) * imgW, y: imgTop + (q.br[1] / 100) * imgH },
    { x: imgLeft + (q.bl[0] / 100) * imgW, y: imgTop + (q.bl[1] / 100) * imgH },
  ]

  target.style.transform = computeQuadWarp(sourceW, sourceH, dst)
}

// Recompute on load and resize
const imgEl = document.querySelector('img')
imgEl.addEventListener('load', () => applyQuadWarp(/* ... */))
window.addEventListener('resize', () => applyQuadWarp(/* ... */))
if (imgEl.complete) applyQuadWarp(/* ... */)
```

### The HQW Pipeline

```
Step 1: Generate scene image (AI image generator)
Step 2: Color grade for atmosphere (optional)
Step 3: Build IQM tuner page, start dev server, give user the URL
Step 4: USER drags corners to map the target surface in their browser
Step 5: USER clicks COPY, pastes quad JSON back to chat
Step 6: Apply computeQuadWarp() with oversized source div using those coordinates
Step 7: User reviews result on dev server — iterate if needed
```

**Steps 3-5 are the human-in-the-loop.** See the IQM Collaborative Workflow section above. Do not skip this — guessed coordinates will not align correctly on perspective surfaces.

### Key Gotchas

1. **`transform-origin: 0 0` is MANDATORY** — The default `50% 50%` breaks all the math. Every element using `matrix3d()` from `computeQuadWarp` must have `transform-origin: 0 0`.
2. **`overflow: hidden` clips BEFORE transform** — That is why the oversized source trick exists. Render large, let matrix3d scale down.
3. **CSS rotateX/rotateY CANNOT do this** — They rotate in 3D space; they cannot map to an arbitrary quad. Only `matrix3d()` with homography gives independent corner control.
4. **Convex quads only** — Concave quads produce visual artifacts. All four corners must form a convex polygon.
5. **Pointer events respect the warp** — Links inside warped content are clickable at the warped position.
6. **Recompute on resize** — Pixel coordinates change with the viewport. Always `window.addEventListener('resize', recompute)`.
7. **Use naturalWidth/naturalHeight** — Never assume the image aspect ratio. Read it from the element after load.
8. **Validate coordinate inputs** — Never pass unsanitized user input into `matrix3d()` values. All coordinates must be validated as finite numbers (see the validation in `computeQuadWarp` above).

---

## ADT — Area Drawing Tool

*Originated in the jord0-cmd workshop. Freeform polygon hotspot definition for irregular shap

…(truncated)
