sozai / darkroom_preview.html
Shereen Lee
feat: Sozai app with ZeroGPU img2img watercolour develop (staged LoRA)
bb628b8
Raw
History Blame Contribute Delete
36.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sozai · Darkroom preview</title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.97 0.012 85);
--foreground: oklch(0.28 0.05 264);
--card: oklch(0.985 0.008 85);
--card-foreground: oklch(0.28 0.05 264);
--popover: oklch(0.985 0.008 85);
--popover-foreground: oklch(0.28 0.05 264);
--primary: oklch(0.5 0.21 268);
--primary-foreground: oklch(0.98 0.01 85);
--secondary: oklch(0.93 0.015 85);
--secondary-foreground: oklch(0.32 0.06 264);
--muted: oklch(0.93 0.012 85);
--muted-foreground: oklch(0.5 0.04 264);
--accent: oklch(0.9 0.03 264);
--accent-foreground: oklch(0.32 0.08 264);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.85 0.02 264);
--input: oklch(0.88 0.018 264);
--ring: oklch(0.5 0.21 268);
--chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0);
}
@layer base {
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground; }
html { @apply font-sans; }
}
</style>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js" crossorigin></script>
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js" crossorigin></script>
</head>
<body class="bg-[oklch(0.1_0.02_25)] antialiased">
<div class="mx-auto max-w-6xl px-4 py-8">
<p class="mb-4 text-center font-mono text-xs uppercase tracking-widest text-[oklch(0.7_0.08_25)]">
Standalone preview — top-down baths, then drag a print left/right to shake it developed
</p>
<div id="root"></div>
</div>
<script type="text/babel" data-presets="react">
const { useState, useEffect, useRef, useMemo, useCallback } = React;
function cn(){ return Array.prototype.slice.call(arguments).flat(Infinity).filter(Boolean).join(" "); }
const _L = window.lucide || {};
function iconNode(name){ return _L[name] || (_L.icons && _L.icons[name]) || null; }
function camelAttrs(a={}){ const o={}; for(const k in a){ if(k==="class"){o.className=a[k];continue;} o[k.replace(/-([a-z])/g,(_,c)=>c.toUpperCase())]=a[k]; } return o; }
function Icon({ name, className="", ...rest }){
const node = iconNode(name);
const children = Array.isArray(node) ? node.map((t,i)=>{ const tag=Array.isArray(t)?t[0]:t.tag; const attrs=Array.isArray(t)?(t[1]||{}):(t.attrs||{}); return React.createElement(tag,{key:i,...camelAttrs(attrs)}); }) : null;
return (<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true" {...rest}>{children}</svg>);
}
const mk = (n) => (props) => <Icon name={n} {...props} />;
const Sparkles = mk("Sparkles"), ArrowRight = mk("ArrowRight");
// ----------------------------------------------------------------- //
// components/develop/* (ported from storybook_framer.zip)
// Red-safelight Darkroom with two phases:
// 1) chemical baths -> REWORKED to a top-down view where each real
// print travels tray-to-tray one by one, dipping with water ripples
// and synthesized dip/lift water SFX (Web Audio, no asset files).
// 2) hang & shake-to-reveal -> faithful port of HangingPhoto.
// ----------------------------------------------------------------- //
const Droplet = mk("Droplet"), Volume2 = mk("Volume2"), VolumeX = mk("VolumeX"),
FastForward = mk("FastForward");
const DR_REDUCED = typeof window !== "undefined" && window.matchMedia
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// baths kept from the zip (colors + labels), seen from above
const DR_BATHS = [
{ id: "developer", label: "Developer", color: "oklch(0.45 0.14 150)", edge: "oklch(0.32 0.1 150)" },
{ id: "stop", label: "Stop Bath", color: "oklch(0.5 0.12 90)", edge: "oklch(0.36 0.09 90)" },
{ id: "fixer", label: "Fixer", color: "oklch(0.5 0.1 250)", edge: "oklch(0.36 0.08 250)" },
{ id: "wash", label: "Wash", color: "oklch(0.55 0.08 220)", edge: "oklch(0.4 0.06 220)" },
];
// top-down layout (percent within the bench)
const DR_TRAY_X = [21, 40, 59, 78];
const DR_SURFACE_Y = 54, DR_ABOVE_Y = 30, DR_QUEUE_X = 7, DR_EXIT_X = 94;
// prints stay latent in the baths; full colour is the shake payoff on the line
function drBathFilter(p) {
const blur = (1 - Math.min(1, p * 2.4)) * 4;
const gray = (1 - p) * 100;
const bright = 0.5 + p * 0.55;
const sep = (1 - p) * 32;
return "blur(" + blur.toFixed(2) + "px) grayscale(" + gray.toFixed(0) + "%) brightness(" + bright.toFixed(2) + ") sepia(" + sep.toFixed(0) + "%)";
}
function drLock() {
let busy = false; const q = [];
return {
acquire() { return new Promise((res) => { if (!busy) { busy = true; res(); } else q.push(res); }); },
release() { const n = q.shift(); if (n) n(); else busy = false; },
};
}
const drSleep = (ms) => new Promise((r) => setTimeout(r, DR_REDUCED ? Math.min(ms, 120) : ms));
// ---- synthesized water SFX (Web Audio; no audio files needed) ------ //
function useWaterSfx(muted) {
const ref = useRef(null);
const mutedRef = useRef(muted);
function engine() {
let a = ref.current;
if (!a) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
const ac = new AC();
const master = ac.createGain();
master.gain.value = mutedRef.current ? 0 : 0.6;
master.connect(ac.destination);
a = ref.current = { ac, master };
}
if (a.ac.state === "suspended") a.ac.resume();
return a;
}
useEffect(() => {
mutedRef.current = muted;
if (ref.current) ref.current.master.gain.value = muted ? 0 : 0.6;
}, [muted]);
function noise(ac, dur) {
const n = Math.max(1, (ac.sampleRate * dur) | 0);
const b = ac.createBuffer(1, n, ac.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return b;
}
const dip = useCallback(() => { // splash going in
if (mutedRef.current) return;
const a = engine(); if (!a) return;
const ac = a.ac, t = ac.currentTime;
const s = ac.createBufferSource(); s.buffer = noise(ac, 0.45);
const bp = ac.createBiquadFilter(); bp.type = "bandpass";
bp.frequency.setValueAtTime(1900, t);
bp.frequency.exponentialRampToValueAtTime(320, t + 0.4); bp.Q.value = 0.7;
const g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.55, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
s.connect(bp); bp.connect(g); g.connect(a.master); s.start(t); s.stop(t + 0.46);
const o = ac.createOscillator(); o.type = "sine";
o.frequency.setValueAtTime(240, t);
o.frequency.exponentialRampToValueAtTime(85, t + 0.2);
const og = ac.createGain();
og.gain.setValueAtTime(0.45, t);
og.gain.exponentialRampToValueAtTime(0.001, t + 0.28);
o.connect(og); og.connect(a.master); o.start(t); o.stop(t + 0.3);
}, []);
const drip = useCallback(() => { // drips lifting out
if (mutedRef.current) return;
const a = engine(); if (!a) return;
const ac = a.ac, base = ac.currentTime, n = 3 + ((Math.random() * 3) | 0);
for (let i = 0; i < n; i++) {
const t = base + i * (0.06 + Math.random() * 0.09);
const o = ac.createOscillator(); o.type = "sine";
const f = 850 + Math.random() * 1300;
o.frequency.setValueAtTime(f, t);
o.frequency.exponentialRampToValueAtTime(f * 0.4, t + 0.1);
const g = ac.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.28, t + 0.005);
g.gain.exponentialRampToValueAtTime(0.0006, t + 0.13);
o.connect(g); g.connect(a.master); o.start(t); o.stop(t + 0.15);
}
}, []);
const prime = useCallback(() => { engine(); }, []);
return { dip, drip, prime };
}
// ---- phase 1: top-down chemical baths ------------------------------ //
function TopDownBaths({ photos, onComplete, sfx }) {
const list = (photos && photos.length ? photos : []);
const [sheets, setSheets] = useState(() => list.map((p, i) => ({
id: p.id || ("dr" + i), src: p.src || null, alt: p.alt || "",
x: DR_QUEUE_X, y: DR_SURFACE_Y - 2 - i * 1.4, rot: -4 + i * 1.5, scale: 0.86,
submerged: false, level: 0, tint: null, z: i, opacity: 1,
})));
const [ripples, setRipples] = useState([]);
const [started, setStarted] = useState(false);
const [done, setDone] = useState(0);
const alive = useRef(true);
const total = list.length;
useEffect(() => () => { alive.current = false; }, []);
const patch = useCallback((id, p) => {
setSheets((prev) => prev.map((s) => (s.id === id ? { ...s, ...p } : s)));
}, []);
const addRipple = useCallback((x, y, color) => {
if (DR_REDUCED) return;
const rid = "rp" + Math.random().toString(36).slice(2);
setRipples((r) => [...r, { id: rid, x, y, color }]);
setTimeout(() => { if (alive.current) setRipples((r) => r.filter((p) => p.id !== rid)); }, 1300);
}, []);
useEffect(() => {
if (!started) return;
alive.current = true;
const locks = DR_BATHS.map(() => drLock());
let cancelled = false;
async function runSheet(sheet, idx) {
await drSleep(idx * 620);
if (cancelled) return;
patch(sheet.id, { z: 100 + idx, scale: 1 });
for (let b = 0; b < DR_BATHS.length; b++) {
const bath = DR_BATHS[b];
await locks[b].acquire();
if (cancelled || !alive.current) { locks[b].release(); return; }
patch(sheet.id, { x: DR_TRAY_X[b], y: DR_ABOVE_Y, rot: b % 2 ? 2 : -2 });
await drSleep(640);
sfx.dip();
addRipple(DR_TRAY_X[b], DR_SURFACE_Y, bath.color);
setTimeout(() => addRipple(DR_TRAY_X[b], DR_SURFACE_Y, bath.color), 150);
patch(sheet.id, { y: DR_SURFACE_Y, submerged: true, tint: bath.color, level: (b + 1) / (DR_BATHS.length + 2) });
await drSleep(1150);
patch(sheet.id, { y: DR_ABOVE_Y, submerged: false, tint: null });
sfx.drip();
await drSleep(560);
locks[b].release();
}
// glide off toward the drying line and fade
patch(sheet.id, { x: DR_EXIT_X, y: DR_ABOVE_Y - 6, rot: 6, scale: 0.7, opacity: 0 });
await drSleep(620);
if (alive.current && !cancelled) setDone((c) => c + 1);
}
(async () => {
sfx.prime();
await Promise.all(sheets.map((s, i) => runSheet(s, i)));
if (!cancelled && alive.current) setTimeout(() => { if (alive.current) onComplete(); }, 650);
})();
return () => { cancelled = true; };
}, [started]); // eslint-disable-line
const tr = DR_REDUCED ? "all .16s linear"
: "left .8s cubic-bezier(.4,.1,.2,1), top .62s cubic-bezier(.5,0,.3,1), transform .8s cubic-bezier(.4,.1,.2,1), opacity .55s ease";
return (
<div className="flex w-full flex-col items-center gap-5 py-2">
<div className="text-center">
<p className="font-mono text-xs uppercase tracking-[0.3em] text-[oklch(0.78_0.12_25)]">
{started ? "Bathing " + total + " frames" : total + " frames exposed"}
</p>
<h2 className="mt-1 font-mono text-lg font-semibold uppercase tracking-widest text-[oklch(0.9_0.08_25)]">
{started ? (done >= total ? "Negatives fixed" : done + " / " + total + " through the baths") : "Ready to develop"}
</h2>
</div>
{/* the bench, viewed from above */}
<div className="relative w-full max-w-4xl overflow-hidden rounded-lg border-2 border-[oklch(0.4_0.05_25)]"
style={{ aspectRatio: "1100 / 420",
background: "radial-gradient(120% 100% at 50% 0%, oklch(0.24 0.06 25), oklch(0.12 0.03 25) 70%)",
boxShadow: "inset 0 0 70px rgba(0,0,0,0.6)" }}>
{/* exposed-stack label */}
<span className="absolute font-mono text-[10px] uppercase tracking-widest"
style={{ left: DR_QUEUE_X + "%", top: (DR_SURFACE_Y + 24) + "%", transform: "translate(-50%,0)", color: "oklch(0.72 0.1 25)" }}>
Exposed
</span>
<span className="absolute font-mono text-[10px] uppercase tracking-widest"
style={{ left: DR_EXIT_X + "%", top: (DR_ABOVE_Y + 4) + "%", transform: "translate(-50%,0)", color: "oklch(0.72 0.1 25)" }}>
To the line
</span>
{/* trays */}
{DR_BATHS.map((b, i) => (
<div key={b.id} style={{ position: "absolute", left: DR_TRAY_X[i] + "%", top: DR_SURFACE_Y + "%", width: "17%", transform: "translate(-50%,-50%)" }}>
<div className="relative rounded-[10px] border-2 border-[oklch(0.42_0.06_25)] p-1.5"
style={{ aspectRatio: "5 / 4", background: "oklch(0.16 0.03 25)", boxShadow: "0 6px 16px rgba(0,0,0,0.5)" }}>
<div className="relative h-full w-full overflow-hidden rounded-[6px]"
style={{ background: "radial-gradient(120% 120% at 50% 0%, " + b.color + ", " + b.edge + ")" }}>
<div className="absolute inset-0" style={{ filter: "url(#dr-ripple)",
background: "linear-gradient(180deg, rgba(255,255,255,0.20), rgba(255,255,255,0) 45%)" }} />
<div className="absolute inset-0" style={{ boxShadow: "inset 0 0 24px rgba(0,0,0,0.5)" }} />
</div>
</div>
<p className="mt-1 text-center font-mono text-[10px] uppercase tracking-wider text-[oklch(0.78_0.08_25)]">{b.label}</p>
</div>
))}
{/* ripple rings */}
{ripples.map((r) => (
<span key={r.id} className="pointer-events-none absolute rounded-full"
style={{ left: r.x + "%", top: r.y + "%", width: "13%", aspectRatio: "1",
border: "2px solid " + (r.color || "#fff"), animation: "dr-ring 1.2s ease-out forwards", mixBlendMode: "screen" }} />
))}
{/* prints */}
{sheets.map((s) => {
const bf = drBathFilter(s.level);
const filt = s.submerged ? ("url(#dr-ripple) " + bf) : bf;
return (
<div key={s.id} style={{ position: "absolute", left: s.x + "%", top: s.y + "%", width: "11%",
transform: "translate(-50%,-50%) rotate(" + s.rot + "deg) scale(" + s.scale + ")",
zIndex: s.z, opacity: s.opacity, transition: tr }}>
<div className="relative rounded-sm bg-[oklch(0.99_0.01_85)] p-1 shadow-xl">
<div className="relative aspect-[4/5] overflow-hidden rounded-[2px] bg-[oklch(0.2_0.02_60)]"
style={{ filter: filt, transition: "filter 1.1s ease" }}>
{s.src ? (
<img src={s.src} alt="" draggable={false} className="absolute inset-0 h-full w-full object-cover"
onError={(e) => { e.currentTarget.style.display = "none"; }} />
) : null}
{/* red emulsion haze while latent */}
<div className="pointer-events-none absolute inset-0"
style={{ opacity: 0.8 * (1 - s.level * 2.2 > 0 ? 1 - s.level * 2.2 : 0),
background: "radial-gradient(circle at 50% 40%, oklch(0.35 0.12 25 / 0.6), oklch(0.18 0.06 25 / 0.85))" }} />
</div>
{s.submerged ? (
<div className="pointer-events-none absolute inset-1 rounded-[2px]"
style={{ background: s.tint, opacity: 0.4, mixBlendMode: "multiply" }} />
) : null}
</div>
</div>
);
})}
{/* start overlay (primes audio on the click gesture) */}
{!started ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4"
style={{ background: "oklch(0.1 0.03 25 / 0.74)", backdropFilter: "blur(1px)" }}>
<button type="button" onClick={() => { sfx.prime(); setStarted(true); }}
className="inline-flex items-center gap-2 rounded-full border-2 border-[oklch(0.85_0.12_25)] bg-[oklch(0.35_0.14_25)] px-6 py-2.5 font-mono text-sm uppercase tracking-widest text-[oklch(0.95_0.05_85)] transition-colors hover:bg-[oklch(0.42_0.16_25)]">
<Droplet className="size-4" /> Start developing
</button>
</div>
) : null}
</div>
{/* progress dots, kept from the zip */}
<div className="flex items-center gap-2" role="status" aria-label="Development progress">
{list.map((p, i) => (
<span key={p.id || i} className="h-2 rounded-full transition-all duration-500"
style={{ width: i < done ? "1.5rem" : "0.5rem",
background: i < done ? "oklch(0.8 0.14 25)" : "oklch(0.4 0.05 25)" }} />
))}
</div>
</div>
);
}
// ---- phase 2: hanging print, shake to reveal (ported) -------------- //
const DR_SHAKES_TO_REVEAL = 8;
function HangingPrint({ photo, index, swayDelay, onRevealed }) {
const [progress, setProgress] = useState(0);
const [revealed, setRevealed] = useState(false);
const [dragging, setDragging] = useState(false);
const [tilt, setTilt] = useState(0);
const dragState = useRef({ active: false, lastX: 0, lastDir: 0, offset: 0 });
const progressRef = useRef(0);
useEffect(() => { if (revealed) onRevealed(photo.id); }, [revealed, onRevealed, photo.id]);
const registerShake = useCallback(() => {
const next = Math.min(1, progressRef.current + 1 / DR_SHAKES_TO_REVEAL);
progressRef.current = next; setProgress(next);
if (next >= 1) setRevealed(true);
}, []);
function onPointerDown(e) {
if (revealed) return;
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
dragState.current = { active: true, lastX: e.clientX, lastDir: 0, offset: 0 };
setDragging(true);
}
function onPointerMove(e) {
const s = dragState.current;
if (!s.active || revealed) return;
const dx = e.clientX - s.lastX;
if (Math.abs(dx) < 4) return;
const dir = dx > 0 ? 1 : -1;
if (s.lastDir !== 0 && dir !== s.lastDir) registerShake();
s.lastDir = dir; s.lastX = e.clientX;
s.offset = Math.max(-26, Math.min(26, s.offset + dx));
setTilt(s.offset);
}
function endDrag(e) {
dragState.current.active = false; setDragging(false); setTilt(0);
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
}
const blurPx = (1 - progress) * 14;
const grayscale = (1 - progress) * 100;
const brightness = 0.35 + progress * 0.65;
const fogOpacity = (1 - progress) * 0.9;
return (
<li className="flex w-40 flex-col items-center sm:w-44">
<div className="relative z-10 h-4 w-3 rounded-sm bg-[oklch(0.45_0.06_60)] shadow-sm" aria-hidden="true" />
<figure
className={cn("group relative -mt-1 select-none rounded-sm bg-[oklch(0.99_0.01_85)] p-2 pb-9 shadow-xl",
dragging ? "" : "dr-sway", revealed ? "cursor-default" : "cursor-grab active:cursor-grabbing")}
style={{ animationDelay: swayDelay + "s",
transform: dragging ? "rotate(" + (tilt * 0.3) + "deg) translateX(" + (tilt * 0.4) + "px)" : undefined,
transition: dragging ? "none" : "transform 0.4s ease" }}
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
<div className="relative aspect-[4/5] w-full overflow-hidden rounded-sm bg-[oklch(0.2_0.02_60)]">
<img src={photo.src || "/assets/placeholder.svg"} alt={revealed ? photo.alt : ""} draggable={false}
className="h-full w-full object-cover transition-[filter] duration-200"
style={{ filter: "blur(" + blurPx + "px) grayscale(" + grayscale + "%) brightness(" + brightness + ") sepia(" + ((1 - progress) * 40) + "%)" }} />
<div className="pointer-events-none absolute inset-0 transition-opacity duration-200"
style={{ opacity: fogOpacity,
background: "radial-gradient(circle at 50% 40%, oklch(0.35 0.12 25 / 0.65), oklch(0.18 0.06 25 / 0.85))" }} aria-hidden="true" />
{!revealed ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-[oklch(0.15_0.04_25/0.7)] px-3 py-1 font-mono text-[11px] uppercase tracking-wider text-[oklch(0.95_0.05_25)]">
{progress > 0 ? "Developing…" : "Shake me"}
</span>
</div>
) : (
<div className="pointer-events-none absolute right-1.5 top-1.5 text-[oklch(0.95_0.05_85)] drop-shadow">
<Sparkles className="size-4" aria-hidden="true" />
</div>
)}
</div>
<figcaption className="absolute inset-x-0 bottom-2 line-clamp-2 px-2 text-center font-mono text-[11px] leading-tight text-[oklch(0.3_0.04_264)]">
{revealed ? photo.defaultCaption : ("Frame " + String(index + 1).padStart(2, "0"))}
</figcaption>
</figure>
</li>
);
}
// ---- the Darkroom shell -------------------------------------------- //
function DarkroomDeveloper({ photos, onDone, onSkip }) {
const list = (photos && photos.length ? photos : []);
const [phase, setPhase] = useState("baths");
const [revealed, setRevealed] = useState(() => new Set());
const [muted, setMuted] = useState(false);
const sfx = useWaterSfx(muted);
const allRevealed = revealed.size === list.length;
const handleRevealed = useCallback((id) => {
setRevealed((prev) => { const next = new Set(prev); next.add(id); return next; });
}, []);
return (
<section className="relative w-full max-w-6xl overflow-hidden rounded-xl border-2 border-[oklch(0.4_0.1_25)] bg-[oklch(0.16_0.04_25)] shadow-2xl">
<style dangerouslySetInnerHTML={{ __html:
"@keyframes dr-safelight{0%,100%{opacity:.85}45%{opacity:.78}55%{opacity:.92}70%{opacity:.82}}" +
"@keyframes dr-ring{from{transform:translate(-50%,-50%) scale(.2);opacity:.7}to{transform:translate(-50%,-50%) scale(1.7);opacity:0}}" +
"@keyframes dr-sway-kf{0%,100%{transform:rotate(-1.5deg)}50%{transform:rotate(1.5deg)}}" +
"@keyframes dr-fade{from{opacity:0}to{opacity:1}}" +
".dr-flicker{animation:dr-safelight 5s ease-in-out infinite}" +
".dr-sway{animation:dr-sway-kf 4s ease-in-out infinite;transform-origin:top center}" +
"@media (prefers-reduced-motion: reduce){.dr-flicker,.dr-sway{animation:none}}"
}} />
{/* hidden filter that ripples the liquid + submerged prints */}
<svg aria-hidden="true" style={{ position: "absolute", width: 0, height: 0 }}>
<filter id="dr-ripple" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="7" result="noise">
<animate attributeName="baseFrequency" dur="14s"
values="0.012 0.02;0.016 0.014;0.01 0.022;0.012 0.02" repeatCount="indefinite" />
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="7" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
{/* red safelight glow */}
<div className="dr-flicker pointer-events-none absolute inset-0" aria-hidden="true"
style={{ background:
"radial-gradient(120% 90% at 50% 0%, oklch(0.4 0.18 25 / 0.55), transparent 60%), radial-gradient(100% 80% at 50% 120%, oklch(0.3 0.14 25 / 0.5), transparent 55%)" }} />
<div className="relative px-5 py-8 sm:px-8">
<header className="mb-6 flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 text-center">
<h1 className="font-mono text-base font-semibold uppercase tracking-[0.3em] text-[oklch(0.88_0.1_25)]">Darkroom</h1>
<p className="mt-1 font-mono text-xs text-[oklch(0.7_0.08_25)]">
{phase === "baths"
? "Running each print through the chemical baths"
: allRevealed ? "All prints developed"
: "Shake each print to reveal it — " + revealed.size + "/" + list.length + " done"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button type="button" onClick={() => setMuted((m) => !m)} aria-pressed={muted}
aria-label={muted ? "Unmute water sounds" : "Mute water sounds"}
className="flex size-9 items-center justify-center rounded-md border-2 border-[oklch(0.45_0.08_25)] text-[oklch(0.85_0.1_25)] transition-colors hover:bg-[oklch(0.28_0.08_25)]">
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
</button>
<button type="button" onClick={() => onSkip && onSkip()}
className="flex items-center gap-1.5 rounded-md border-2 border-[oklch(0.45_0.08_25)] px-2.5 py-1.5 font-mono text-[11px] uppercase tracking-wide text-[oklch(0.85_0.1_25)] transition-colors hover:bg-[oklch(0.28_0.08_25)]">
<FastForward className="size-3.5" /> Skip
</button>
</div>
</header>
{phase === "baths" ? (
<TopDownBaths photos={list} sfx={sfx} onComplete={() => setPhase("hang")} />
) : (
<div className="flex flex-col items-center">
<div className="relative w-full">
<div className="absolute inset-x-0 top-2 h-[2px] bg-[oklch(0.6_0.05_60)]" aria-hidden="true" />
<ul className="relative flex flex-wrap items-start justify-center gap-6 pt-2 sm:gap-8">
{list.map((photo, i) => (
<HangingPrint key={photo.id} photo={photo} index={i} swayDelay={i * 0.4} onRevealed={handleRevealed} />
))}
</ul>
</div>
<button type="button" onClick={() => onDone && onDone()} disabled={!allRevealed}
className={cn("mt-10 inline-flex items-center gap-2 rounded-full border-2 px-6 py-2.5 font-mono text-sm uppercase tracking-widest transition-all",
allRevealed
? "border-[oklch(0.85_0.12_25)] bg-[oklch(0.35_0.14_25)] text-[oklch(0.95_0.05_85)] hover:bg-[oklch(0.42_0.16_25)]"
: "cursor-not-allowed border-[oklch(0.4_0.05_25)] text-[oklch(0.6_0.06_25)] opacity-40")}>
{allRevealed ? "View your map" : "Reveal all prints first"}
<ArrowRight className="size-4" aria-hidden="true" />
</button>
</div>
)}
</div>
</section>
);
}
// ----------------------------------------------------------------- //
const SAMPLE = [
{ id: "p1", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23ff7a59%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23c0392b%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23ffd166%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%237a2e1e%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3EOTTAWA%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Library of Parliament — beautiful morning!" },
{ id: "p2", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%232ecc71%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2316a085%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23a8e6cf%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%230e5a43%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3ECANAL%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Walk along the canal." },
{ id: "p3", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%239b59b6%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%238e44ad%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23f8c8dc%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%234a235a%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3EDINER%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Pit stop at the old diner." },
{ id: "p4", src: "data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22320%22%20height%3D%22400%22%20viewBox%3D%220%200%20320%20400%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22g%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%23f1c40f%22/%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23e67e22%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect%20width%3D%22320%22%20height%3D%22400%22%20fill%3D%22url%28%23g%29%22/%3E%3Ccircle%20cx%3D%22230%22%20cy%3D%22120%22%20r%3D%2270%22%20fill%3D%22%23fff3b0%22%20opacity%3D%220.6%22/%3E%3Crect%20x%3D%220%22%20y%3D%22300%22%20width%3D%22320%22%20height%3D%22100%22%20fill%3D%22%237e5109%22%20opacity%3D%220.5%22/%3E%3Ctext%20x%3D%22160%22%20y%3D%22210%22%20font-family%3D%22monospace%22%20font-size%3D%2222%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20opacity%3D%220.85%22%3ELATTE%3C/text%3E%3C/svg%3E", alt: "sample", defaultCaption: "Best latte on the trip." }
];
function Preview(){
const [done,setDone]=useState(false);
return (<div className="flex items-center justify-center">
{done
? <div className="rounded-xl border-2 border-[oklch(0.4_0.1_25)] bg-[oklch(0.16_0.04_25)] px-8 py-10 text-center">
<p className="font-mono text-sm uppercase tracking-widest text-[oklch(0.88_0.1_25)]">Map screen would load here ✓</p>
<button className="mt-4 rounded-full border-2 border-[oklch(0.85_0.12_25)] px-4 py-2 font-mono text-xs uppercase tracking-widest text-[oklch(0.95_0.05_85)] hover:bg-[oklch(0.35_0.14_25)]" onClick={()=>setDone(false)}>Run again</button>
</div>
: <DarkroomDeveloper photos={SAMPLE} onDone={()=>setDone(true)} onSkip={()=>setDone(true)} />}
</div>);
}
ReactDOM.createRoot(document.getElementById("root")).render(<Preview />);
</script>
</body>
</html>