# Portable image-effects lab

A dependency-free Canvas2D renderer with four mutually exclusive treatments: **Off**, **CRT**, **VHS**, and **Digital glitch**. It edits image pixels only. The labels, controls, captions and prose remain semantic HTML; there is no OCR, synthetic text overlay, full-frame strobe, audio, upload or remote processing service.

The default is **Off**, **20% strength held in reserve**, **seed 17**, and **paused**. The default workshop is a generated editorial hybrid, not hand-authored native-grid pixel art. Downsampling it does not change that provenance.

## Run the standalone

Serve the package over loopback HTTP rather than `file://`. From the repository root:

```sh
python3 -m http.server 8765 --bind 127.0.0.1 --directory skills/pixel-craft
```

Open `http://127.0.0.1:8765/demo/`. Stop the server when finished. From an extracted skill directory, use `--directory .` instead. Do not serve a private checkout publicly.

`demo/index.html` retains an original image and explanation with JavaScript disabled. `tokens.css` is the standalone neutral numeric theme; `standalone.css` owns the outer page. Neither is required by a branded host. The standalone supplies workshop, orbital and synthwave originals plus native warm, cool, neutral and warm-locked studies through `options.images`. All four native studies were checked byte-for-byte against their 384 × 216 decoded source at the default width; none is enlarged into the processing grid.

## Embed the same renderer

Load `demo/lab.css` and map the role variables below in the host. Supply a real static fallback before mounting. Do not import `standalone.mjs` into a host; it targets the standalone's `#pixel-lab`.

```html
<link rel="stylesheet" href="./path/to/demo/lab.css">
<div id="my-lab">
  <img src="./images/source.png" alt="Describe the actual source image.">
  <p>The original is available without JavaScript. Effects are optional and local.</p>
</div>
<script type="module">
  import { mountLab } from './path/to/demo/lab.mjs';
  const lab = mountLab(document.querySelector('#my-lab'), {
    label: 'Image treatment comparison',
    images: [
      { id: 'first', label: 'First study', src: './images/source.png',
        description: 'Describe this image, its provenance and relevant limits.' },
      { id: 'second', label: 'Changed context', src: './images/other.png',
        description: 'A distinct source for comparison.' }
    ],
    processingWidth: 576,
    fps: 12
  });
  const ready = await lab.ready; // true: processed; false: readable fallback
  // Optional host controls, never necessary for the built-in UI:
  await lab.set({ effect: 'vhs', strength: 0.2, seed: 17 });
  // lab.play();                 // explicit motion intent
  // lab.pause();                // freeze current frame, retain intent = paused
  // const png = await lab.exportPNG(2); // Blob; does not itself download
  // lab.destroy();              // on host unmount
</script>
```

### Mount options

| Option | Contract |
|---|---|
| `images` | Nonempty array of `{id, label, src, description?}`. Unique nonempty string IDs; label/src must be strings. First entry is initially selected. The only built-in filename is module-relative `../assets/workshop.png`. No assumptions about other artwork. |
| `src` resolution | URLs resolve against the **host document's base URI**. Only same-origin HTTP(S) URLs are accepted. Cross-origin, `data:`, `file:` and `blob:` sources are rejected before mounting. A remote-image feature would need explicit CORS handling and readback/error tests; this renderer does not silently taint a canvas or proxy images. |
| `label` | Accessible name of the mounted section. Default: `Image effects lab`. |
| `effect` | `off` (default), `crt`, `vhs`, or `glitch`. One treatment, never a stack. |
| `strength` | Finite normalized number, clamped to `0…1`, default `0.2`. UI shows `0…100%`. |
| `seed` | Finite number, floored and clamped to unsigned 32-bit range, default `17`. |
| `processingWidth` | Finite requested maximum logical width, rounded/clamped to `1…768`, default `576`. Native small images are not enlarged. |
| `fps` | Finite number clamped to `1…24`, default `12`. Upper bound on composed frames, not a device-performance promise. |

Numeric settings reject strings, `NaN` and infinities. The effect core also rejects `null`. Invalid `set()` calls reject without partially applying a patch. Finite out-of-range values clamp except PNG scale, whose integer range is strict.

### Controller API

- `ready: Promise<boolean>` — **initial** image load only. Subsequent image changes should await `set()`.
- `set({ effect?, strength?, seed?, image?, processingWidth?, compare?, scale?, time? }): Promise<boolean>` — update settings; `image` is a declared ID. `compare` must be boolean. `time` is finite seconds in `0…1,000,000`, useful for reproducible frozen-frame studies. A treatment or seed change resets time to zero. Image changes reset time after decode. No update starts motion by itself.
- `play()` — records Play intent. Runs only when a processed non-Off, nonzero treatment is visible, on-screen, and allowed by reduced-motion policy.
- `pause()` — cancels Play intent and freezes the current output. Visibility, preference and history restoration cannot override it.
- `reset(): Promise<boolean>` — Off / 20% / seed 17 / comparison off / scale 1 / time zero / paused. Retains selected image and processing width.
- `exportPNG(scale = currentScale): Promise<Blob>` — integer `1`, `2`, `3` or `4`; snapshots the full treated frame at invocation, even during motion. Comparison split and HTML labels are excluded. Failures reject **and** appear in the live status. The built-in Export button additionally downloads the Blob.
- `getState()` — copied state, including `ready`, `playing` (intent), `running` (current loop), `reduced`, `width`, `height`, `error`, `time`, `destroyed`.
- `getPixels({ original = false } = {})` — independent copy of the logical treated RGBA buffer, or the original processing buffer. Returns `null` before load/after release. Mutating it never changes the renderer.
- `destroy()` — idempotent: cancel RAF; abort listeners; disconnect intersection observation; invalidate pending decodes; release image/pixel references and owned Blob URLs; restore the host's original child nodes. Mutating controller methods reject/throw after destroy. Call destroy before mounting another lab into the same element.

The pure module `demo/effects.mjs` exports `EFFECTS`, `bounded`, `normalizeSettings`, and `applyEffect(source, width, height, settings)`. The source must be an RGBA `Uint8ClampedArray` of exactly `width × height × 4`; logical width `1…768`, height `1…2048`. The returned independent array preserves destination alpha and does not mutate the source.

### Stable DOM hooks

The section has `.craft-lab`; layout uses `.craft-workspace`, `.craft-figure`, `.craft-stage`, `.craft-controls`. Select only within your mount element when using hooks; multiple labs have separate radio names.

`data-craft` values: `canvas`, `fallback`, `image`, `caption`, `dimensions`, `treatment`, `description`, `strength`, `strength-value`, `seed`, `width`, `compare`, `compare-labels`, `play`, `reset`, `motion`, `scale`, `export`, `export-note`, `status`, `retry`. Treatment radios use `input[type="radio"][value="off|crt|vhs|glitch"]`. Prefer controller methods for host interactions rather than changing DOM values without dispatching events.

## CSS role contract

`lab.css` consumes these properties; it does **not** set brand numeric values or style the host body. Map them on `:root` or an ancestor of the mount. `tokens.css` supplies neutral standalone values only. The lab uses container queries, so a narrow embed rearranges even on a wide desktop.

| Roles | Use |
|---|---|
| `--craft-field`, `--craft-surface` | Lab field and input/figure surfaces |
| `--craft-ink`, `--craft-muted` | Primary and explanatory text; choose contrast-safe host roles |
| `--craft-line` | Control and separator borders |
| `--craft-accent`, `--craft-on-accent` | Selected state/export fill and its contrasting text |
| `--craft-focus` | Visible keyboard focus outline |
| `--craft-font-body`, `--craft-font-mono` | Controls/prose and short measurement labels |
| `--craft-size-body`, `--craft-size-small` | Base lab text and supporting controls/copy |
| `--craft-space-sm`, `--craft-space-md`, `--craft-space-lg` | Component gaps and padding |
| `--craft-radius` | Control corner radius |
| `--craft-font-display`, `--craft-size-title` | Standalone headline roles; available to the host, not required by the inner renderer |
| `--craft-page-margin` | **Standalone body only**; host owns its own outer margin |

The host should map exact approved theme roles, not copy the neutral swatches into a branded theme. Illustration colors are source content, separate from the interface role system. No runtime fonts, libraries or CDN requests are made.

## What the treatments actually do

These are **artistic pixel-buffer approximations**, not calibrated CRT simulation, physical tape transport, NTSC/PAL signal encoding, or tape-standard emulation. All dimensions below refer to the **logical processing grid**, never CSS pixels or device pixels. Strength blends the treatment into the original at normalized `0…1`; it is not a physical control.

| Treatment | Mechanism and units |
|---|---|
| Off | Exact original **processing-buffer bytes**. It does not promise the encoded source's original resolution or metadata. Any treatment at strength zero also returns an exact independent copy. |
| CRT | Alternate logical rows dim toward `78%` at full strength; radial vignette reduces brightness by up to `22%`; fixed seeded grain is ±`1.5` 8-bit channel levels before strength blending. A low-amplitude beam term moves smoothly (`0.6` radians/second) when explicitly played. No curvature, phosphor subpixel model or calibrated transfer function is claimed. |
| VHS | Luma estimate `0.299 R + 0.587 G + 0.114 B` stays sharper than chroma differences. Chroma is sampled horizontally with five weights `[1,2,3,2,1]/9`; tap spacing and center delay are `d = max(1, round(width/192))` logical pixels. Chroma trails luminance by `d` pixels. Seeded fine noise is ±`4` channel levels, row noise ±`2.5`, before strength blending. The lower `14%` of rows gains smoothly varying horizontal tracking displacement, rising quadratically to at most `1.6%` of grid width. This is RGB-difference artistic reconstruction, not a color-managed YUV decoder. |
| Digital glitch | Seed anchors a `3.5%`-height row band and a block up to `23%` wide × `12%` high, plus a second `2%` row band. These regions alone use bounded source-row displacements (base offsets `2.7%`, `−3.6%`, `1.4%` of grid width) and red-channel delay `2d`. A slow sine modulates local displacement. Most pixels remain untouched; no global blackout or random whole-frame flash. |

Sampling clamps at horizontal edges. All alpha bytes remain those of the destination pixel in the pure effect buffer. Seeds change fixed grain/region placement; motion does **not** randomize the whole frame each tick. No mandatory color quantization, palette reduction, fake OCR or generated text overlays occur.

## Processing and export order

1. Decode the selected same-origin source. A generation guard prevents stale successful **or failed** decodes from replacing a newer selection.
2. Fit within requested width, source native size, and a `2048`-pixel height ceiling; round dimensions to integer pixels (aspect ratio preserved to nearest-pixel precision). Use nearest-neighbor sampling with `imageSmoothingEnabled = false`.
3. Read original RGBA once via `getImageData`. Process a fresh copy from that original, not last frame's output; no cumulative degradation.
4. Put processed bytes into the logical display canvas. Comparison optionally replaces its left half with original bytes; the full treated buffer remains separate.
5. Export a snapshot of that full treated buffer, then draw into an exact `logicalWidth × scale` by `logicalHeight × scale` canvas with smoothing disabled. Encode PNG; do not bake the CSS fit, captions, status, split marker or controls.

Effects **introduce new RGB colors by design**. A strict indexed palette would require an explicit separate final quantization stage (not included), after the effect and before enlargement. Canvas handles its own color conversion and premultiplied-alpha storage; this is not an ICC-accurate image editor, and invisible/partially transparent RGB can be rounded by browser canvas serialization. Tested exact export-block equality uses opaque RGB source artwork. Screen previews can have noninteger CSS scaling; PNG logical dimensions and integer blocks are the precise output contract.

## Motion, fallback and cleanup

- No automatic motion. One timestamp-driven RAF owner, at most 12 composed frames/second by default (configurable upper bound 24). Elapsed time uses timestamps, not frame count; stalled work is clamped and hidden time is not replayed.
- Live reduced-motion changes cancel progression and restore time-zero composition. Removing reduction resumes **only retained Play intent**. Pause during reduction clears that intent.
- Visibility and intersection suspension keep the current frame and reset the clock on resumption. `pagehide` suspends rather than destroys; `pageshow` reconciles all independent restrictions, preserving explicit pause across bfcache.
- Missing images leave the last original fallback and a readable error. Image selection and Retry remain usable. Canvas failure leaves the fallback and disables unusable enhancements. The original standalone HTML remains when JavaScript never runs.
- Controls never move into Canvas. No per-frame screen-reader status announcements. No audio resources exist.

## Verification

Run from the repository root (development dependencies are **test-only**):

```sh
node --test skills/pixel-craft/tests/effects.test.mjs
node skills/pixel-craft/tests/lab.browser.mjs
```

From an extracted skill root, the equivalent paths are `tests/effects.test.mjs` and `tests/lab.browser.mjs`. The browser runner requires resolvable `playwright` and `@axe-core/playwright`; the effect unit tests use only Node's built-in runner. The harness resolves its own package root, serves only that directory on `127.0.0.1` at an ephemeral port, confines canonical file paths beneath the root (including symlinks), and closes both server and browser in `finally`.

`CHROMIUM_PATH` can select an existing compatible browser. Otherwise the runner uses Playwright's **full Chromium executable** (`chromium.executablePath()`), not its headless shell. It removes the bfcache-disabling default and waits for Back navigation at `commit`: a genuine cached restoration does not fire another page `load` event. The headless shell initially reported `BackForwardCacheDisabledForDelegate`; full Chromium exercised real `pageshow.persisted` restoration instead.

Evidence goes under the nearest package root’s `.artifacts/pixel-lab/` by default: `skills/pixel-craft/.artifacts/pixel-lab/` in this repository, or `.artifacts/pixel-lab/` within an extracted skill. These ignored test artifacts are excluded from the export allowlist. `ARTIFACT_DIR` can override this for a fresh-location package check. `browser-report.json` records the actual browser, passed checks and limitations; screenshots and decoded 1×/2×/3×/4× PNGs sit beside it. Failure JSON is diagnostic history, not a pass report.

Recorded local verification: **7 Node effect tests and 29 browser checks passed** in Chromium **153.0.8010.12**, with DPR 1 screenshot/layout checks at 390, 700 and 1440 pixels. All three axe scans had zero violations. Real cached Back restoration preserved both prior Play and explicit Pause. Resource instrumentation returned all owned listener signals, pending RAF handles and intersection observers to zero in three live teardown cycles.

Coverage includes pure-buffer identity/determinism/seed differences/bounds/alpha/frozen stability; actual browser pixels; every decoded pixel in all integer-scale export blocks; download; keyboard/focus; live reduced preference both directions; real offscreen observation; actual bfcache pause preservation; stale decode and teardown; missing image recovery; unavailable Canvas and thrown readback; export encoder failure; finite validation and remote-source rejection; repeated live teardown resource counts; no-JavaScript fallback; 390/700/1440 layouts and axe scans. A hidden-tab test is a **document.hidden handler fixture**, not a real OS background-tab observation. Automated axe is not screen-reader certification. Physical mobile, Safari/Firefox rendering, display color fidelity, human tape-authenticity judgment and native editor transfer remain unclaimed.

Visual review found the initial 700px auto-placed grid separated export controls from their explanatory text. Explicit medium-width grid areas corrected that relationship; the lab also uses container rather than viewport width so a narrow host does not inherit desktop columns. The desktop keeps one landscape image beside restrained controls; the small screen stacks them without replacing HTML text with a pixel font.

## Implementation sources

- MDN, [CanvasRenderingContext2D.imageSmoothingEnabled](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingEnabled): set `false` for sharp nearest-neighbor enlargement.
- MDN, [CanvasRenderingContext2D.getImageData](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/getImageData): original pixel readback, alpha/color handling and security exceptions.
- MDN, [Window.requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame): timestamp-driven advancement and explicit cancellation/suspension.

These platform sources support the implementation contract. Historical tape/display references and artwork rights belong in the package's separate research/provenance references; they are not implied by this approximate renderer.
