const asset = (key) => `https://assets.carterogunsola.com/${key.replace(/^\/+/, "")}`;
const ITEMS = PLATES.map(([n, title]) => ({
src: asset(`cdn-cgi/image/width=640,quality=74,format=auto/dts/unwrapped/DTS_Unwrapped_by_FRANCO_DUPUY_${n}.jpg`),
const DEG = Math.PI / 180;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const mod = (i, n) => (i % n + n) % n;
const damp = (cur, target, lambda, dt) => cur + (target - cur) * (1 - Math.exp(-lambda * dt));
const MOMENTUM_DECAY = 1.3;
const SCROLL_SCALE = 0.22;
const IMG_EASE = [0.16, 1, 0.3, 1];
const toEase = (id, b) => CustomEase.create(id, `M0,0 C${b[0]},${b[1]} ${b[2]},${b[3]} 1,1`);
spring: "back.inOut(2.2)"
const STYLE_ID = "exp-rg-style";
font-family: var(--font-sans);
-webkit-user-select: none;
.rg.is-dragging .rg__stage {
.rg[data-view="grid"] .rg__stage {
transform: translate(-50%,-50%);
height: calc(var(--rg-card-w)*var(--rg-ratio));
border-radius: var(--r-xs);
background: var(--card-solid);
gap: clamp(.5rem,1.4vw,1.1rem);
padding: clamp(1rem,4vw,3.5rem);
grid-template-columns: repeat(5,1fr);
.rg[data-view="grid"] .rg__grid {
aspect-ratio: 1 / var(--rg-ratio);
justify-content: space-between;
padding: clamp(1rem,2.5vw,2rem);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: clamp(1.6rem,3vw,2.6rem);
font-family: var(--font-mono);
text-transform: uppercase;
font-size: var(--fs-mono-meta,.75rem);
border-radius: var(--r-sm);
border-radius: calc(var(--r-sm) - .15rem);
transition: background-color .25s var(--ease-out-quad),color .25s var(--ease-out-quad),scale .15s var(--ease-out-quad);
.rg__view-btn[data-active="true"] {
background: var(--surface);
box-shadow: 0 1px 2px rgba(0,0,0,.12);
.rg__view-btn:focus-visible {
outline: 2px solid var(--ink);
@media (max-width:47.99rem) {
grid-template-columns: repeat(2,1fr);
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
const carouselIcon = `<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5.333" width="5.333" height="5.333"/><rect x="5.333" y="10.667" width="5.333" height="5.333"/><rect x="10.667" y="5.333" width="5.333" height="5.333"/><rect y="5.333" width="5.333" height="5.333"/></svg>`;
const gridIcon = `<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="0.25" y="0.75" width="5.333" height="5.333"/><rect x="10.583" y="0.75" width="5.333" height="5.333"/><rect x="0.25" y="10" width="5.333" height="5.333"/><rect x="10.583" y="10" width="5.333" height="5.333"/></svg>`;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const easeId = `rgImg-${rgEaseSeq++}`;
let imgEase = toEase(easeId, P.imgEase);
const rg = document.createElement("div");
rg.dataset.view = "carousel";
const stage = document.createElement("div");
stage.className = "rg__stage";
const carousel = document.createElement("div");
carousel.className = "rg__carousel";
const grid = document.createElement("div");
grid.className = "rg__grid";
ITEMS.forEach((item, i) => {
const it = document.createElement("div");
it.className = "rg__item";
it.style.zIndex = String(Math.round(500 + Math.cos(i * SLICE * DEG) * 100));
const flip = document.createElement("div");
flip.className = "rg__flip";
flip.dataset.i = String(i);
const img = document.createElement("img");
img.className = "rg__img";
carousel.appendChild(it);
const slot = document.createElement("div");
slot.className = "rg__slot";
slot.style.zIndex = it.style.zIndex;
const hud = document.createElement("div");
hud.className = "rg__hud";
const meta = document.createElement("div");
meta.className = "rg__meta";
const odo = document.createElement("div");
odo.className = "rg__odo";
odo.setAttribute("aria-hidden", "true");
const digit = () => `<span class="rg__odo-col"><span class="rg__odo-strip">${[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => `<span class="rg__odo-num">${n}</span>`).join("")}</span></span>`;
odo.innerHTML = digit() + digit();
const strips = [...odo.querySelectorAll(".rg__odo-strip")];
const name = document.createElement("div");
name.className = "rg__name";
name.innerHTML = `<span class="rg__name-strip">${ITEMS.map(
(it) => `<span class="rg__name-item">${it.title}</span>`
const nameStrip = name.querySelector(".rg__name-strip");
const views = document.createElement("div");
views.className = "rg__views";
views.setAttribute("role", "group");
views.setAttribute("aria-label", "View");
const carBtn = document.createElement("button");
carBtn.className = "rg__view-btn";
carBtn.dataset.view = "carousel";
carBtn.dataset.active = "true";
carBtn.setAttribute("aria-label", "Carousel view");
carBtn.innerHTML = carouselIcon;
const gridBtn = document.createElement("button");
gridBtn.className = "rg__view-btn";
gridBtn.dataset.view = "grid";
gridBtn.dataset.active = "false";
gridBtn.setAttribute("aria-label", "Grid view");
gridBtn.innerHTML = gridIcon;
views.append(carBtn, gridBtn);
stage.append(carousel, grid);
const w = stage.clientWidth;
const h = stage.clientHeight;
const side = Math.min(w, h);
radius = clamp(Math.min(h * 0.26, w * 0.2), 140, 210);
const cardW = radius * 0.82;
rg.style.setProperty("--rg-card-w", `${cardW}px`);
carousel.style.width = carousel.style.height = `${side}px`;
carousel.style.perspective = `${P.perspective}px`;
for (let i = 0; i < N; i++) {
const a = (i * SLICE + angle + PHASE) * DEG;
const x = center + radius * Math.sin(a);
const y = center - radius * Math.cos(a);
const z = -depth * Math.cos(a);
items[i].style.transform = `translate3d(${x}px, ${y}px, ${z}px) translate(-50%, -50%)`;
const displayIdx = () => hoverIdx >= 0 ? hoverIdx : activeIdx;
const setImages = () => {
const active = displayIdx();
for (let i = 0; i < N; i++) {
scale: i === active ? 1.06 : 1,
duration: reduced ? 0 : 1,
const setMeta = (idx) => {
const oneBased = idx + 1;
const tens = Math.floor(oneBased / 10);
const ones = oneBased % 10;
nameStrip.style.transform = `translateY(${-idx * 1.1}em)`;
const setView = (next) => {
if (flipping || next === view) return;
carBtn.dataset.active = String(next === "carousel");
gridBtn.dataset.active = String(next === "grid");
flips.forEach((f, i) => (next === "grid" ? slots[i] : items[i]).appendChild(f));
const state = Flip.getState(flips, { props: "filter" });
flips.forEach((f, i) => (next === "grid" ? slots[i] : items[i]).appendChild(f));
stagger: next === "grid" ? 0.015 : 0.02,
onComplete: () => flipping = false
carBtn.addEventListener("click", () => setView("carousel"));
gridBtn.addEventListener("click", () => setView("grid"));
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
flips.forEach((flip, i) => {
flip.addEventListener("pointerenter", () => {
if (hoverIdx === i) return;
flip.addEventListener("pointerleave", () => {
if (hoverIdx !== i) return;
const tick = (_t, dtMs) => {
const dt = Math.min(dtMs, 64) / 1e3 || 0.016;
if (view === "carousel" && !flipping) {
angle = damp(angle, target, P.weight, dt);
momentum = damp(momentum, P.drift * dir, MOMENTUM_DECAY, dt);
const idx = mod(Math.round(-angle / SLICE), N);
for (let d = 0; d < 2; d++) {
const cur = Number(strips[d].dataset.y ?? "0");
const next = reduced ? digitY[d] : damp(cur, digitY[d], DIGIT_DAMP, 1 / 60);
if (Math.abs(next - cur) > 2e-4) {
strips[d].dataset.y = String(next);
strips[d].style.transform = `translateY(${next}em)`;
if (view === "carousel" && dirty) {
const ac = new AbortController();
if (view !== "carousel" || flipping) return;
if (e.target.closest(".rg__views")) return;
lastMoveT = performance.now();
rg.classList.add("is-dragging");
stage.setPointerCapture?.(e.pointerId);
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!axis && Math.hypot(dx, dy) > LOCK_PX) axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
const d = axis === "x" ? dx : dy;
target = pressAngle + d * DRAG_SCALE;
const now = performance.now();
const mdt = Math.max(1, now - lastMoveT);
vel = (d - lastDelta) * DRAG_SCALE / (mdt / 1e3);
rg.classList.remove("is-dragging");
momentum = clamp(vel, -900, 900);
dir = Math.sign(momentum) || dir;
window.addEventListener("pointerup", endDrag, { signal });
window.addEventListener("pointercancel", endDrag, { signal });
if (view !== "carousel" || flipping) return;
target = (dragging ? target : angle) + e.deltaY * SCROLL_SCALE;
dir = Math.sign(e.deltaY) || dir;
wheelIdle = setTimeout(() => {
momentum = clamp(e.deltaY * SCROLL_SCALE * 8, -900, 900);
{ passive: false, signal }
if (e.key === "ArrowLeft") {
angle = damp(angle, target, 1, 1);
setTimeout(() => dragging = false, 60);
} else if (e.key === "ArrowRight") {
setTimeout(() => dragging = false, 60);
} else if (e.key === "g" || e.key === "G") {
setView(view === "grid" ? "carousel" : "grid");
activeIdx = mod(Math.round(-angle / SLICE), N);
const ro = new ResizeObserver(() => {
gsap.set(flips, { clipPath: "inset(100%)", willChange: "clip-path" });
gsap.set([meta, views], { opacity: 0, y: 26 });
const tl = gsap.timeline({ delay: 0.1 });
clearProps: "clipPath,willChange"
tl.to(meta, { opacity: 1, y: 0, duration: 1.1, ease: "expo.out" }, "<0.1");
tl.to(views, { opacity: 1, y: 0, duration: 1.2, ease: "expo.out" }, "<0.35");
const set = (key, value) => {
} else if (key === "imgEase") {
if (Array.isArray(value) && value.length === 4) {
imgEase = toEase(easeId, P.imgEase);
} else if (key === "perspective") {
P.perspective = Number(value);
carousel.style.perspective = `${P.perspective}px`;
gsap.ticker.remove(tick);
gsap.killTweensOf([...imgs, ...items, ...flips, meta, views, rg]);
experiment.mount(document.querySelector(".stage"));