Exclusivebeta
Esper — <mv-esper>
Deep-zoom image inspector built on discrete, addressable steps instead of free pinch and scroll. A lettered grid (A–H × 1–6 by default, with rulers) is laid over the current view; each step is a fixed zoom level (2× by default, up to 16×) around exact coordinates: click or press Enter on a cell, drag a box (snapped to the deepest level that still fits it), repeat “enhance” on the targeted point, track one cell with Shift+arrows, or type commands in a small field (“C4”, “enhance”, “track left”, “out”, “back”, “41.2, 61.8, 4”, several joined with “;”). Every step is recorded in a strip of live-cropped thumbnails you can jump back to, a reticle shows the region you are zooming into (or leaving) and the minimap keeps you oriented. The current view serializes to a short string (“73.47,73.8,8”: center x %, y %, zoom) reflected on the view attribute and, with hash, in the URL, so a teammate’s link reproduces exactly the same crop; mv-view reports the crop in source pixels too. Past hires-at, a high-resolution source is preloaded one step early and faded in on top (and srcset candidates are re-selected for the zoomed size); a chip warns when the view goes beyond the source’s real resolution.
| Category | Media |
|---|---|
| Type | Web Component (<mv-esper>) |
| Status | beta |
| Also installs | input |
| Keywords | exclusive, culture, zoom, deep-zoom, image-viewer, inspector, enhance, grid, coordinates, crop, permalink, share-link, history, breadcrumb, minimap, hires, srcset, qa, inspection, claims, keyboard, command-line |
When to use
- Reviewers inspect product, packaging or board photos for defects and must point teammates to the exact spot
- Insurance or inspection photos must be examined step by step with a trail of what was zoomed and where
- A diagram, floor plan or map needs keyboard-complete zoom with named grid cells instead of free panning
- A design review or bug report should link to a precise crop of a screenshot rather than describe where to look
Avoid when
- Shoppers just want a quick magnified look under the cursor while browsing → use Lens instead
- A product page needs several photos with thumbnails, swipe and hover zoom → use Product Gallery instead
- The image is a gigapixel slide or satellite map that needs streamed tiles and continuous pinch zoom
Install
node scripts/add.mjs esper --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["esper"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/esper/esper.js, components/esper/esper.css, components/input/input.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="es-demo" style="width:min(100%,74rem);margin-inline:auto">
<style>
#es-demo { display:grid; gap:1.5rem }
#es-demo .es-layout { display:grid; grid-template-columns:minmax(0,1fr) 18.5rem; gap:1.25rem; align-items:start }
#es-demo .es-panel { display:grid; gap:1rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#es-demo .es-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
#es-demo .es-head h3 { margin:.25rem 0 .125rem; font-size:.9375rem; letter-spacing:-.01em }
#es-demo .es-head p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
#es-demo .es-findings { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#es-demo .es-finding { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.375rem .5rem; align-items:center; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#es-demo .es-finding[data-active] { border-color:color-mix(in oklab, var(--mv-accent) 55%, var(--mv-border)); background:var(--mv-accent-subtle) }
#es-demo .es-finding b { grid-column:1; font-weight:600; line-height:1.35 }
#es-demo .es-finding .mv-badge { justify-self:start }
#es-demo .es-finding .mv-button { grid-column:2; grid-row:1 / span 3 }
#es-demo .es-finding code, #es-demo .es-view code { color:var(--mv-fg-muted); font:.6875rem/1.3 var(--mv-font-mono) }
#es-demo .es-view { display:grid; gap:.375rem; padding-top:.875rem; border-top:1px solid var(--mv-border) }
#es-demo .es-view-row { display:flex; align-items:center; justify-content:space-between; gap:.5rem }
#es-demo .es-view code { padding:.375rem .5rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg); font-size:.75rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#es-demo .es-log { min-height:2.25rem; margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.5 }
#es-demo .es-switches { display:grid; gap:.25rem; padding-top:.75rem; border-top:1px solid var(--mv-border) }
#es-demo .es-switches .mv-choice { font-size:.8125rem }
#es-demo .es-claim { display:grid; grid-template-columns:17rem minmax(0,1fr); gap:1.25rem; align-items:start; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#es-demo .es-claim h3 { margin:.25rem 0 .375rem; font-size:.9375rem }
#es-demo .es-claim p { margin:0 0 .75rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#es-demo .es-claim dl { display:grid; grid-template-columns:auto 1fr; gap:.25rem .75rem; margin:0 0 1rem; font-size:.75rem }
#es-demo .es-claim dt { color:var(--mv-fg-muted) }
#es-demo .es-claim dd { margin:0; font-variant-numeric:tabular-nums }
#es-demo .es-actions { display:flex; flex-wrap:wrap; gap:.5rem }
@media (max-width:60rem) {
#es-demo .es-layout, #es-demo .es-claim { grid-template-columns:minmax(0,1fr) }
}
</style>
<!-- Main: pre-shipment QA of a controller board photo (low-res JPEG + high-res source past 2×) -->
<div class="es-layout">
<mv-esper id="es-board" grid="8x6" step="2" max-zoom="16" hires-at="2" hash="board" label="Board photo, NW-IO-24 Rev C">
<img id="es-board-img" alt="Top side of the NW-IO-24 Rev C controller board on an anti-static mat" width="1500" height="1000">
</mv-esper>
<aside class="es-panel" aria-label="Inspection report">
<div class="es-head">
<span class="es-k">Pre-shipment QA</span>
<h3>NW-IO-24 Rev C · SN 26-091-0417</h3>
<p>Lot 2451 · Inspected Sep 23, 2026 by Priya Raman</p>
</div>
<ol class="es-findings" id="es-findings">
<li class="es-finding" data-view="73.47,73.8,8">
<span class="mv-badge" data-variant="danger"><span class="mv-badge-dot"></span>Critical</span>
<b>Solder bridge, U7 pins 3–4</b>
<code>73.47,73.8,8</code>
<button type="button" class="mv-button" data-variant="outline" data-size="sm">Show</button>
</li>
<li class="es-finding" data-view="55.67,54,8">
<span class="mv-badge" data-variant="warning"><span class="mv-badge-dot"></span>Major</span>
<b>Capacitor C14 not placed</b>
<code>55.67,54,8</code>
<button type="button" class="mv-button" data-variant="outline" data-size="sm">Show</button>
</li>
<li class="es-finding" data-view="42.67,46.9,8">
<span class="mv-badge" data-variant="secondary">Minor</span>
<b>U1 lot code reads 2447, BOM says 2451</b>
<code>42.67,46.9,8</code>
<button type="button" class="mv-button" data-variant="outline" data-size="sm">Show</button>
</li>
</ol>
<div class="es-view">
<span class="es-k">Current view</span>
<div class="es-view-row">
<code id="es-view">50,50,1</code>
<button type="button" class="mv-button" data-size="sm" id="es-pin">Pin as finding</button>
</div>
<p class="es-log" id="es-log">Try “enhance”, “track right” or “back” in the command field, or drag a box on the photo.</p>
</div>
<div class="es-switches">
<label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" id="es-sw-cmd" checked><span class="mv-choice-text"><span class="mv-choice-title">Command field</span></span></label>
<label class="mv-choice" data-control="end"><input type="checkbox" role="switch" class="mv-switch" id="es-sw-map" checked><span class="mv-choice-text"><span class="mv-choice-title">Minimap</span></span></label>
</div>
</aside>
</div>
<!-- Variant: insurance claim photo, 6×4 grid, 3× steps, no command field -->
<section class="es-claim" aria-label="Parcel damage claim">
<div>
<span class="mv-badge" data-variant="info"><span class="mv-badge-dot"></span>Under review</span>
<h3>Claim CLM-20931 · Damaged parcel</h3>
<p>Customer photo of the delivered box. Step in 3× at a time; each step is kept so the adjuster can retrace the review.</p>
<dl>
<dt>Declared value</dt><dd>$1,240.00</dd>
<dt>Delivered</dt><dd>Sep 19, 2026</dd>
<dt>Claimant</dt><dd>Amara Okafor</dd>
</dl>
<div class="es-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="es-claim-seal">Tamper seal</button>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="es-claim-corner">Crushed corner</button>
</div>
</div>
<mv-esper id="es-claim" grid="6x4" step="3" max-zoom="9" commands="off" minimap="off" label="Claim photo, delivered parcel">
<img id="es-claim-img" alt="A delivered cardboard parcel with a shipping label, a crushed lower corner and packing tape" width="1200" height="900">
</mv-esper>
</section>
<script type="module">
/* ── Procedural "photos": deterministic SVG, so the demo ships no binary assets ── */
const rng = (seed) => () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<");
function boardSVG(detail) {
const r = rng(11);
const o = [];
const silk = "#eef3ee";
const pin = "#cfd4da";
const gold = "#c9a44c";
const text = (x, y, s, size, fill = silk, extra = "") => o.push(`<text x="${x}" y="${y}" font-family="Arial, Helvetica, sans-serif" font-size="${size}" fill="${fill}" ${extra}>${esc(s)}</text>`);
o.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 1000" width="${detail ? 7200 : 1500}" height="${detail ? 4800 : 1000}">`);
o.push(`<defs>
<linearGradient id="mat" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#34404c"/><stop offset="1" stop-color="#1f262e"/></linearGradient>
<pattern id="matg" width="50" height="50" patternUnits="userSpaceOnUse"><path d="M50 0H0V50" fill="none" stroke="#ffffff" stroke-opacity=".05" stroke-width="2"/></pattern>
<radialGradient id="sheen" cx=".35" cy=".3" r=".9"><stop offset="0" stop-color="#ffffff" stop-opacity=".13"/><stop offset=".6" stop-color="#ffffff" stop-opacity="0"/><stop offset="1" stop-color="#000000" stop-opacity=".18"/></radialGradient>
<linearGradient id="metal" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#eef1f4"/><stop offset=".5" stop-color="#aab1b9"/><stop offset="1" stop-color="#d9dde2"/></linearGradient>
<radialGradient id="can" cx=".4" cy=".35" r=".7"><stop offset="0" stop-color="#f2f4f6"/><stop offset=".7" stop-color="#b7bec6"/><stop offset="1" stop-color="#8e969f"/></radialGradient>
<linearGradient id="gloss" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffffff" stop-opacity=".1"/><stop offset=".5" stop-color="#ffffff" stop-opacity="0"/></linearGradient>
</defs>`);
o.push(`<rect width="1500" height="1000" fill="url(#mat)"/><rect width="1500" height="1000" fill="url(#matg)"/>`);
o.push(`<rect x="132" y="104" width="1260" height="820" rx="26" fill="#000" opacity=".35"/>`);
o.push(`<rect x="120" y="90" width="1260" height="820" rx="26" fill="#1c6a47" stroke="#124630" stroke-width="5"/>`);
// Traces (45° routing) and vias.
const route = (a, b, w = 4) => {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const mid = Math.abs(dx) > Math.abs(dy) ? `H${b[0] - Math.sign(dx) * Math.abs(dy)}` : `V${b[1] - Math.sign(dy) * Math.abs(dx)}`;
o.push(`<path d="M${a[0]} ${a[1]} ${mid} L${b[0]} ${b[1]}" fill="none" stroke="#2b8a5b" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`);
};
const via = (x, y) => o.push(`<circle cx="${x}" cy="${y}" r="6" fill="${gold}"/><circle cx="${x}" cy="${y}" r="2.4" fill="#0e2f1f"/>`);
for (let i = 0; i < 16; i++) {
const y = 342 + i * 11;
route([530, y], [300 - (i % 4) * 22, 180 + i * 38]);
via(300 - (i % 4) * 22, 180 + i * 38);
route([750, y], [930, 250 + i * 34]);
}
for (let i = 0; i < 16; i++) {
const x = 552 + i * 11;
route([x, 540], [380 + i * 42, 780 + (i % 3) * 18]);
via(380 + i * 42, 780 + (i % 3) * 18);
route([x, 320], [470 + i * 30, 200 - (i % 2) * 20]);
}
route([1080, 671], [1150, 590], 5); route([1150, 530], [1230, 420], 7); route([1230, 300], [1310, 180], 7);
for (let i = 0; i < 22; i++) via(Math.round(160 + r() * 1180), Math.round(140 + r() * 720));
// Mounting holes, fiducials.
for (const [x, y] of [[166, 136], [1334, 136], [166, 864], [1334, 864]]) o.push(`<circle cx="${x}" cy="${y}" r="26" fill="${gold}"/><circle cx="${x}" cy="${y}" r="15" fill="#11161b"/>`);
for (const [x, y] of [[230, 136], [1270, 864], [230, 864]]) o.push(`<circle cx="${x}" cy="${y}" r="12" fill="#124630"/><circle cx="${x}" cy="${y}" r="6" fill="${pin}"/>`);
// QFP microcontroller U1.
const qx = 540;
const qy = 330;
for (let i = 0; i < 16; i++) {
const p = 17 + i * 11;
o.push(`<rect x="${qx + p - 2.5}" y="${qy - 14}" width="5" height="16" rx="1" fill="${pin}"/>`);
o.push(`<rect x="${qx + p - 2.5}" y="${qy + 198}" width="5" height="16" rx="1" fill="${pin}"/>`);
o.push(`<rect x="${qx - 14}" y="${qy + p - 2.5}" width="16" height="5" rx="1" fill="${pin}"/>`);
o.push(`<rect x="${qx + 198}" y="${qy + p - 2.5}" width="16" height="5" rx="1" fill="${pin}"/>`);
}
o.push(`<rect x="${qx}" y="${qy}" width="200" height="200" rx="6" fill="#17191d"/><rect x="${qx}" y="${qy}" width="200" height="200" rx="6" fill="url(#gloss)"/>`);
o.push(`<circle cx="${qx + 22}" cy="${qy + 22}" r="7" fill="#0d0e10" stroke="#2a2d33" stroke-width="1.5"/>`);
text(qx + 100, qy + 96, "NW32F4", 28, "#a3abb3", `text-anchor="middle" font-weight="700" letter-spacing="1"`);
if (detail) {
text(qx + 100, qy + 124, "G7B6 · 2447", 13, "#8d959d", `text-anchor="middle" letter-spacing=".5"`);
text(qx + 100, qy + 142, "TAIWAN ARM®", 9, "#7d858d", `text-anchor="middle" letter-spacing=".8"`);
} else {
o.push(`<rect x="${qx + 62}" y="${qy + 113}" width="76" height="12" rx="2" fill="#8d959d" opacity=".22"/>`);
}
text(qx - 4, qy - 24, "U1", 16, silk, `font-weight="700"`);
// SOIC-8 chips.
const soic = (cx, cy, name, mark, bridge = false) => {
for (let i = 0; i < 4; i++) {
const x = cx - 33 + i * 22;
o.push(`<rect x="${x - 4.5}" y="${cy - 43}" width="9" height="15" rx="1.5" fill="${pin}"/>`);
o.push(`<rect x="${x - 4.5}" y="${cy + 28}" width="9" height="15" rx="1.5" fill="${pin}"/>`);
}
o.push(`<rect x="${cx - 48}" y="${cy - 29}" width="96" height="58" rx="4" fill="#1a1c20"/><rect x="${cx - 48}" y="${cy - 29}" width="96" height="58" rx="4" fill="url(#gloss)"/>`);
o.push(`<circle cx="${cx - 36}" cy="${cy + 16}" r="4" fill="#0c0d0f" stroke="#2b2e34"/>`);
if (detail) text(cx, cy + 4, mark, 12, "#9aa2aa", `text-anchor="middle" letter-spacing=".6"`);
text(cx - 48, cy - 50, name, 14, silk, `font-weight="700"`);
if (bridge) {
const x3 = cx - 33 + 2 * 22;
if (detail) {
o.push(`<path d="M${x3 + 1} ${cy + 33} C${x3 + 7} ${cy + 31.5} ${x3 + 15} ${cy + 31.5} ${x3 + 21} ${cy + 33} C${x3 + 24} ${cy + 36} ${x3 + 23} ${cy + 40} ${x3 + 19} ${cy + 41} C${x3 + 14} ${cy + 39.5} ${x3 + 8} ${cy + 39.5} ${x3 + 3} ${cy + 41} C${x3 - 1} ${cy + 40} ${x3 - 2} ${cy + 36} ${x3 + 1} ${cy + 33}Z" fill="#d6dbe0" stroke="#aeb5bc" stroke-width=".6"/>`);
o.push(`<path d="M${x3 + 6} ${cy + 34.5} C${x3 + 10} ${cy + 33.6} ${x3 + 13} ${cy + 33.6} ${x3 + 17} ${cy + 34.5}" fill="none" stroke="#fff" stroke-width=".9" stroke-linecap="round" opacity=".85"/>`);
} else {
o.push(`<rect x="${x3}" y="${cy + 32}" width="22" height="10" rx="5" fill="${pin}" opacity=".55"/>`);
}
}
};
soic(980, 300, "U2", "24C64 WP");
soic(980, 440, "U3", "TPS562 31A");
soic(330, 690, "U4", "65HVD72");
soic(1170, 560, "U6", "LM393 D");
soic(1080, 700, "U7", "TXS0104", true);
// 0603 passives in rows; C14 is left unplaced (bare pads).
let n = 12;
const part = (x, y, kind, vertical = false, missing = false) => {
const ref = `${kind}${n++}`;
const g = vertical ? `transform="rotate(90 ${x} ${y})"` : "";
o.push(`<g ${g}>`);
o.push(`<rect x="${x - 15}" y="${y - 7}" width="8" height="14" rx="1.5" fill="${pin}"/><rect x="${x + 7}" y="${y - 7}" width="8" height="14" rx="1.5" fill="${pin}"/>`);
if (!missing) {
o.push(`<rect x="${x - 9}" y="${y - 6.5}" width="18" height="13" rx="1" fill="${kind === "R" ? "#15171a" : "#b88a5a"}"/>`);
if (detail && kind === "R") o.push(`<text x="${x}" y="${y + 3}" font-family="Arial" font-size="7.5" fill="#e9ecef" text-anchor="middle">${["103", "472", "220", "10R", "331"][n % 5]}</text>`);
}
o.push(`</g>`);
if (missing) o.push(`<rect x="${x - 21}" y="${y - 12}" width="42" height="24" rx="3" fill="none" stroke="${silk}" stroke-width="1.6" stroke-dasharray="4 3"/>`);
if (detail || missing) vertical ? text(x - 13, y + 3, ref, 9, silk, `text-anchor="end"`) : text(x - 12, y - 16, ref, 9, silk);
return ref;
};
part(835, 510, "C");
part(875, 510, "C");
part(835, 540, "C", false, true); // C14: bare pads
part(875, 540, "R");
for (let i = 0; i < 7; i++) part(560 + i * 30, 290, i % 2 ? "C" : "R");
for (let i = 0; i < 6; i++) part(790, 360 + i * 30, i % 2 ? "R" : "C", true);
for (let i = 0; i < 5; i++) part(900 + i * 34, 800, i % 2 ? "C" : "R");
for (let i = 0; i < 3; i++) part(1000 + i * 34, 380, i % 2 ? "R" : "C");
for (let i = 0; i < 3; i++) part(900, 620 + i * 30, "C", true);
for (let i = 0; i < 4; i++) part(470 + i * 34, 620, i % 2 ? "R" : "C");
for (let i = 0; i < 4; i++) part(1270, 640 + i * 32, "R", true);
// Power inductor, small transistors, a 4-pin wire-to-board connector.
o.push(`<rect x="1068" y="416" width="56" height="56" rx="6" fill="#2a2e34"/><rect x="1068" y="416" width="56" height="56" rx="6" fill="url(#gloss)"/>`);
if (detail) text(1096, 449, "4R7", 12, "#a7afb7", `text-anchor="middle" font-weight="700"`);
text(1068, 408, "L1", 13, silk, `font-weight="700"`);
for (const [x, y, name] of [[440, 440, "Q1"], [440, 500, "Q2"]]) {
o.push(`<rect x="${x - 12}" y="${y - 16}" width="7" height="10" fill="${pin}"/><rect x="${x + 5}" y="${y - 16}" width="7" height="10" fill="${pin}"/><rect x="${x - 3.5}" y="${y + 6}" width="7" height="10" fill="${pin}"/><rect x="${x - 15}" y="${y - 9}" width="30" height="17" rx="2" fill="#1a1c20"/>`);
text(x - 22, y + 4, name, 11, silk, `font-weight="700" text-anchor="end"`);
}
o.push(`<rect x="1100" y="112" width="120" height="44" rx="4" fill="#ece3cf" stroke="#b9ae96"/><rect x="1108" y="120" width="104" height="18" rx="2" fill="#d8ccb1"/>`);
for (let i = 0; i < 4; i++) o.push(`<rect x="${1118 + i * 26}" y="146" width="10" height="16" fill="${pin}"/>`);
text(1228, 142, "J3", 13, silk, `font-weight="700"`);
// Electrolytic caps, crystal, USB-C, LEDs, test points.
for (const [x, y, rr, name] of [[1235, 250, 50, "C31"], [1235, 385, 40, "C32"]]) {
o.push(`<circle cx="${x}" cy="${y}" r="${rr + 8}" fill="none" stroke="${silk}" stroke-width="2"/>`);
o.push(`<circle cx="${x}" cy="${y}" r="${rr}" fill="url(#can)" stroke="#7d858e" stroke-width="2"/>`);
o.push(`<path d="M${x - rr * 0.7} ${y - rr * 0.7} A${rr} ${rr} 0 0 0 ${x - rr * 0.7} ${y + rr * 0.7}" fill="none" stroke="#2c323a" stroke-width="${rr * 0.28}"/>`);
if (detail) o.push(`<path d="M${x - rr * 0.45} ${y} H${x + rr * 0.45} M${x} ${y - rr * 0.45} V${y + rr * 0.45}" stroke="#8a929b" stroke-width="2"/>`);
text(x + rr + 12, y - rr + 8, name, 13, silk, `font-weight="700"`);
}
o.push(`<rect x="430" y="286" width="72" height="30" rx="14" fill="url(#metal)" stroke="#8a939c"/>`);
if (detail) text(466, 305, "16.000", 10, "#4a525b", `text-anchor="middle" font-weight="700"`);
text(430, 278, "Y1", 13, silk, `font-weight="700"`);
o.push(`<rect x="96" y="392" width="88" height="124" rx="12" fill="url(#metal)" stroke="#7f8891" stroke-width="2"/><rect x="112" y="420" width="56" height="68" rx="9" fill="#23282e"/><rect x="124" y="436" width="32" height="36" rx="4" fill="#3a4048"/>`);
text(194, 408, "J1", 13, silk, `font-weight="700"`);
for (const [y, lens, name] of [[720, "#72e29d", "D1"], [760, "#ffc46b", "D2"], [800, "#8fc5ff", "D3"]]) {
o.push(`<rect x="1318" y="${y - 8}" width="30" height="16" rx="2" fill="${pin}"/><rect x="1324" y="${y - 7}" width="18" height="14" rx="2" fill="${lens}"/>`);
text(1292, y + 5, name, 11, silk, `font-weight="700" text-anchor="end"`);
}
for (let i = 0; i < 6; i++) {
const x = 300 + i * 150 + Math.round(r() * 40);
const y = 205 + Math.round(r() * 30);
o.push(`<circle cx="${x}" cy="${y}" r="9" fill="${gold}"/><circle cx="${x}" cy="${y}" r="4" fill="#e6c877"/>`);
if (detail) text(x + 13, y + 4, `TP${i + 1}`, 9, silk);
}
// Header J2 (2×10).
for (let i = 0; i < 10; i++) {
for (const y of [842, 876]) o.push(`<rect x="${426 + i * 34}" y="${y - 12}" width="24" height="24" rx="${i === 0 && y === 842 ? 2 : 12}" fill="${gold}"/><circle cx="${438 + i * 34}" cy="${y}" r="6" fill="#12171c"/>`);
}
o.push(`<rect x="416" y="820" width="346" height="68" rx="4" fill="none" stroke="${silk}" stroke-width="2"/>`);
text(416, 812, "J2", 13, silk, `font-weight="700"`);
// Serial sticker and board title.
o.push(`<rect x="220" y="560" width="150" height="58" rx="4" fill="#f6f5f0"/>`);
for (let i = 0, x = 230; x < 360; i++) {
const w = 1 + Math.floor(r() * 3.2);
if (i % 2 === 0) o.push(`<rect x="${x}" y="568" width="${w}" height="26" fill="#1b1d20"/>`);
x += w + 1;
}
if (detail) text(295, 609, "SN 26-091-0417", 11, "#1b1d20", `text-anchor="middle" font-weight="700" letter-spacing=".3"`);
text(1004, 882, "NORTHWIND IO-24", 30, silk, `font-weight="700" letter-spacing="2"`);
if (detail) text(1006, 900, "REV C · 09/2026 · IPC-A-610 CLASS 2", 11, silk, `letter-spacing="1"`);
o.push(`<rect x="120" y="90" width="1260" height="820" rx="26" fill="url(#sheen)"/>`);
o.push(`</svg>`);
return o.join("");
}
function parcelSVG() {
const r = rng(5);
const o = [];
o.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" width="7200" height="5400">`);
o.push(`<defs>
<linearGradient id="floor" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8e8a84"/><stop offset="1" stop-color="#6c6863"/></linearGradient>
<linearGradient id="card" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d2a56b"/><stop offset="1" stop-color="#b98a52"/></linearGradient>
<linearGradient id="top" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#dcb57f"/><stop offset="1" stop-color="#c79a5f"/></linearGradient>
<radialGradient id="stain" cx=".5" cy=".5" r=".5"><stop offset="0" stop-color="#6b4a25" stop-opacity=".32"/><stop offset=".82" stop-color="#6b4a25" stop-opacity=".2"/><stop offset=".95" stop-color="#5a3c1b" stop-opacity=".42"/><stop offset="1" stop-color="#5a3c1b" stop-opacity="0"/></radialGradient>
</defs>`);
o.push(`<rect width="1200" height="900" fill="url(#floor)"/>`);
for (let i = 0; i < 60; i++) o.push(`<circle cx="${Math.round(r() * 1200)}" cy="${Math.round(r() * 900)}" r="${(r() * 3 + 1).toFixed(1)}" fill="#000" opacity="${(r() * 0.08).toFixed(2)}"/>`);
o.push(`<ellipse cx="610" cy="800" rx="470" ry="40" fill="#000" opacity=".28"/>`);
// Box: top face + front face, crushed lower-right corner.
o.push(`<path d="M190 250 L330 150 L1030 150 L1010 250 Z" fill="url(#top)"/>`);
o.push(`<path d="M190 250 L1010 250 L1010 690 L960 740 L905 790 L190 790 Z" fill="url(#card)"/>`);
o.push(`<path d="M1010 250 L1030 150 L1030 640 L1010 690 Z" fill="#a67943"/>`);
o.push(`<path d="M905 790 L925 735 L975 700 L1010 690 L960 740 Z" fill="#8f6435"/><path d="M905 790 L930 742 M935 770 L965 725 M960 740 L990 705" stroke="#6e4a22" stroke-width="3" fill="none"/>`);
o.push(`<path d="M600 150 L590 250" stroke="#9f7542" stroke-width="3"/>`);
// Tape with a torn tamper strip.
o.push(`<path d="M540 150 L655 150 L648 250 L533 250 Z" fill="#e8d7b5" opacity=".7"/>`);
o.push(`<rect x="533" y="250" width="115" height="160" fill="#e8d7b5" opacity=".62"/>`);
o.push(`<path d="M548 180 L600 176 L612 186 L632 181 L640 196 L552 200 Z" fill="#fff" opacity=".85"/>`);
o.push(`<path d="M600 176 L606 190 L596 205 L610 214" fill="none" stroke="#7a5630" stroke-width="2.2"/>`);
o.push(`<text x="596" y="235" font-family="Arial" font-size="9" fill="#9b2d24" text-anchor="middle" font-weight="700" letter-spacing="1">VOID IF OPENED</text>`);
// Water stain.
o.push(`<ellipse cx="330" cy="620" rx="95" ry="70" fill="url(#stain)"/>`);
// Shipping label.
o.push(`<rect x="660" y="300" width="300" height="230" rx="6" fill="#fbfaf6" stroke="#d8d3c8"/>`);
o.push(`<rect x="660" y="300" width="300" height="40" rx="6" fill="#1d2126"/><text x="676" y="327" font-family="Arial" font-size="20" font-weight="700" fill="#fff" letter-spacing="2">PRIORITY</text><text x="944" y="326" font-family="Arial" font-size="12" fill="#fff" text-anchor="end">2 LB 4 OZ</text>`);
const line = (y, s, size = 11, w = 400) => o.push(`<text x="676" y="${y}" font-family="Arial" font-size="${size}" font-weight="${w}" fill="#23272c">${esc(s)}</text>`);
line(360, "SHIP TO:", 9, 700);
line(376, "AMARA OKAFOR", 12, 700);
line(391, "418 HARBOR STREET, APT 5C");
line(405, "PORTLAND, OR 97209");
for (let i = 0, x = 676; x < 940; i++) {
const w = 1 + Math.floor(r() * 3.4);
if (i % 2 === 0) o.push(`<rect x="${x}" y="420" width="${w}" height="62" fill="#111"/>`);
x += w + 1.2;
}
line(500, "TRK 7741 2093 5518 2270", 11, 700);
line(516, "PACKED 09/17/2026 · STATION 12 · INSPECTED", 7.5);
o.push(`<rect x="220" y="300" width="190" height="84" rx="4" fill="#d9362c" transform="rotate(-4 315 342)"/><text x="315" y="352" font-family="Arial" font-size="34" font-weight="700" fill="#fff" text-anchor="middle" letter-spacing="3" transform="rotate(-4 315 342)">FRAGILE</text>`);
o.push(`<path d="M250 470 l40 -34 l40 34 M270 452 v48 M310 452 v48" stroke="#5b3d1c" stroke-width="5" fill="none" stroke-linecap="round" opacity=".7"/><text x="290" y="528" font-family="Arial" font-size="11" fill="#5b3d1c" text-anchor="middle" opacity=".8">THIS SIDE UP</text>`);
o.push(`</svg>`);
return o.join("");
}
const blobURL = (svg) => URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
// Low-res board: the plain SVG at once, then a soft 960px JPEG made from it (what a phone upload looks like).
const boardImg = document.getElementById("es-board-img");
const lowSVG = blobURL(boardSVG(false));
boardImg.src = lowSVG;
const raster = new Image();
raster.onload = () => {
try {
const c = document.createElement("canvas");
c.width = 960;
c.height = 640;
c.getContext("2d").drawImage(raster, 0, 0, 960, 640);
boardImg.src = c.toDataURL("image/jpeg", 0.78);
} catch {}
};
raster.src = lowSVG;
const board = document.getElementById("es-board");
board.hires = blobURL(boardSVG(true));
const claimImg = document.getElementById("es-claim-img");
claimImg.src = blobURL(parcelSVG());
/* ── Wiring ── */
const $ = (id) => document.getElementById(id);
const viewOut = $("es-view");
const log = $("es-log");
const findings = $("es-findings");
const mark = (view) => {
for (const li of findings.querySelectorAll(".es-finding")) li.toggleAttribute("data-active", li.dataset.view === view);
};
board.addEventListener("mv-view", (e) => {
viewOut.textContent = e.detail.view;
const px = e.detail.sourceRect;
log.textContent = `${e.detail.label} · ${e.detail.zoom}×${px ? ` · ${px.width}×${px.height} px crop` : ""}`;
mark(e.detail.view);
});
board.addEventListener("mv-hires", () => { log.textContent = "Full-resolution source loaded."; });
findings.addEventListener("click", (e) => {
const li = e.target.closest(".mv-button") && e.target.closest(".es-finding");
if (li) board.go(li.dataset.view, { label: li.querySelector("b").textContent });
});
let pinned = 0;
$("es-pin").addEventListener("click", () => {
const view = board.view;
const zoom = board.zoom;
const li = document.createElement("li");
li.className = "es-finding";
li.dataset.view = view;
const badge = document.createElement("span");
badge.className = "mv-badge";
badge.dataset.variant = "outline";
badge.textContent = "Note";
const title = document.createElement("b");
title.textContent = `Pinned view ${++pinned} · ${zoom}×`;
const code = document.createElement("code");
code.textContent = view;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "mv-button";
btn.dataset.variant = "outline";
btn.dataset.size = "sm";
btn.textContent = "Show";
li.append(badge, title, code, btn);
findings.append(li);
mark(view);
log.textContent = `Pinned ${view}. Anyone opening this link lands on the same crop.`;
});
$("es-sw-cmd").addEventListener("change", (e) => { board.commands = e.target.checked ? null : "off"; });
$("es-sw-map").addEventListener("change", (e) => { board.minimap = e.target.checked ? null : "off"; });
const claim = $("es-claim");
$("es-claim-seal").addEventListener("click", () => claim.go("49.75,21.5,9", { label: "Tamper seal" }));
$("es-claim-corner").addEventListener("click", () => claim.go("80,83.33,3", { label: "Crushed corner" }));
// Open mid-inspection (unless the URL already carries a shared view).
customElements.whenDefined("mv-esper").then(() => {
if (!/(^|[#&])board=/.test(location.hash)) board.run("enhance F5; enhance E3; out", { animate: false });
});
</script>
</div>Cultural reference
Blade Runner — Ridley Scott (1982, film). The detective feeds a photograph to a machine and inspects it by spoken, discrete commands (enhance a region, track, stop), each a reproducible step around grid coordinates, until a hidden detail emerges. In the UI, a photo is inspected through addressable grid cells and fixed zoom steps driven by clicks, keys or typed commands, every step is recorded and can be replayed, and the exact view travels as a short link.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
grid | "<columns>x<rows>" | 8x6 | Cells laid over the current view: columns are letters (A…, up to 26), rows are numbers (up to 99). Addresses are relative to the view, so “enhance C4” always means the cell you see labelled C4. |
step | number | 2 | Zoom factor of one step (1.25 to 8). Levels are powers of it: 1, 2, 4, 8, 16… Every view is snapped to a level, which keeps views reproducible. |
max-zoom | number | 16 | Deepest zoom factor; the last level is the largest power of step that does not exceed it. At the deepest level, enhance recenters on the cell instead. |
view | "x,y,zoom" | Current view: center in % of the image width and height, and zoom factor (e.g. "73.47,73.8,8"). Read it, or set it to navigate (recorded as a step). Reflected by the component after every step; also accepts "full". | |
hash | string | Key under which the view is kept in the URL hash (#photo=73.47,73.8,8, merged with other key=value pairs, updated with replaceState, removed at full frame). Read on first connection and on hashchange; enables the Copy link button. | |
hires | URL | High-resolution source, preloaded and decoded once the next step would reach hires-at, then faded in over the base image (never swapped mid-frame). Same crossorigin and referrerpolicy as the <img>. | |
hires-at | number | 2 | Zoom factor from which the high-resolution source is needed (a “Loading detail…” chip shows while it is not ready). |
label | string | Accessible name of the grid (default: the image alt, else “Image”); the zoom level is appended. | |
commands | "off" | Hides the command field (keyboard and pointer steps still work). | |
history | "off" | Hides the step strip (steps are still recorded; back and forward still work). | |
minimap | "off" | Hides the overview inset shown while zoomed. | |
toolbar | "off" | Hides the whole toolbar (readout, command field, buttons), for a fully custom UI driven by the API. | |
history-limit | number | 50 | Maximum recorded steps; the oldest after the first (full frame) are dropped. |
data-zoomed / data-max / data-resolving / data-upscaled / data-dragging / data-pointer | set by the component | On the host: zoomed past 1×, at the deepest level, high-res source loading, view beyond the source resolution (more than 1.5 screen pixels per source pixel), box drag in progress, last interaction was a pointer (cells focused by a click show no keyboard ring or preview). |
Properties
| Name | Type | Description |
|---|---|---|
view | string | Live view string (see the attribute). Setting it navigates. |
zoom | number | Current zoom factor (read-only). |
center | { x, y } | Center of the view in image fractions, 0..1 (read-only). |
rect | { x, y, width, height } | Visible region in image fractions (read-only). |
sourceRect | { x, y, width, height } | null | Visible region in source pixels, from the <img> width/height attributes or its natural size, e.g. to crop server-side for a ticket (read-only). |
steps | Array<{ view, label, action, cell, zoom }> | Recorded steps, oldest first (read-only copy). |
index | number | Position of the current step in steps (read-only). |
target | string | null | Targeted cell in the current view (the point a bare “enhance” goes to), e.g. "E4" (read-only). |
shareURL | string | The current page URL with the view in its hash (key: hash attribute, else "view"). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (labels, button titles, help texts, step captions with {cell}/{dir}/{n}/{zoom}, announceStep with {label}/{zoom}/{x}/{y}…). English defaults. |
grid / step / maxZoom / hires / hiresAt / hash / label / commands / history / minimap / toolbar / historyLimit | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
enhance(cell?) | One step deeper into a cell ("C4", an index, or { col, row }), else into the targeted point, else around the center. At the deepest level it recenters on the cell. Returns false if nothing changed or the step was cancelled. |
pullBack() | One step out around the current center. |
track(direction, cells = 1) | Pans by whole grid cells: "left" | "right" | "up" | "down". Stops at the image edge. |
reset() | Back to the full frame (recorded as a step). |
back() / forward() / goTo(index) | Moves through the recorded steps without recording new ones; a new step after going back drops the steps ahead. |
go(view, { label?, animate? }) | Navigates to a view string or { x, y, zoom } (fractions), snapped to a level and kept inside the image. Recorded with label (default “Linked view”). |
aim(cell) | Targets a cell without zooming: the next bare enhance goes there. |
run(text, { animate? }) | Runs text commands, several separated by “;” or “then”: C4 · enhance [C4] · center C4 · track left|right|up|down [n] · out / pull back · back · forward · reset · zoom 4 · 41.2, 61.8, 4 · stop. Returns false if one was not understood. |
copyLink() | Copies shareURL (Clipboard API, with a fallback) after the cancelable mv-copy. Resolves to true when copied. |
Events
| Name | Description |
|---|---|
mv-step | Cancelable, before any view change. detail: { action: "enhance" | "center" | "box" | "track" | "pull-back" | "reset" | "view" | "history", label, cell, from, to (view strings), zoom }. preventDefault() refuses the step (e.g. lock the zoom until a finding is saved). |
mv-view | After every step. detail: { view, zoom, center, rect, sourceRect, action, label, cell, index }. |
mv-command | Cancelable, for every command typed or passed to run(). detail: { command, known }. preventDefault() means your app handled it (add custom verbs such as “flag”); unknown commands otherwise show an error. |
mv-hires | The high-resolution source is decoded and shown. detail: { src }. |
mv-copy | Cancelable, before the view link is copied. detail: { url, view }. preventDefault() to use your own share sheet. |
Content structure
| Name | Description |
|---|---|
(img) | A direct <img> child with src and/or srcset, alt, and ideally width/height (they give the aspect ratio before load and the source-pixel crop). It stays in the DOM, hidden once upgraded; the viewer renders its own copies (framework-safe) and follows later changes to it. |
CSS classes
| Name | Description |
|---|---|
mv-esper-frame / -view / -ruler / -stage | Card, rulers + stage grid, letter and number rulers (the hovered or focused column and row light up), the clipped stage (aspect ratio of the image). |
mv-esper-canvas / -img / -hires | The transformed layer (scale + translate, transform-origin 0 0), the base image and the high-res layer (data-ready once shown). |
mv-esper-grid / -row / -cell / -tag | role=grid of role=gridcell cells (data-cell="C4", data-target on the targeted one) with their address tag. |
mv-esper-band / -reticle / -minimap / -status | Box being dragged, corner reticle (data-preview, data-active while animating), overview inset with -minimap-view, loading / beyond-resolution chip (data-kind). |
mv-esper-bar / -readout / -zoom / -coords / -at / -command / -tools / -tool / -msg | Toolbar: zoom and center readout, hovered cell, command field (a .mv-input), grouped icon buttons (aria-disabled at the limits), command error line. |
mv-esper-steps / -step / -thumb / -step-num / -step-label / -step-zoom | Step strip: buttons with a live crop thumbnail; aria-current="step" on the current one, data-future on steps ahead after going back. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-esper-ratio | from the image | Aspect ratio of the stage and thumbnails (width / height), set from the <img> width/height or natural size. |
--mv-esper-max-height | 80vh | Maximum stage height; the view narrows and centers to keep the ratio. |
--mv-esper-accent | var(--mv-accent) | Focus, hover, band and targeted-cell tint over the image. |
--mv-esper-ink / --mv-esper-shade | #fff / rgb(8 10 14 / 0.62) | Text and backdrop of the overlays drawn on top of the photo (tags, chip, reticle, minimap frame), chosen to read on any image. |
--mv-esper-grid / --mv-esper-grid-idle | rgb(255 255 255 / 0.26) / 0.1 | Grid line color while the stage is hovered or focused, and at rest. |
Accessibility
The image is exposed as a real ARIA grid (role=grid named after the image and the zoom level, rows of role=gridcell cells named “A1”…“H6”) with a single tab stop and a description of the keys. Arrow keys move between cells, typing a letter then digits (“c4”) jumps to a cell, Home/End go to the row ends (Ctrl for the first and last cell); Enter, Space or + enhances the focused cell, − pulls back, Shift+arrows track the view one cell, Backspace goes back a step (Shift+Backspace forward), 0 returns to the full frame. After a keyboard step, focus follows the content to the cell that now holds the targeted point. A keyboard-focused cell previews, with corner marks, the exact region the next step will show. Every step is announced once in a polite live region (“Enhance C4. 4× zoom, centered at 73% across and 74% down.”, plus “Beyond source resolution.” when relevant); limits are announced instead of failing silently (maximum zoom, full frame, image edge, first or latest step). The targeted cell has aria-selected. The command field has a label, a description of the grammar, aria-invalid and an announced message on unknown commands, and Up/Down recall earlier commands. Toolbar buttons are real buttons with names and shortcut hints, set to aria-disabled (never disabled, so focus is never lost) at the limits. The step strip is a labelled list of buttons (“Step 3 of 4: Enhance E4, 4× zoom”) with a roving tab stop and aria-current="step". Pointer: click a cell to enhance, drag a box, Alt-click to pull back; touch taps enhance and page scrolling is never blocked (boxes need a mouse or pen); the wheel is never hijacked. Reduced motion (OS or data-motion="reduce"): steps are instant, with no reticle flight, minimap glide or spinner. Forced colors: cells, reticle, band and minimap frame use Highlight and system colors. Overlays on the photo use their own ink and shade colors so they read on any image, in light and dark themes.