import { previewUrl } from "@/config/assets";
import { MeshMode, meshTile } from "@lib/mesh";
...[22, 25, 27].map((n) => `dts/unwrapped/DTS_Unwrapped_by_FRANCO_DUPUY_${n}.jpg`),
...[6, 17, 44].map((n) => `dts/landing-site/DTS_Landing_Site_by_Fanette_Guilloud_${n}.jpg`)
const IDS = KEYS.map((_, i) => `p${i}`);
damp: [0.085, 0.02, 0.3, 5e-3],
const VERT = `#version 300 es
uniform vec4 uRect; // x, y, w, h — css px, y down
uniform vec2 uRes; // css px
uniform vec2 uVel; // smoothed px/frame
vec2 p = uRect.xy + aUv * uRect.zw;
// Paper flex: the center leads, the edges trail the motion. Zero at rest.
p.x -= uVel.x * uBend * (1.0 - sin(3.14159 * aUv.x));
p.y -= uVel.y * uBend * (1.0 - sin(3.14159 * aUv.y));
gl_Position = vec4(p.x / uRes.x * 2.0 - 1.0, 1.0 - p.y / uRes.y * 2.0, 0.0, 1.0);
const FRAG = `#version 300 es
// object-fit: cover crop against the plane's live aspect.
float rc = uRect.z / max(uRect.w, 1.0);
float ri = uImgRes.x / uImgRes.y;
if (rc > ri) { float s = ri / rc; uv.y = (uv.y - 0.5) * s + 0.5; }
else { float s = rc / ri; uv.x = (uv.x - 0.5) * s + 0.5; }
// The photo as-is — no treatment fights the flight.
vec4 t = texture(uImg, uv);
float a = uAlpha * t.a; // the mesh tile's transparent ground stays open
outColor = vec4(t.rgb * a, a);
const s = document.createElement("style");
s.dataset.labStyle = "paste-up";
.pu { position: absolute; inset: 0; user-select: none; }
/* clear of the stage's docs button in the top-left corner */
.pu__tabs { position: absolute; inset-inline-start: 56px;
inset-block-start: var(--s-12); display: flex; gap: 18px; z-index: 3; }
.pu__tab { appearance: none; background: none; border: 0; padding: 2px 0 6px;
font-family: var(--font-mono); font-size: var(--fs-mono-meta);
letter-spacing: 0.06em; text-transform: uppercase; cursor: pointer;
color: var(--ink-muted); transition: color 0.25s var(--ease-in-cubic); }
.pu__tab.is-on { color: var(--ink); transition: color 0.55s var(--ease-out-expo); }
/* one shared underline, slid between tabs (the flip-pill move, small) */
.pu__tabline { position: absolute; inset-block-end: 0; inset-inline-start: 0;
height: 1px; width: 20px; background: var(--ink);
transition: transform 0.55s var(--ease-out-expo), width 0.55s var(--ease-out-expo); }
.pu__field { position: absolute; inset: 52px var(--s-16) 44px; }
.pu__board { position: absolute; inset: 0; display: none; }
.pu__board.is-on { display: grid; }
/* Invisible boxes — almost. A pencil-line frame at the edge of perception
marks the board's layout while textures load; the planes cover it. */
.pu__slot { position: relative; min-height: 0; min-width: 0; cursor: pointer;
border: 1px solid color-mix(in srgb, var(--ink) 8%, transparent); }
.pu__slot:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.pu__slot img { position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; pointer-events: none; }
.pu__tag { position: absolute; inset-inline-start: 0;
inset-block-start: calc(100% + 4px); font-family: var(--font-mono);
font-size: 9px; letter-spacing: 0.16em; color: var(--ink-muted);
text-transform: uppercase; white-space: nowrap; pointer-events: none; }
.pu__index.is-on { grid-template: repeat(2, minmax(0, 1fr)) / repeat(3, minmax(0, 1fr));
gap: 24px 14px; padding-block-end: 16px; }
.pu__spread.is-on { grid-template-columns: minmax(0, 1fr) clamp(90px, 16%, 150px);
gap: 14px; padding-block-end: 16px; }
.pu__rail { display: grid; grid-template-rows: repeat(${N - 1}, minmax(0, 1fr));
gap: 20px 0; min-height: 0; }
.pu__strip.is-on { grid-template-columns: repeat(${N}, minmax(0, 1fr));
gap: 14px; align-content: center; }
.pu__strip .pu__slot { aspect-ratio: 3 / 4; align-self: center; }
.pu__canvas { position: absolute; inset: 0; width: 100%; height: 100%;
z-index: 2; pointer-events: none; }
.pu__hud { position: absolute; inset-inline: var(--s-16);
inset-block-end: var(--s-12); display: flex; justify-content: space-between;
font-family: var(--font-mono); font-size: var(--fs-mono-meta);
letter-spacing: 0.06em; color: var(--ink-muted); text-transform: uppercase;
pointer-events: none; z-index: 3; }
const pad = (n) => String(n).padStart(2, "0");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const wrap = document.createElement("div");
const slot = (id, tag) => `<div class="pu__slot" data-print="${id}" tabindex="0" role="button" aria-label="print ${Number(id.slice(1)) + 1}"><span class="pu__tag">${tag}</span></div>`;
${["index", "spread", "strip"].map((b, i) => `<button class="pu__tab${i === 0 ? " is-on" : ""}" data-board="${b}">${b}</button>`).join("")}
<i class="pu__tabline"></i>
<div class="pu__board pu__index is-on">
${IDS.map((id, i) => slot(id, `PRINT::${pad(i + 1)}`)).join("")}
<div class="pu__board pu__spread">
${IDS.slice(1).map((id) => slot(id, "")).join("")}
<div class="pu__board pu__strip">
${IDS.map((id, i) => slot(id, `${pad(i + 1)}A`)).join("")}
<canvas class="pu__canvas" aria-hidden="true"></canvas>
<span data-pu-board>BOARD::INDEX</span><span>A PRINT BELONGS TO ITS ID</span>
const field = wrap.querySelector(".pu__field");
const canvas = wrap.querySelector(".pu__canvas");
index: wrap.querySelector(".pu__index"),
spread: wrap.querySelector(".pu__spread"),
strip: wrap.querySelector(".pu__strip")
const tabs = [...wrap.querySelectorAll(".pu__tab")];
const tabline = wrap.querySelector(".pu__tabline");
const hudBoard = wrap.querySelector("[data-pu-board]");
const gl = canvas.getContext("webgl2", { alpha: true, antialias: false });
wrap.querySelectorAll("[data-print]").forEach((el) => {
img.src = previewUrl(KEYS[Number(el.dataset.print.slice(1))], 720);
const claims = new Map();
const refreshClaims = () => {
boards[active].querySelectorAll("[data-print]").forEach((el) => claims.set(el.dataset.print, el));
let upgradePlate = (_i) => {
const assignSpread = (id) => {
upgradePlate(Number(id.slice(1)));
const slots = boards.spread.querySelectorAll("[data-print]");
const rest = IDS.filter((p) => p !== id);
slots.forEach((el, i) => el.dataset.print = i === 0 ? id : rest[i - 1]);
const img = el.querySelector("img");
if (img) img.src = previewUrl(KEYS[Number(el.dataset.print.slice(1))], 720);
const planes = IDS.map(() => ({
cur: { x: 0, y: 0, w: 0, h: 0 },
tar: { x: 0, y: 0, w: 0, h: 0 },
const fr = field.getBoundingClientRect();
const el = claims.get(id);
const r = el.getBoundingClientRect();
if (r.width < 1 && P.memory) return;
p.tar = { x: r.left - fr.left, y: r.top - fr.top, w: r.width, h: r.height };
if (!p.placed || reduced) {
tabs.forEach((t) => t.classList.toggle("is-on", t.dataset.board === active));
const on = tabs.find((t) => t.dataset.board === active);
tabline.style.transform = `translate3d(${on.offsetLeft}px, 0, 0)`;
tabline.style.width = `${on.offsetWidth}px`;
hudBoard.textContent = `BOARD::${active.toUpperCase()}`;
const go = (board, plateId) => {
boards[active].classList.remove("is-on");
if (board === "spread") assignSpread(plateId ?? hero);
boards[board].classList.add("is-on");
if (P.fetch > 0 && !reduced) swapT = window.setTimeout(land, P.fetch);
(t) => t.addEventListener("click", () => {
if (t.dataset.board !== active) go(t.dataset.board);
const onFieldClick = (e) => {
const el = e.target.closest("[data-print]");
const id = el.dataset.print;
if (active !== "spread") go("spread", id);
else if (id === hero) go("index");
field.addEventListener("click", onFieldClick);
const onFieldKey = (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const el = e.target.closest("[data-print]");
field.addEventListener("keydown", onFieldKey);
const ro = new ResizeObserver(() => {
const s = gl.createShader(t);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
console.warn("[paste-up]", gl.getShaderInfoLog(s));
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG));
for (const n of ["uRect", "uRes", "uVel", "uBend", "uImg", "uImgRes", "uAlpha"])
u[n] = gl.getUniformLocation(prog, n);
for (let y = 0; y <= GRID; y++)
for (let x = 0; x <= GRID; x++) verts.push(x / GRID, y / GRID);
for (let y = 0; y < GRID; y++)
for (let x = 0; x < GRID; x++) {
const a = y * (GRID + 1) + x;
idx.push(a, a + 1, a + GRID + 1, a + 1, a + GRID + 2, a + GRID + 1);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW);
const aUv = gl.getAttribLocation(prog, "aUv");
gl.enableVertexAttribArray(aUv);
gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 0, 0);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const texes = KEYS.map((key, i) => {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, meshTile(540, 720, { seed: 31 + i * 7 }));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const rec = { tex, w: 540, h: 720, ready: true, hi: false };
if (MeshMode.on) return rec;
img.crossOrigin = "anonymous";
img.src = previewUrl(key, 720);
img.decode().then(() => {
rec.w = img.naturalWidth;
rec.h = img.naturalHeight;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
if (!t || t.hi || MeshMode.on) return;
im.crossOrigin = "anonymous";
im.src = previewUrl(KEYS[i], 1440);
gl.bindTexture(gl.TEXTURE_2D, t.tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im);
const dpr = Math.min(devicePixelRatio || 1, DPR_CAP);
const r = field.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(u.uRes, viewW, viewH);
gl.activeTexture(gl.TEXTURE0);
const order = planes.map((p, i) => ({ i, s: Math.abs(p.vel.x) + Math.abs(p.vel.y) })).sort((a, b) => a.s - b.s);
for (const { i } of order) {
if (!p.placed || p.alpha <= 1e-3 || p.cur.w < 0.5) continue;
gl.bindTexture(gl.TEXTURE_2D, t.tex);
gl.uniform4f(u.uRect, p.cur.x, p.cur.y, p.cur.w, p.cur.h);
gl.uniform2f(u.uImgRes, t.w, t.h);
Math.max(-VEL_MAX, Math.min(VEL_MAX, p.vel.x)) * BEND_PX,
Math.max(-VEL_MAX, Math.min(VEL_MAX, p.vel.y)) * BEND_PX
gl.uniform1f(u.uBend, reduced ? 0 : P.bend);
gl.uniform1f(u.uAlpha, p.alpha);
gl.drawElements(gl.TRIANGLES, idx.length, gl.UNSIGNED_SHORT, 0);
const k = reduced ? 1 : P.damp;
for (let i = 0; i < N; i++) {
["x", "y", "w", "h"].forEach((a) => {
p.cur[a] += (p.tar[a] - p.cur[a]) * k;
if (Math.abs(p.tar[a] - p.cur[a]) < 0.05) p.cur[a] = p.tar[a];
p.vel.x = p.vel.x * 0.8 + (p.cur.x - px) * 0.2;
p.vel.y = p.vel.y * 0.8 + (p.cur.y - py) * 0.2;
if (Math.abs(p.vel.x) + Math.abs(p.vel.y) > 0.05) moving = true;
else p.vel.x = p.vel.y = 0;
const aTar = texes[i].ready ? 1 : 0;
p.alpha += (aTar - p.alpha) * 0.12;
if (Math.abs(aTar - p.alpha) < 0.01) p.alpha = aTar;
if (!moving) awake = false;
gsap.ticker.remove(tick);
field.removeEventListener("click", onFieldClick);
field.removeEventListener("keydown", onFieldKey);
gl.getExtension("WEBGL_lose_context")?.loseContext();
P.memory = value === "on" || value === true ? 1 : 0;
if (key === "damp" || key === "bend") wake();
experiment.mount(document.querySelector(".stage"));