Spaces:
Paused
Paused
| <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> |