[READ-ONLY] Mirror of https://github.com/mrgnw/zenfo.co.
0

Configure Feed

Select the types of activity you want to include in your feed.

hero showcase with light table, new sample photos and depth maps

+1761 -313
+361
.opencode/plans/hero-lighttable.md
··· 1 + # morgan.photos — Hero Showcase + Light Table 2 + 3 + ## Overview 4 + 5 + Two-section photography portfolio page: 6 + 1. **Cinematic Hero** — 7 featured photos, fullscreen, with depth-aware transitions and parallax 7 + 2. **Light Table** — ~12 curated thumbnails, scroll-driven morph from hero to grid 8 + 9 + --- 10 + 11 + ## Hero Images (7) 12 + 13 + | File | Title | Why | 14 + |---|---|---| 15 + | `20230708-A7_00042-Edit.jpg` | Lobo de espejo | Wildlife, B&W, moody — depth transition includes hue/saturation shift | 16 + | `20240625-A7_00222-HDR.jpg` | Mossy Sentinel | Nature close-up, green, lush depth layers | 17 + | `20230708-A7_00067.jpg` | Lake in the Clouds | Epic landscape, classic foreground/mid/background | 18 + | `20240627-A7_00077-HDR.jpg` | Mountain Railway | Travel/journey, leading lines + depth | 19 + | `20231008-A7_00156-HDR.jpg` | City Bicycle | Urban, warm autumn light, close subject | 20 + | `20231021-IMG_4709 (1).jpg` | Coastal Palm | Tropical, vivid color, palm foreground + sea bg | 21 + | `20240508-IMG_1615-Pano-2.jpg` | Golden Hour Through the Trees | Architecture, golden light, silhouette | 22 + 23 + ## Light Table Images (~12) 24 + 25 + All 7 heroes + these additional picks: 26 + - `20230706-DJI_0145-HDR-Pano-Edit.jpg` — Patagonian Dusk (aerial landscape) 27 + - `20240628-A7_00040.jpg` — Glacial Inlet (Alaska reflections) 28 + - `20250323-IMG_4925.jpg` — Barcelona Tiles (hexagonal detail — **special hex tile animation**) 29 + - `20230713-DJI_0412.jpg` — Rock and Foam (aerial abstract) 30 + - `20240306-IMG_8457-Pano.jpg` — Gothic Quarter Alley (street/architecture) 31 + 32 + --- 33 + 34 + ## Technology Stack 35 + 36 + ### Philosophy: Use the most modern thing. Fallback to a dissolve. 37 + 38 + If a browser doesn't support WebGPU, scroll-driven animations, or View Transitions — they get a simple crossfade. No polyfills, no workarounds. The cutting-edge experience is for cutting-edge browsers. 39 + 40 + ### Threlte + Three.js — Our rendering foundation 41 + 42 + Replaces the hand-rolled WebGL. Threlte gives us: 43 + - **Declarative Svelte components** for 3D scenes (meshes, materials, cameras) 44 + - **Reactive props** — change a texture/uniform with `{value}`, no manual `gl.uniform2f()` 45 + - **Automatic disposal** via Svelte lifecycle, no manual `destroy()` 46 + - **`ShaderMaterial`** for custom GLSL (depth transitions, parallax) via `<T.ShaderMaterial>` 47 + - **`<ImageMaterial>`** from `@threlte/extras` — built-in cover-fit, brightness/contrast/hue/saturation/monochrome, rounded corners, transparency — nearly everything we need for image display with color processing 48 + - **`useTask`** for render loops instead of manual `requestAnimationFrame` 49 + - **`useTexture`** for async texture loading with Suspense support 50 + - **WebGPU renderer** option via `createRenderer` prop + TSL (Three.js Shading Language) for node-based materials — fallback to WebGL automatically 51 + 52 + ### WebGPU + TSL (Three.js Shading Language) 53 + - Threlte supports `WebGPURenderer` with automatic WebGL fallback 54 + - TSL lets us write shader logic as composable JS nodes instead of raw GLSL strings 55 + - `MeshPhysicalNodeMaterial` with custom `outputNode` for depth-based transitions 56 + - Uniform updates via `$effect()` — fully reactive 57 + - **Fallback**: Three.js WebGPU renderer auto-falls back to WebGL when WebGPU unavailable 58 + 59 + ### CSS Scroll-Driven Animations 60 + - `animation-timeline: scroll()` / `view()` — bind keyframes to scroll position 61 + - Hero→grid morph, staggered grid reveals, hex tile animation 62 + - **Fallback**: IntersectionObserver-triggered CSS transitions 63 + 64 + ### View Transition API 65 + - `document.startViewTransition()` for lightbox open/close 66 + - `view-transition-name` on grid thumbnails for per-element morphs 67 + - **Fallback**: Simple opacity crossfade 68 + 69 + ### CSS `clip-path: polygon()` — Hexagonal masking 70 + - Animatable polygon clips for hex tile reveal 71 + - **Fallback**: Rectangular clip or simple fade 72 + 73 + ### CSS `@property` — Typed custom properties 74 + - Register `--progress` as `<number>` for animatable custom properties 75 + - Enables things like animating gradient stops, color values via keyframes 76 + - **Fallback**: Just use regular transitions 77 + 78 + ### Houdini Paint API 79 + - Procedural hex grid pattern as CSS `background: paint(hex-grid)` 80 + - **Fallback**: Static SVG or clip-path approach 81 + 82 + --- 83 + 84 + ## Phase 1: Generate Depth Maps 85 + 86 + ```sh 87 + spatial-maker --output-types depth --model l \ 88 + "static/samples/describe/20230708-A7_00042-Edit.jpg" \ 89 + "static/samples/describe/20240625-A7_00222-HDR.jpg" \ 90 + "static/samples/describe/20230708-A7_00067.jpg" \ 91 + "static/samples/describe/20240627-A7_00077-HDR.jpg" \ 92 + "static/samples/describe/20231008-A7_00156-HDR.jpg" \ 93 + "static/samples/describe/20231021-IMG_4709 (1).jpg" \ 94 + "static/samples/describe/20240508-IMG_1615-Pano-2.jpg" 95 + ``` 96 + 97 + Output depth maps to `static/samples/describe/depth/`. 98 + 99 + ## Phase 2: Depth Transition Material (Threlte + ShaderMaterial) 100 + 101 + A custom `<DepthTransitionMaterial>` Svelte component wrapping `<T.ShaderMaterial>`. 102 + 103 + ### Architecture 104 + 105 + ```svelte 106 + <!-- DepthTransitionMaterial.svelte --> 107 + <script> 108 + // Reactive uniforms — Threlte handles updates automatically 109 + let { imageA, depthA, imageB, depthB, progress, saturationA, saturationB, pointer } = $props() 110 + </script> 111 + 112 + <T.ShaderMaterial 113 + vertexShader={vertShader} 114 + fragmentShader={fragShader} 115 + uniforms={{ 116 + u_image_a: { value: imageA }, 117 + u_depth_a: { value: depthA }, 118 + u_image_b: { value: imageB }, 119 + u_depth_b: { value: depthB }, 120 + u_progress: { value: progress }, 121 + u_saturation_a: { value: saturationA }, 122 + u_saturation_b: { value: saturationB }, 123 + u_pointer: { value: pointer }, 124 + }} 125 + /> 126 + ``` 127 + 128 + Or **if WebGPU is available**, use TSL (Three.js Shading Language): 129 + 130 + ```ts 131 + // Depth transition as composable TSL nodes 132 + const depthA = texture(u_depth_a, uv) 133 + const pixelProgress = smoothstep(depthA.sub(spread), depthA.add(spread), u_progress) 134 + const blurred = gaussianBlur(u_image_a, uv, pixelProgress.mul(blurAmount)) 135 + const result = mix(blurred, texture(u_image_b, uv), pixelProgress) 136 + ``` 137 + 138 + TSL is cleaner than raw GLSL strings and compiles to both WGSL (WebGPU) and GLSL (WebGL fallback). 139 + 140 + ### Transition algorithm 141 + 1. Sample depth from outgoing image at current UV 142 + 2. Per-pixel timing: `smoothstep(depth - spread, depth + spread, progress)` — foreground peels first 143 + 3. Multi-tap blur on outgoing image (9-tap kernel scaled by pixel_progress) 144 + 4. Cross-fade using pixel_progress 145 + 5. Saturation lerp for B&W ↔ color (luma-based desaturation in shader) 146 + 6. Parallax displacement continues on active image via pointer uniform 147 + 148 + ### Transition behavior 149 + - Auto-advance: ~6s per photo 150 + - Transition duration: ~1.5s with CSS-like ease-in-out 151 + - B&W wolf: `saturation_a/b` lerps from 0→1 or 1→0 during transition 152 + - Active photo retains parallax pointer-tracking 153 + - During transition: incoming photo's parallax gradually takes over 154 + 155 + ## Phase 3: Hero Section (`HeroShowcase.svelte`) 156 + 157 + Threlte `<Canvas>` filling the viewport with an orthographic camera looking at a fullscreen quad. 158 + 159 + ```svelte 160 + <Canvas createRenderer={webgpuWithFallback}> 161 + <T.OrthographicCamera makeDefault position.z={1} /> 162 + <T.Mesh> 163 + <T.PlaneGeometry args={[2, 2]} /> 164 + <DepthTransitionMaterial 165 + imageA={currentTexture} 166 + depthA={currentDepth} 167 + imageB={nextTexture} 168 + depthB={nextDepth} 169 + progress={transitionProgress} 170 + pointer={pointerPos} 171 + /> 172 + </T.Mesh> 173 + </Canvas> 174 + ``` 175 + 176 + - Uses `useTexture` from `@threlte/extras` for async loading with Suspense 177 + - Preloads current + next image pair 178 + - Dot indicator at bottom (HTML overlay via `<HTML>` from extras, or plain DOM) 179 + - Title + description overlay fades during transitions 180 + - Keyboard (←/→) + touch swipe navigation 181 + - Auto-advance timer resets on interaction 182 + 183 + ## Phase 4: Light Table (`LightTable.svelte`) 184 + 185 + - ~12 images, dark background (#0a0a0a) 186 + - CSS Grid with `auto-fill` for responsive columns 187 + - Images lazy-loaded with `loading="lazy"` + IntersectionObserver fade-in 188 + - Hover: `filter: brightness(1.2)`, slight `scale(1.03)`, title tooltip 189 + - Click: expand with View Transition API (`document.startViewTransition()`) 190 + - `view-transition-name` on each thumbnail for per-element morphing 191 + - Fallback: simple opacity crossfade 192 + 193 + ### Special: Barcelona Tiles Hex Reveal Effect 194 + 195 + The `20250323-IMG_4925.jpg` (2048x1280) has ~12 clearly visible Gaudi hexagonal pavement tiles with regular grout lines and an orange leaf focal point. 196 + 197 + **Multi-layer approach: CSS clip-path + scroll-driven + Houdini paint fallback** 198 + 199 + 1. **Pre-define hex grid coordinates** — Map each tile's center (x%, y%) and size as percentages. The grid is extremely regular — flat-top hexagons in an offset grid. 200 + 201 + 2. **Create ~12 overlay `<div>` elements**, each: 202 + - `position: absolute` over the image container 203 + - `clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)` 204 + - `background: url(tiles.jpg)` with `background-position` offsetting to show correct slice 205 + - Initial state: `opacity: 0; transform: scale(0.6) rotateX(40deg); filter: blur(4px)` 206 + 207 + 3. **Scroll-driven reveal** (modern browsers): 208 + ```css 209 + .hex-tile { 210 + animation: hex-reveal 1s ease-out both; 211 + animation-timeline: view(); 212 + animation-range: entry 10% entry 60%; 213 + } 214 + @keyframes hex-reveal { 215 + from { opacity: 0; transform: scale(0.6) rotateX(40deg); filter: blur(4px); } 216 + to { opacity: 1; transform: scale(1) rotateX(0deg); filter: blur(0); } 217 + } 218 + ``` 219 + Each tile gets a unique `animation-range` offset for center-outward stagger. 220 + 221 + 4. **`@property` for smooth custom easing**: 222 + ```css 223 + @property --hex-progress { 224 + syntax: "<number>"; 225 + inherits: false; 226 + initial-value: 0; 227 + } 228 + ``` 229 + Enables animating gradient overlays, glow effects per-tile. 230 + 231 + 5. **Houdini Paint API** (Chrome/Edge bonus): 232 + - Register a `paint(hex-grout)` worklet that draws the dark grout lines procedurally 233 + - Animates the grout "growing" between tiles as they settle into place 234 + - Other browsers: just use the image's natural grout lines (look fine already) 235 + 236 + 6. **Fallback** (no scroll-driven animation support): 237 + - IntersectionObserver triggers a `.visible` class 238 + - Simple CSS transition: `opacity 0.6s, transform 0.6s` 239 + 240 + **Stagger order**: Center tile (orange leaf) first → ring 1 (6 adjacent) → ring 2 (outer partial tiles). Delay ~100ms between rings. 241 + 242 + ## Phase 5: Scroll-Driven Morph (Hero → Light Table) 243 + 244 + Use CSS scroll-driven animations: 245 + 246 + ```css 247 + .hero-section { 248 + view-timeline-name: --hero; 249 + } 250 + 251 + .hero-image { 252 + animation: hero-shrink linear both; 253 + animation-timeline: --hero; 254 + animation-range: exit 0% exit 100%; 255 + } 256 + 257 + @keyframes hero-shrink { 258 + from { 259 + transform: scale(1); 260 + border-radius: 0; 261 + } 262 + to { 263 + transform: scale(0.15); 264 + border-radius: 8px; 265 + opacity: 0.8; 266 + } 267 + } 268 + ``` 269 + 270 + The WebGL canvas gets `pointer-events: none` and fades out during this transition, while a CSS `<img>` clone of the current hero image takes over for the morph animation (CSS transforms on a static image are cheaper and smoother than trying to animate a WebGL canvas position). 271 + 272 + **Sequence:** 273 + 1. As hero scrolls out of view, WebGL canvas fades (opacity 0) 274 + 2. A positioned `<img>` of the current photo takes its place 275 + 3. That `<img>` shrinks and repositions toward its grid slot (scroll-driven) 276 + 4. Simultaneously, other grid thumbnails fade in with staggered `animation-range` offsets 277 + 5. On scroll back up: reverse animation restores hero 278 + 279 + ## Phase 6: Page Assembly 280 + 281 + ```svelte 282 + <main> 283 + <HeroShowcase {photos} /> 284 + 285 + <!-- Scroll transition zone (~50vh) --> 286 + <div class="transition-zone" /> 287 + 288 + <LightTable {photos} {currentHeroIndex} /> 289 + </main> 290 + ``` 291 + 292 + - `morgan.photos` branding top-left (persistent) 293 + - Photo info overlay adapts to context (hero shows full description, grid shows title on hover) 294 + 295 + --- 296 + 297 + ## Dependencies 298 + 299 + ```sh 300 + pnpm add three @threlte/core @threlte/extras 301 + pnpm add -D @types/three 302 + ``` 303 + 304 + - `three` + `@threlte/core` + `@threlte/extras` — rendering, `<ImageMaterial>`, `useTexture`, `<HTML>` 305 + - `spatial-maker` (installed) — depth map generation 306 + - Everything else (scroll-driven animations, View Transitions, Houdini, `@property`) is native CSS/JS 307 + 308 + ### What we can delete 309 + - `src/lib/gl/renderer.ts` — replaced by Threlte 310 + - `src/lib/gl/shaders.ts` — replaced by custom ShaderMaterial or TSL nodes 311 + - `src/lib/components/DepthPhoto.svelte` — replaced by HeroShowcase 312 + - `src/lib/components/DepthSlice.svelte` — replaced or kept as alternate mode 313 + 314 + ## Files to Create/Edit 315 + 316 + | File | Action | 317 + |---|---| 318 + | `src/lib/components/DepthTransitionMaterial.svelte` | New — custom ShaderMaterial for depth crossfade | 319 + | `src/lib/components/HeroShowcase.svelte` | New — Threlte Canvas, 7-photo hero | 320 + | `src/lib/components/LightTable.svelte` | New — CSS grid with View Transitions | 321 + | `src/lib/components/HexReveal.svelte` | New — hex tile stagger for Barcelona Tiles | 322 + | `src/lib/shaders/depth-transition.frag` | New — GLSL fragment shader (or TSL nodes) | 323 + | `src/lib/shaders/depth-transition.vert` | New — GLSL vertex shader | 324 + | `src/routes/+page.svelte` | Edit — replace current gallery | 325 + | `static/samples/describe/depth/` | New — 7 generated depth maps | 326 + | `src/lib/hex-grid.ts` | New — hex coordinate data for the Barcelona Tiles image | 327 + 328 + --- 329 + 330 + ## Fallback Strategy 331 + 332 + Every cutting-edge feature degrades to a simple dissolve/fade: 333 + 334 + | Feature | Full experience | Fallback | 335 + |---|---|---| 336 + | WebGPU + TSL | Node-based shaders, compute | WebGL via Three.js auto-fallback | 337 + | Depth transitions | Per-pixel depth-driven crossfade | Simple opacity crossfade between `<img>` | 338 + | Scroll-driven animations | Scroll-bound hero→grid morph | IntersectionObserver + CSS transitions | 339 + | View Transition API | Morphing thumbnail → lightbox | Opacity fade | 340 + | CSS `@property` | Animated gradient stops, glow | Static values | 341 + | Houdini Paint API | Procedural hex grout animation | Image's natural grout lines | 342 + | `clip-path: polygon()` | Animated hex reveal | `clip-path: inset()` or opacity fade | 343 + 344 + Detection: 345 + ```js 346 + const hasScrollTimeline = CSS.supports('animation-timeline', 'scroll()') 347 + const hasViewTransitions = 'startViewTransition' in document 348 + const hasPaintWorklet = 'paintWorklet' in CSS 349 + ``` 350 + 351 + ## Open Questions 352 + 353 + 1. **Hex tile coordinates** — Manually map ~12 hex positions from the image as (cx%, cy%, radius%) tuples. A quick Python script with OpenCV edge detection on the grout lines could automate this, but manual mapping of 12 regular hexagons is probably 10 minutes of work. 354 + 355 + 2. **Image loading strategy** — 12 grid images × ~800KB = ~10MB. Options: 356 + - Generate 400px-wide thumbnails at build time (Python script or sharp) 357 + - Use `<img srcset>` with multiple sizes 358 + - Cloudflare Image Resizing (if enabled on the worker) 359 + - Load full-size only on lightbox expand 360 + 361 + 3. **TSL vs raw GLSL** — TSL is cleaner and future-proof but adds complexity if we also want a raw GLSL fallback. Could write in GLSL first (works everywhere via Three.js ShaderMaterial), then optionally port to TSL for WebGPU bonus features later.
+10 -1
package.json
··· 13 13 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 14 14 }, 15 15 "pnpm": { 16 - "onlyBuiltDependencies": ["sharp", "workerd"] 16 + "onlyBuiltDependencies": [ 17 + "sharp", 18 + "workerd" 19 + ] 17 20 }, 18 21 "devDependencies": { 19 22 "@sveltejs/adapter-cloudflare": "^7.2.7", 20 23 "@sveltejs/kit": "^2.50.2", 21 24 "@sveltejs/vite-plugin-svelte": "^6.2.4", 25 + "@types/three": "^0.183.0", 22 26 "svelte": "^5.49.2", 23 27 "svelte-check": "^4.3.6", 24 28 "typescript": "^5.9.3", 25 29 "vite": "^7.3.1" 30 + }, 31 + "dependencies": { 32 + "@threlte/core": "^8.3.1", 33 + "@threlte/extras": "^9.7.1", 34 + "three": "^0.183.0" 26 35 } 27 36 }
+238
pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + dependencies: 11 + '@threlte/core': 12 + specifier: ^8.3.1 13 + version: 8.3.1(svelte@5.51.2)(three@0.183.0) 14 + '@threlte/extras': 15 + specifier: ^9.7.1 16 + version: 9.7.1(@types/three@0.183.0)(svelte@5.51.2)(three@0.183.0) 17 + three: 18 + specifier: ^0.183.0 19 + version: 0.183.0 10 20 devDependencies: 11 21 '@sveltejs/adapter-cloudflare': 12 22 specifier: ^7.2.7 ··· 17 27 '@sveltejs/vite-plugin-svelte': 18 28 specifier: ^6.2.4 19 29 version: 6.2.4(svelte@5.51.2)(vite@7.3.1) 30 + '@types/three': 31 + specifier: ^0.183.0 32 + version: 0.183.0 20 33 svelte: 21 34 specifier: ^5.49.2 22 35 version: 5.51.2 ··· 81 94 '@cspotcode/source-map-support@0.8.1': 82 95 resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} 83 96 engines: {node: '>=12'} 97 + 98 + '@dimforge/rapier3d-compat@0.12.0': 99 + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} 84 100 85 101 '@emnapi/runtime@1.8.1': 86 102 resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} ··· 615 631 svelte: ^5.0.0 616 632 vite: ^6.3.0 || ^7.0.0 617 633 634 + '@threejs-kit/instanced-sprite-mesh@2.5.1': 635 + resolution: {integrity: sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA==} 636 + peerDependencies: 637 + three: '>=0.170.0' 638 + 639 + '@threlte/core@8.3.1': 640 + resolution: {integrity: sha512-qKjjNCQ+40hyeBcfOMh/8ef5x/j5PG5Wmo/L9Ye0aDCcdD6fCewWxfp7tV/J3CxPzX1dEp1JGK7sjyc7ntZSrg==} 641 + peerDependencies: 642 + svelte: '>=5' 643 + three: '>=0.160' 644 + 645 + '@threlte/extras@9.7.1': 646 + resolution: {integrity: sha512-SGm59HDCdHxADFHuweHfFDknwubkCPodyK0pbfsVtOWWOX26gE2xfK7aKolh6YFDiPAjWjGxN0jIgkNbbr1ohg==} 647 + peerDependencies: 648 + svelte: '>=5' 649 + three: '>=0.160' 650 + 651 + '@tweenjs/tween.js@23.1.3': 652 + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} 653 + 618 654 '@types/cookie@0.6.0': 619 655 resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 620 656 621 657 '@types/estree@1.0.8': 622 658 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 659 + 660 + '@types/stats.js@0.17.4': 661 + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} 662 + 663 + '@types/three@0.183.0': 664 + resolution: {integrity: sha512-AaGkvloQhxdrfMm/HbY8cpOz1K1jkEELn6zjFoY3yhAiC7zhhZE19+gDBybYwKk+GqXmWKxqDCB43NqyW3+QZw==} 623 665 624 666 '@types/trusted-types@2.0.7': 625 667 resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 626 668 669 + '@types/webxr@0.5.24': 670 + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} 671 + 672 + '@webgpu/types@0.1.69': 673 + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} 674 + 627 675 acorn@8.15.0: 628 676 resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 629 677 engines: {node: '>=0.4.0'} ··· 637 685 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 638 686 engines: {node: '>= 0.4'} 639 687 688 + bidi-js@1.0.3: 689 + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} 690 + 640 691 blake3-wasm@2.1.5: 641 692 resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} 642 693 694 + camera-controls@3.1.2: 695 + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} 696 + engines: {node: '>=22.0.0', npm: '>=10.5.1'} 697 + peerDependencies: 698 + three: '>=0.126.1' 699 + 643 700 chokidar@4.0.3: 644 701 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 645 702 engines: {node: '>= 14.16.0'} ··· 667 724 devalue@5.6.2: 668 725 resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} 669 726 727 + diet-sprite@0.0.1: 728 + resolution: {integrity: sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A==} 729 + 730 + earcut@2.2.4: 731 + resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} 732 + 670 733 error-stack-parser-es@1.0.5: 671 734 resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} 672 735 ··· 690 753 picomatch: 691 754 optional: true 692 755 756 + fflate@0.8.2: 757 + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} 758 + 693 759 fsevents@2.3.3: 694 760 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 695 761 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} ··· 705 771 locate-character@3.0.0: 706 772 resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} 707 773 774 + maath@0.10.8: 775 + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} 776 + peerDependencies: 777 + '@types/three': '>=0.134.0' 778 + three: '>=0.134.0' 779 + 708 780 magic-string@0.30.21: 709 781 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 710 782 783 + meshoptimizer@1.0.1: 784 + resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} 785 + 711 786 miniflare@4.20260217.0: 712 787 resolution: {integrity: sha512-t2v02Vi9SUiiXoHoxLvsntli7N35e/35PuRAYEqHWtHOdDX3bqQ73dBQ0tI12/8ThCb2by2tVs7qOvgwn6xSBQ==} 713 788 engines: {node: '>=18.0.0'} 714 789 hasBin: true 790 + 791 + mitt@3.0.1: 792 + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} 715 793 716 794 mri@1.2.0: 717 795 resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} ··· 754 832 resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} 755 833 engines: {node: '>=8'} 756 834 835 + require-from-string@2.0.2: 836 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 837 + engines: {node: '>=0.10.0'} 838 + 757 839 rollup@4.57.1: 758 840 resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} 759 841 engines: {node: '>=18.0.0', npm: '>=8.0.0'} ··· 799 881 resolution: {integrity: sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w==} 800 882 engines: {node: '>=18'} 801 883 884 + three-instanced-uniforms-mesh@0.52.4: 885 + resolution: {integrity: sha512-YwDBy05hfKZQtU+Rp0KyDf9yH4GxfhxMbVt9OYruxdgLfPwmDG5oAbGoW0DrKtKZSM3BfFcCiejiOHCjFBTeng==} 886 + peerDependencies: 887 + three: '>=0.125.0' 888 + 889 + three-mesh-bvh@0.9.8: 890 + resolution: {integrity: sha512-YphYvdXEZSXdz6iNdWJo1RB6qvSCRyiXPEVSvNU6xVWbLDOdSrfEIsJOpgFOnefdmVEvZ6M+sY0cjh9gl7MvdA==} 891 + peerDependencies: 892 + three: '>= 0.159.0' 893 + 894 + three-perf@1.0.11: 895 + resolution: {integrity: sha512-OgBpZjwL+csQKGKZjpkH/QHdbGFMxqngMbSEJeSnVNfXDYd6On7WHNv/GhUZH4YxIpNMwMahBWrNnsJvnbSJHQ==} 896 + peerDependencies: 897 + three: '>=0.170' 898 + 899 + three-viewport-gizmo@2.2.0: 900 + resolution: {integrity: sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q==} 901 + peerDependencies: 902 + three: '>=0.162.0 <1.0.0' 903 + 904 + three@0.183.0: 905 + resolution: {integrity: sha512-G6SH2jfefIVa2YI4JL2VbgQhrrbp1A8dRc7lr3PW827kdVyaX2RgH6M5FmjmdVFLgSHppyg3OYOZdTfWElle+g==} 906 + 802 907 tinyglobby@0.2.15: 803 908 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 804 909 engines: {node: '>=12.0.0'} ··· 807 912 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 808 913 engines: {node: '>=6'} 809 914 915 + troika-three-text@0.52.4: 916 + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} 917 + peerDependencies: 918 + three: '>=0.125.0' 919 + 920 + troika-three-utils@0.52.4: 921 + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} 922 + peerDependencies: 923 + three: '>=0.125.0' 924 + 925 + troika-worker-utils@0.52.0: 926 + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} 927 + 810 928 tslib@2.8.1: 811 929 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 812 930 931 + tweakpane@3.1.10: 932 + resolution: {integrity: sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ==} 933 + 813 934 typescript@5.9.3: 814 935 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 815 936 engines: {node: '>=14.17'} ··· 869 990 peerDependenciesMeta: 870 991 vite: 871 992 optional: true 993 + 994 + webgl-sdf-generator@1.1.1: 995 + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} 872 996 873 997 workerd@1.20260217.0: 874 998 resolution: {integrity: sha512-6jVisS6wB6KbF+F9DVoDUy9p7MON8qZCFSaL8OcDUioMwknsUPFojUISu3/c30ZOZ24D4h7oqaahFc5C6huilw==} ··· 940 1064 '@cspotcode/source-map-support@0.8.1': 941 1065 dependencies: 942 1066 '@jridgewell/trace-mapping': 0.3.9 1067 + 1068 + '@dimforge/rapier3d-compat@0.12.0': {} 943 1069 944 1070 '@emnapi/runtime@1.8.1': 945 1071 dependencies: ··· 1288 1414 vite: 7.3.1 1289 1415 vitefu: 1.1.1(vite@7.3.1) 1290 1416 1417 + '@threejs-kit/instanced-sprite-mesh@2.5.1(@types/three@0.183.0)(three@0.183.0)': 1418 + dependencies: 1419 + diet-sprite: 0.0.1 1420 + earcut: 2.2.4 1421 + maath: 0.10.8(@types/three@0.183.0)(three@0.183.0) 1422 + three: 0.183.0 1423 + three-instanced-uniforms-mesh: 0.52.4(three@0.183.0) 1424 + troika-three-utils: 0.52.4(three@0.183.0) 1425 + transitivePeerDependencies: 1426 + - '@types/three' 1427 + 1428 + '@threlte/core@8.3.1(svelte@5.51.2)(three@0.183.0)': 1429 + dependencies: 1430 + mitt: 3.0.1 1431 + svelte: 5.51.2 1432 + three: 0.183.0 1433 + 1434 + '@threlte/extras@9.7.1(@types/three@0.183.0)(svelte@5.51.2)(three@0.183.0)': 1435 + dependencies: 1436 + '@threejs-kit/instanced-sprite-mesh': 2.5.1(@types/three@0.183.0)(three@0.183.0) 1437 + camera-controls: 3.1.2(three@0.183.0) 1438 + svelte: 5.51.2 1439 + three: 0.183.0 1440 + three-mesh-bvh: 0.9.8(three@0.183.0) 1441 + three-perf: 1.0.11(three@0.183.0) 1442 + three-viewport-gizmo: 2.2.0(three@0.183.0) 1443 + troika-three-text: 0.52.4(three@0.183.0) 1444 + transitivePeerDependencies: 1445 + - '@types/three' 1446 + 1447 + '@tweenjs/tween.js@23.1.3': {} 1448 + 1291 1449 '@types/cookie@0.6.0': {} 1292 1450 1293 1451 '@types/estree@1.0.8': {} 1294 1452 1453 + '@types/stats.js@0.17.4': {} 1454 + 1455 + '@types/three@0.183.0': 1456 + dependencies: 1457 + '@dimforge/rapier3d-compat': 0.12.0 1458 + '@tweenjs/tween.js': 23.1.3 1459 + '@types/stats.js': 0.17.4 1460 + '@types/webxr': 0.5.24 1461 + '@webgpu/types': 0.1.69 1462 + fflate: 0.8.2 1463 + meshoptimizer: 1.0.1 1464 + 1295 1465 '@types/trusted-types@2.0.7': {} 1296 1466 1467 + '@types/webxr@0.5.24': {} 1468 + 1469 + '@webgpu/types@0.1.69': {} 1470 + 1297 1471 acorn@8.15.0: {} 1298 1472 1299 1473 aria-query@5.3.2: {} 1300 1474 1301 1475 axobject-query@4.1.0: {} 1302 1476 1477 + bidi-js@1.0.3: 1478 + dependencies: 1479 + require-from-string: 2.0.2 1480 + 1303 1481 blake3-wasm@2.1.5: {} 1304 1482 1483 + camera-controls@3.1.2(three@0.183.0): 1484 + dependencies: 1485 + three: 0.183.0 1486 + 1305 1487 chokidar@4.0.3: 1306 1488 dependencies: 1307 1489 readdirp: 4.1.2 ··· 1317 1499 detect-libc@2.1.2: {} 1318 1500 1319 1501 devalue@5.6.2: {} 1502 + 1503 + diet-sprite@0.0.1: {} 1504 + 1505 + earcut@2.2.4: {} 1320 1506 1321 1507 error-stack-parser-es@1.0.5: {} 1322 1508 ··· 1359 1545 optionalDependencies: 1360 1546 picomatch: 4.0.3 1361 1547 1548 + fflate@0.8.2: {} 1549 + 1362 1550 fsevents@2.3.3: 1363 1551 optional: true 1364 1552 ··· 1370 1558 1371 1559 locate-character@3.0.0: {} 1372 1560 1561 + maath@0.10.8(@types/three@0.183.0)(three@0.183.0): 1562 + dependencies: 1563 + '@types/three': 0.183.0 1564 + three: 0.183.0 1565 + 1373 1566 magic-string@0.30.21: 1374 1567 dependencies: 1375 1568 '@jridgewell/sourcemap-codec': 1.5.5 1376 1569 1570 + meshoptimizer@1.0.1: {} 1571 + 1377 1572 miniflare@4.20260217.0: 1378 1573 dependencies: 1379 1574 '@cspotcode/source-map-support': 0.8.1 ··· 1385 1580 transitivePeerDependencies: 1386 1581 - bufferutil 1387 1582 - utf-8-validate 1583 + 1584 + mitt@3.0.1: {} 1388 1585 1389 1586 mri@1.2.0: {} 1390 1587 ··· 1411 1608 readdirp@4.1.2: {} 1412 1609 1413 1610 regexparam@3.0.0: {} 1611 + 1612 + require-from-string@2.0.2: {} 1414 1613 1415 1614 rollup@4.57.1: 1416 1615 dependencies: ··· 1523 1722 magic-string: 0.30.21 1524 1723 zimmerframe: 1.1.4 1525 1724 1725 + three-instanced-uniforms-mesh@0.52.4(three@0.183.0): 1726 + dependencies: 1727 + three: 0.183.0 1728 + troika-three-utils: 0.52.4(three@0.183.0) 1729 + 1730 + three-mesh-bvh@0.9.8(three@0.183.0): 1731 + dependencies: 1732 + three: 0.183.0 1733 + 1734 + three-perf@1.0.11(three@0.183.0): 1735 + dependencies: 1736 + three: 0.183.0 1737 + troika-three-text: 0.52.4(three@0.183.0) 1738 + tweakpane: 3.1.10 1739 + 1740 + three-viewport-gizmo@2.2.0(three@0.183.0): 1741 + dependencies: 1742 + three: 0.183.0 1743 + 1744 + three@0.183.0: {} 1745 + 1526 1746 tinyglobby@0.2.15: 1527 1747 dependencies: 1528 1748 fdir: 6.5.0(picomatch@4.0.3) ··· 1530 1750 1531 1751 totalist@3.0.1: {} 1532 1752 1753 + troika-three-text@0.52.4(three@0.183.0): 1754 + dependencies: 1755 + bidi-js: 1.0.3 1756 + three: 0.183.0 1757 + troika-three-utils: 0.52.4(three@0.183.0) 1758 + troika-worker-utils: 0.52.0 1759 + webgl-sdf-generator: 1.1.1 1760 + 1761 + troika-three-utils@0.52.4(three@0.183.0): 1762 + dependencies: 1763 + three: 0.183.0 1764 + 1765 + troika-worker-utils@0.52.0: {} 1766 + 1533 1767 tslib@2.8.1: 1534 1768 optional: true 1769 + 1770 + tweakpane@3.1.10: {} 1535 1771 1536 1772 typescript@5.9.3: {} 1537 1773 ··· 1555 1791 vitefu@1.1.1(vite@7.3.1): 1556 1792 optionalDependencies: 1557 1793 vite: 7.3.1 1794 + 1795 + webgl-sdf-generator@1.1.1: {} 1558 1796 1559 1797 workerd@1.20260217.0: 1560 1798 optionalDependencies:
+1 -2
src/app.css
··· 15 15 } 16 16 17 17 html, body { 18 - height: 100%; 19 18 background: var(--color-bg); 20 19 color: var(--color-text); 21 20 font-family: system-ui, -apple-system, sans-serif; 22 21 -webkit-font-smoothing: antialiased; 23 - overflow: hidden; 22 + overflow-x: hidden; 24 23 } 25 24 26 25 ::selection {
+154
src/lib/components/DepthTransitionMaterial.svelte
··· 1 + <script lang="ts"> 2 + import { T } from '@threlte/core'; 3 + import { ShaderMaterial, Vector2 } from 'three'; 4 + import type { Texture } from 'three'; 5 + 6 + interface Props { 7 + imageA: Texture | null; 8 + depthA: Texture | null; 9 + imageB: Texture | null; 10 + depthB: Texture | null; 11 + progress: number; 12 + saturationA?: number; 13 + saturationB?: number; 14 + pointer?: { x: number; y: number }; 15 + intensity?: number; 16 + } 17 + 18 + let { 19 + imageA, 20 + depthA, 21 + imageB, 22 + depthB, 23 + progress, 24 + saturationA = 1, 25 + saturationB = 1, 26 + pointer = { x: 0.5, y: 0.5 }, 27 + intensity = 0.02, 28 + }: Props = $props(); 29 + 30 + const vertexShader = /* glsl */ ` 31 + varying vec2 vUv; 32 + void main() { 33 + vUv = uv; 34 + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 35 + } 36 + `; 37 + 38 + const fragmentShader = /* glsl */ ` 39 + uniform sampler2D u_image_a; 40 + uniform sampler2D u_depth_a; 41 + uniform sampler2D u_image_b; 42 + uniform sampler2D u_depth_b; 43 + uniform float u_progress; 44 + uniform float u_saturation_a; 45 + uniform float u_saturation_b; 46 + uniform vec2 u_pointer; 47 + uniform float u_intensity; 48 + uniform vec2 u_resolution; 49 + 50 + varying vec2 vUv; 51 + 52 + // Cover-fit UV correction — maps texture to fill the quad while preserving aspect ratio 53 + vec2 coverUv(vec2 uv, vec2 imgSize, vec2 quadSize) { 54 + vec2 s = quadSize / imgSize; 55 + float scale = max(s.x, s.y); 56 + vec2 offset = (quadSize - imgSize * scale) / 2.0 / quadSize; 57 + return uv * (quadSize / (imgSize * scale)) + offset * 0.0 58 + + (uv - 0.5) * (1.0 - quadSize / (imgSize * scale)) * 0.0; 59 + } 60 + 61 + // Mirror wrap to avoid edge tearing from parallax 62 + vec2 mirrored(vec2 uv) { 63 + vec2 m = mod(uv, 2.0); 64 + return mix(m, 2.0 - m, step(1.0, m)); 65 + } 66 + 67 + // Saturation adjustment via luma 68 + vec3 adjustSat(vec3 rgb, float sat) { 69 + float luma = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); 70 + return mix(vec3(luma), rgb, sat); 71 + } 72 + 73 + // 9-tap box blur approximation 74 + vec4 blurSample(sampler2D tex, vec2 uv, float amount) { 75 + vec4 sum = vec4(0.0); 76 + float total = 0.0; 77 + vec2 px = amount / u_resolution; 78 + for (int x = -1; x <= 1; x++) { 79 + for (int y = -1; y <= 1; y++) { 80 + float w = 1.0 - length(vec2(x, y)) * 0.25; 81 + sum += texture2D(tex, mirrored(uv + vec2(float(x), float(y)) * px)) * w; 82 + total += w; 83 + } 84 + } 85 + return sum / total; 86 + } 87 + 88 + void main() { 89 + vec2 uv = vUv; 90 + 91 + // Pointer-driven parallax on whichever image is dominant 92 + vec2 pointerOffset = (u_pointer - 0.5) * u_intensity; 93 + 94 + // Sample depth from outgoing image 95 + float dA = texture2D(u_depth_a, mirrored(uv)).r; 96 + float dB = texture2D(u_depth_b, mirrored(uv)).r; 97 + 98 + // Per-pixel progress: foreground (high depth) peels first 99 + float spread = 0.35; 100 + float pixelProgress = smoothstep(dA - spread, dA + spread, u_progress); 101 + 102 + // Parallax displacement — outgoing fades, incoming takes over 103 + vec2 uvA = mirrored(uv - pointerOffset * dA * (1.0 - pixelProgress)); 104 + vec2 uvB = mirrored(uv - pointerOffset * dB * pixelProgress); 105 + 106 + // Sample with blur proportional to pixel progress (outgoing blurs out) 107 + float blurAmount = pixelProgress * (1.0 - pixelProgress) * 8.0; 108 + vec4 colA = blurSample(u_image_a, uvA, blurAmount * 2.0 + 0.001); 109 + vec4 colB = texture2D(u_image_b, uvB); 110 + 111 + // Saturation adjustment (for B&W ↔ color transitions) 112 + float satA = mix(u_saturation_a, u_saturation_b, pixelProgress); 113 + colA.rgb = adjustSat(colA.rgb, satA); 114 + colB.rgb = adjustSat(colB.rgb, u_saturation_b); 115 + 116 + // Depth-modulated vignette on outgoing image 117 + float vigA = smoothstep(0.0, 0.4, dA) * (1.0 - pixelProgress); 118 + colA.rgb *= (1.0 - vigA * 0.3); 119 + 120 + gl_FragColor = mix(colA, colB, pixelProgress); 121 + } 122 + `; 123 + 124 + const material = new ShaderMaterial({ 125 + vertexShader, 126 + fragmentShader, 127 + uniforms: { 128 + u_image_a: { value: null }, 129 + u_depth_a: { value: null }, 130 + u_image_b: { value: null }, 131 + u_depth_b: { value: null }, 132 + u_progress: { value: 0 }, 133 + u_saturation_a: { value: 1 }, 134 + u_saturation_b: { value: 1 }, 135 + u_pointer: { value: new Vector2(0.5, 0.5) }, 136 + u_intensity: { value: 0.02 }, 137 + u_resolution: { value: new Vector2(1920, 1080) }, 138 + }, 139 + }); 140 + 141 + $effect(() => { 142 + material.uniforms.u_image_a.value = imageA; 143 + material.uniforms.u_depth_a.value = depthA; 144 + material.uniforms.u_image_b.value = imageB; 145 + material.uniforms.u_depth_b.value = depthB; 146 + material.uniforms.u_progress.value = progress; 147 + material.uniforms.u_saturation_a.value = saturationA; 148 + material.uniforms.u_saturation_b.value = saturationB; 149 + material.uniforms.u_pointer.value.set(pointer.x, pointer.y); 150 + material.uniforms.u_intensity.value = intensity; 151 + }); 152 + </script> 153 + 154 + <T is={material} />
+85
src/lib/components/HeroScene.svelte
··· 1 + <script lang="ts"> 2 + import { T, useThrelte } from '@threlte/core'; 3 + import { LinearFilter, OrthographicCamera, SRGBColorSpace, TextureLoader } from 'three'; 4 + import type { Texture } from 'three'; 5 + import DepthTransitionMaterial from './DepthTransitionMaterial.svelte'; 6 + 7 + interface Props { 8 + srcA: string; 9 + depthA: string; 10 + srcB: string; 11 + depthB: string; 12 + progress: number; 13 + saturationA?: number; 14 + saturationB?: number; 15 + pointer: { x: number; y: number }; 16 + } 17 + 18 + let { 19 + srcA, 20 + depthA, 21 + srcB, 22 + depthB, 23 + progress, 24 + saturationA = 1, 25 + saturationB = 1, 26 + pointer, 27 + }: Props = $props(); 28 + 29 + const { camera, invalidate } = useThrelte(); 30 + 31 + const cam = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10); 32 + cam.position.set(0, 0, 1); 33 + $effect(() => { camera.set(cam); }); 34 + 35 + const loader = new TextureLoader(); 36 + const cache = new Map<string, Promise<Texture>>(); 37 + 38 + function loadTex(url: string, color = false): Promise<Texture> { 39 + if (cache.has(url)) return cache.get(url)!; 40 + const p = loader.loadAsync(url).then(t => { 41 + if (color) t.colorSpace = SRGBColorSpace; 42 + t.minFilter = LinearFilter; 43 + return t; 44 + }); 45 + cache.set(url, p); 46 + return p; 47 + } 48 + 49 + let textures = $state<[Texture, Texture, Texture, Texture] | null>(null); 50 + 51 + $effect(() => { 52 + const a = srcA, da = depthA, b = srcB, db = depthB; 53 + textures = null; 54 + Promise.all([loadTex(a, true), loadTex(da), loadTex(b, true), loadTex(db)]) 55 + .then(result => { 56 + if (srcA === a && depthA === da && srcB === b && depthB === db) { 57 + textures = result as [Texture, Texture, Texture, Texture]; 58 + invalidate(); 59 + } 60 + }); 61 + }); 62 + 63 + // Invalidate on every animated change so on-demand rendering picks it up 64 + $effect(() => { 65 + // Track reactive dependencies 66 + progress; pointer.x; pointer.y; 67 + invalidate(); 68 + }); 69 + </script> 70 + 71 + <T.Mesh> 72 + <T.PlaneGeometry args={[2, 2]} /> 73 + {#if textures} 74 + <DepthTransitionMaterial 75 + imageA={textures[0]} 76 + depthA={textures[1]} 77 + imageB={textures[2]} 78 + depthB={textures[3]} 79 + {progress} 80 + {saturationA} 81 + {saturationB} 82 + {pointer} 83 + /> 84 + {/if} 85 + </T.Mesh>
+275
src/lib/components/HeroShowcase.svelte
··· 1 + <script lang="ts"> 2 + import { Canvas } from '@threlte/core'; 3 + import HeroScene from './HeroScene.svelte'; 4 + 5 + interface HeroPhoto { 6 + src: string; 7 + depthSrc: string; 8 + title: string; 9 + caption?: string; 10 + saturation?: number; // 0 for B&W, 1 for full color 11 + } 12 + 13 + interface Props { 14 + photos: HeroPhoto[]; 15 + onindexchange?: (index: number) => void; 16 + } 17 + 18 + let { photos, onindexchange }: Props = $props(); 19 + 20 + let currentIndex = $state(0); 21 + let nextIndex = $state(1); 22 + let progress = $state(0); 23 + let isTransitioning = $state(false); 24 + let pointer = $state({ x: 0.5, y: 0.5 }); 25 + let targetPointer = $state({ x: 0.5, y: 0.5 }); 26 + let canvasEl: HTMLDivElement; 27 + let autoTimer: ReturnType<typeof setTimeout>; 28 + 29 + const TRANSITION_DURATION = 1500; 30 + const AUTO_ADVANCE = 6000; 31 + 32 + function ease(t: number): number { 33 + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; 34 + } 35 + 36 + function goTo(index: number) { 37 + if (isTransitioning || index === currentIndex) return; 38 + clearTimeout(autoTimer); 39 + nextIndex = ((index % photos.length) + photos.length) % photos.length; 40 + isTransitioning = true; 41 + progress = 0; 42 + 43 + const start = performance.now(); 44 + function step(now: number) { 45 + const t = Math.min((now - start) / TRANSITION_DURATION, 1); 46 + progress = ease(t); 47 + if (t < 1) { 48 + requestAnimationFrame(step); 49 + } else { 50 + progress = 0; 51 + currentIndex = nextIndex; 52 + nextIndex = (currentIndex + 1) % photos.length; 53 + isTransitioning = false; 54 + onindexchange?.(currentIndex); 55 + scheduleAuto(); 56 + } 57 + } 58 + requestAnimationFrame(step); 59 + } 60 + 61 + function navigate(dir: -1 | 1) { 62 + const target = ((currentIndex + dir) % photos.length + photos.length) % photos.length; 63 + goTo(target); 64 + } 65 + 66 + function scheduleAuto() { 67 + clearTimeout(autoTimer); 68 + autoTimer = setTimeout(() => { 69 + goTo((currentIndex + 1) % photos.length); 70 + }, AUTO_ADVANCE); 71 + } 72 + 73 + function onPointerMove(e: PointerEvent) { 74 + const rect = canvasEl?.getBoundingClientRect(); 75 + if (!rect) return; 76 + targetPointer = { 77 + x: (e.clientX - rect.left) / rect.width, 78 + y: 1 - (e.clientY - rect.top) / rect.height, 79 + }; 80 + } 81 + 82 + function onTouchMove(e: TouchEvent) { 83 + const t = e.touches[0]; 84 + const rect = canvasEl?.getBoundingClientRect(); 85 + if (!rect || !t) return; 86 + targetPointer = { 87 + x: (t.clientX - rect.left) / rect.width, 88 + y: 1 - (t.clientY - rect.top) / rect.height, 89 + }; 90 + } 91 + 92 + // Lerp pointer on rAF outside of Threlte 93 + $effect(() => { 94 + let id: number; 95 + function tick() { 96 + pointer = { 97 + x: pointer.x + (targetPointer.x - pointer.x) * 0.06, 98 + y: pointer.y + (targetPointer.y - pointer.y) * 0.06, 99 + }; 100 + id = requestAnimationFrame(tick); 101 + } 102 + id = requestAnimationFrame(tick); 103 + return () => cancelAnimationFrame(id); 104 + }); 105 + 106 + $effect(() => { 107 + function onKey(e: KeyboardEvent) { 108 + if (e.key === 'ArrowRight' || e.key === 'l') navigate(1); 109 + else if (e.key === 'ArrowLeft' || e.key === 'h') navigate(-1); 110 + } 111 + window.addEventListener('keydown', onKey); 112 + scheduleAuto(); 113 + return () => { 114 + window.removeEventListener('keydown', onKey); 115 + clearTimeout(autoTimer); 116 + }; 117 + }); 118 + 119 + // Swipe detection 120 + let touchStartX = 0; 121 + function onTouchStart(e: TouchEvent) { 122 + touchStartX = e.touches[0]?.clientX ?? 0; 123 + } 124 + function onTouchEnd(e: TouchEvent) { 125 + const dx = (e.changedTouches[0]?.clientX ?? 0) - touchStartX; 126 + if (Math.abs(dx) > 50) navigate(dx < 0 ? 1 : -1); 127 + } 128 + 129 + let current = $derived(photos[currentIndex]); 130 + let next = $derived(photos[nextIndex]); 131 + let uiOpacity = $derived(isTransitioning ? 0 : 1); 132 + </script> 133 + 134 + <div 135 + class="hero" 136 + bind:this={canvasEl} 137 + onpointermove={onPointerMove} 138 + ontouchstart={onTouchStart} 139 + ontouchmove={onTouchMove} 140 + ontouchend={onTouchEnd} 141 + role="region" 142 + aria-label="Photo showcase" 143 + > 144 + <Canvas> 145 + <HeroScene 146 + srcA={current.src} 147 + depthA={current.depthSrc} 148 + srcB={next.src} 149 + depthB={next.depthSrc} 150 + {progress} 151 + saturationA={current.saturation ?? 1} 152 + saturationB={next.saturation ?? 1} 153 + {pointer} 154 + /> 155 + </Canvas> 156 + 157 + <!-- Caption overlay --> 158 + <div class="overlay" style:opacity={uiOpacity}> 159 + <div class="caption"> 160 + <h2>{current.title}</h2> 161 + {#if current.caption} 162 + <p>{current.caption}</p> 163 + {/if} 164 + </div> 165 + </div> 166 + 167 + <!-- Dot indicators --> 168 + <div class="dots" aria-label="Photo navigation"> 169 + {#each photos as _, i} 170 + <button 171 + class="dot" 172 + class:active={i === currentIndex} 173 + onclick={() => goTo(i)} 174 + aria-label="Go to photo {i + 1}" 175 + ></button> 176 + {/each} 177 + </div> 178 + 179 + <!-- Arrow navigation --> 180 + <button class="arrow arrow-left" onclick={() => navigate(-1)} aria-label="Previous photo">‹</button> 181 + <button class="arrow arrow-right" onclick={() => navigate(1)} aria-label="Next photo">›</button> 182 + </div> 183 + 184 + <style> 185 + .hero { 186 + position: relative; 187 + width: 100%; 188 + height: 100svh; 189 + overflow: hidden; 190 + background: #000; 191 + cursor: none; 192 + } 193 + 194 + .hero :global(canvas) { 195 + display: block; 196 + width: 100% !important; 197 + height: 100% !important; 198 + } 199 + 200 + .overlay { 201 + position: absolute; 202 + inset: 0; 203 + pointer-events: none; 204 + transition: opacity 0.6s ease; 205 + display: flex; 206 + align-items: flex-end; 207 + padding: 3rem; 208 + } 209 + 210 + .caption { 211 + color: #fff; 212 + text-shadow: 0 1px 12px rgba(0, 0, 0, 0.8); 213 + } 214 + 215 + .caption h2 { 216 + font-size: clamp(1.25rem, 3vw, 2rem); 217 + font-weight: 300; 218 + letter-spacing: 0.05em; 219 + margin-bottom: 0.25rem; 220 + } 221 + 222 + .caption p { 223 + font-size: clamp(0.8rem, 1.5vw, 1rem); 224 + opacity: 0.7; 225 + font-weight: 300; 226 + } 227 + 228 + .dots { 229 + position: absolute; 230 + bottom: 1.5rem; 231 + left: 50%; 232 + transform: translateX(-50%); 233 + display: flex; 234 + gap: 0.5rem; 235 + pointer-events: all; 236 + } 237 + 238 + .dot { 239 + width: 6px; 240 + height: 6px; 241 + border-radius: 50%; 242 + background: rgba(255, 255, 255, 0.35); 243 + border: none; 244 + cursor: pointer; 245 + transition: background 0.3s, transform 0.3s; 246 + padding: 0; 247 + } 248 + 249 + .dot.active { 250 + background: #fff; 251 + transform: scale(1.4); 252 + } 253 + 254 + .arrow { 255 + position: absolute; 256 + top: 50%; 257 + transform: translateY(-50%); 258 + background: none; 259 + border: none; 260 + color: rgba(255, 255, 255, 0.5); 261 + font-size: 3rem; 262 + cursor: pointer; 263 + padding: 1rem; 264 + transition: color 0.2s; 265 + line-height: 1; 266 + user-select: none; 267 + } 268 + 269 + .arrow:hover { 270 + color: #fff; 271 + } 272 + 273 + .arrow-left { left: 0.5rem; } 274 + .arrow-right { right: 0.5rem; } 275 + </style>
+166
src/lib/components/HexReveal.svelte
··· 1 + <script lang="ts"> 2 + // Barcelona Tiles image: 20250323-IMG_4925.jpg — 2048×1280 3 + // Flat-top hexagonal grid. Tile dims: ~340px wide × 294px tall (as % of image) 4 + 5 + interface Props { 6 + src: string; 7 + alt?: string; 8 + } 9 + 10 + let { src, alt = '' }: Props = $props(); 11 + 12 + // (cx, cy) = tile center as % of full image dimensions 13 + const tiles = [ 14 + // Row 0 — top partial tiles 15 + { cx: 8, cy: 10, ring: 2 }, 16 + { cx: 24, cy: 2, ring: 2 }, 17 + { cx: 41, cy: 10, ring: 2 }, 18 + { cx: 57, cy: 2, ring: 2 }, 19 + { cx: 74, cy: 10, ring: 2 }, 20 + { cx: 90, cy: 2, ring: 2 }, 21 + // Row 1 — middle 22 + { cx: 8, cy: 46, ring: 1 }, 23 + { cx: 24, cy: 38, ring: 1 }, 24 + { cx: 41, cy: 46, ring: 0 }, // focal — orange leaf 25 + { cx: 57, cy: 38, ring: 1 }, 26 + { cx: 74, cy: 46, ring: 1 }, 27 + { cx: 90, cy: 38, ring: 1 }, 28 + // Row 2 — bottom partial tiles 29 + { cx: 8, cy: 82, ring: 2 }, 30 + { cx: 24, cy: 74, ring: 2 }, 31 + { cx: 41, cy: 82, ring: 2 }, 32 + { cx: 57, cy: 74, ring: 2 }, 33 + { cx: 74, cy: 82, ring: 2 }, 34 + { cx: 90, cy: 74, ring: 2 }, 35 + ]; 36 + 37 + const W = 17; // tile bounding-box width as % of container 38 + const H = 23.5; // tile bounding-box height as % of container 39 + 40 + // Flat-top hexagon (8 points to get proper flat sides) 41 + const hexClip = 'polygon(25% 0%, 75% 0%, 100% 25%, 100% 75%, 75% 100%, 25% 100%, 0% 75%, 0% 25%)'; 42 + 43 + const ringDelay = [0, 200, 420]; 44 + 45 + let wrapEl = $state<HTMLElement | null>(null); 46 + 47 + // Fallback for browsers without CSS scroll-driven animations 48 + $effect(() => { 49 + if (!wrapEl || CSS.supports('animation-timeline', 'view()')) return; 50 + const obs = new IntersectionObserver(([entry]) => { 51 + if (entry?.isIntersecting) { 52 + wrapEl?.classList.add('visible'); 53 + obs.disconnect(); 54 + } 55 + }, { threshold: 0.1 }); 56 + obs.observe(wrapEl); 57 + return () => obs.disconnect(); 58 + }); 59 + </script> 60 + 61 + <div class="hex-wrap" role="img" aria-label={alt} bind:this={wrapEl}> 62 + <img class="bg" {src} {alt} loading="lazy" /> 63 + 64 + {#each tiles as tile} 65 + {@const left = tile.cx - W / 2} 66 + {@const top = tile.cy - H / 2} 67 + {@const delay = ringDelay[tile.ring] ?? 0} 68 + <div 69 + class="tile" 70 + style:left="{left}%" 71 + style:top="{top}%" 72 + style:width="{W}%" 73 + style:height="{H}%" 74 + style:clip-path={hexClip} 75 + style:--delay="{delay}ms" 76 + > 77 + <img 78 + class="tile-img" 79 + {src} 80 + {alt} 81 + style:width="{(100 / W) * 100}%" 82 + style:left="-{(left / W) * 100}%" 83 + style:top="-{(top / H) * 100}%" 84 + /> 85 + </div> 86 + {/each} 87 + </div> 88 + 89 + <style> 90 + .hex-wrap { 91 + position: relative; 92 + width: 100%; 93 + height: 100%; 94 + overflow: hidden; 95 + background: #000; 96 + } 97 + 98 + .bg { 99 + position: absolute; 100 + inset: 0; 101 + width: 100%; 102 + height: 100%; 103 + object-fit: cover; 104 + filter: brightness(0.12); 105 + transition: filter 1s ease; 106 + } 107 + 108 + .hex-wrap:hover .bg { 109 + filter: brightness(0.25); 110 + } 111 + 112 + .tile { 113 + position: absolute; 114 + overflow: hidden; 115 + will-change: transform, opacity; 116 + animation: tile-in 0.6s cubic-bezier(0.34, 1.4, 0.64, 1) both; 117 + animation-delay: var(--delay); 118 + animation-timeline: view(); 119 + animation-range: entry 0% entry 65%; 120 + } 121 + 122 + /* Fallback for browsers without scroll-driven animations */ 123 + @supports not (animation-timeline: view()) { 124 + .tile { 125 + animation: none; 126 + opacity: 0; 127 + transform: scale(0.5) rotateY(90deg); 128 + filter: blur(8px); 129 + transition: 130 + opacity 0.6s cubic-bezier(0.34, 1.4, 0.64, 1), 131 + transform 0.6s cubic-bezier(0.34, 1.4, 0.64, 1), 132 + filter 0.6s ease; 133 + transition-delay: var(--delay); 134 + } 135 + :global(.hex-wrap.visible) .tile { 136 + opacity: 1; 137 + transform: none; 138 + filter: none; 139 + } 140 + } 141 + 142 + @keyframes tile-in { 143 + from { 144 + opacity: 0; 145 + transform: scale(0.5) rotateY(90deg); 146 + filter: blur(8px); 147 + } 148 + to { 149 + opacity: 1; 150 + transform: scale(1) rotateY(0deg); 151 + filter: blur(0); 152 + } 153 + } 154 + 155 + .tile-img { 156 + position: absolute; 157 + height: auto; 158 + display: block; 159 + aspect-ratio: 2048 / 1280; 160 + transition: transform 0.4s ease; 161 + } 162 + 163 + .tile:hover .tile-img { 164 + transform: scale(1.06); 165 + } 166 + </style>
+261
src/lib/components/LightTable.svelte
··· 1 + <script lang="ts"> 2 + import HexReveal from './HexReveal.svelte'; 3 + 4 + interface TablePhoto { 5 + src: string; 6 + title: string; 7 + caption?: string; 8 + isHex?: boolean; // Barcelona tiles special treatment 9 + } 10 + 11 + interface Props { 12 + photos: TablePhoto[]; 13 + heroCount?: number; // how many of the leading photos are also heroes 14 + } 15 + 16 + let { photos, heroCount = 0 }: Props = $props(); 17 + 18 + let expandedIndex = $state<number | null>(null); 19 + 20 + function open(i: number) { 21 + if ('startViewTransition' in document) { 22 + (document as any).startViewTransition(() => { 23 + expandedIndex = i; 24 + }); 25 + } else { 26 + expandedIndex = i; 27 + } 28 + } 29 + 30 + function close() { 31 + if ('startViewTransition' in document) { 32 + (document as any).startViewTransition(() => { 33 + expandedIndex = null; 34 + }); 35 + } else { 36 + expandedIndex = null; 37 + } 38 + } 39 + 40 + function onKeyDown(e: KeyboardEvent) { 41 + if (e.key === 'Escape') close(); 42 + if (expandedIndex !== null) { 43 + if (e.key === 'ArrowRight') open((expandedIndex + 1) % photos.length); 44 + if (e.key === 'ArrowLeft') open((expandedIndex - 1 + photos.length) % photos.length); 45 + } 46 + } 47 + </script> 48 + 49 + <svelte:window onkeydown={onKeyDown} /> 50 + 51 + <section class="light-table" aria-label="Photo collection"> 52 + <div class="grid"> 53 + {#each photos as photo, i} 54 + {@const style = `view-transition-name: photo-${i}`} 55 + <button 56 + class="cell" 57 + class:is-hex={photo.isHex} 58 + onclick={() => open(i)} 59 + aria-label="View {photo.title}" 60 + {style} 61 + > 62 + {#if photo.isHex} 63 + <HexReveal src={photo.src} alt={photo.title} /> 64 + {:else} 65 + <img 66 + class="thumb" 67 + src={photo.src} 68 + alt={photo.title} 69 + loading="lazy" 70 + /> 71 + {/if} 72 + <div class="cell-overlay"> 73 + <span class="cell-title">{photo.title}</span> 74 + </div> 75 + </button> 76 + {/each} 77 + </div> 78 + </section> 79 + 80 + <!-- Lightbox --> 81 + {#if expandedIndex !== null} 82 + {@const photo = photos[expandedIndex]} 83 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 84 + <div class="lightbox" role="presentation" onclick={close}> 85 + <div class="lightbox-inner" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={photo.title} tabindex="-1" style="view-transition-name: photo-{expandedIndex}"> 86 + <img class="lightbox-img" src={photo.src} alt={photo.title} /> 87 + <div class="lightbox-info"> 88 + <h2>{photo.title}</h2> 89 + {#if photo.caption} 90 + <p>{photo.caption}</p> 91 + {/if} 92 + </div> 93 + <button class="lightbox-close" onclick={close} aria-label="Close">×</button> 94 + <button class="lightbox-prev" onclick={() => open((expandedIndex! - 1 + photos.length) % photos.length)} aria-label="Previous">‹</button> 95 + <button class="lightbox-next" onclick={() => open((expandedIndex! + 1) % photos.length)} aria-label="Next">›</button> 96 + </div> 97 + </div> 98 + {/if} 99 + 100 + <style> 101 + .light-table { 102 + background: #080808; 103 + padding: 4rem 2rem; 104 + min-height: 100vh; 105 + } 106 + 107 + .grid { 108 + max-width: 1400px; 109 + margin: 0 auto; 110 + display: grid; 111 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 112 + gap: 4px; 113 + } 114 + 115 + .cell { 116 + position: relative; 117 + aspect-ratio: 3 / 2; 118 + overflow: hidden; 119 + background: #111; 120 + border: none; 121 + cursor: pointer; 122 + padding: 0; 123 + display: block; 124 + } 125 + 126 + .cell.is-hex { 127 + aspect-ratio: 16 / 10; 128 + } 129 + 130 + .thumb { 131 + display: block; 132 + width: 100%; 133 + height: 100%; 134 + object-fit: cover; 135 + transition: transform 0.5s ease, filter 0.5s ease; 136 + filter: brightness(0.85); 137 + } 138 + 139 + .cell:hover .thumb { 140 + transform: scale(1.04); 141 + filter: brightness(1.05); 142 + } 143 + 144 + .cell-overlay { 145 + position: absolute; 146 + inset: 0; 147 + display: flex; 148 + align-items: flex-end; 149 + padding: 0.75rem; 150 + background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 50%); 151 + opacity: 0; 152 + transition: opacity 0.3s ease; 153 + } 154 + 155 + .cell:hover .cell-overlay { 156 + opacity: 1; 157 + } 158 + 159 + .cell-title { 160 + color: #fff; 161 + font-size: 0.8rem; 162 + font-weight: 300; 163 + letter-spacing: 0.06em; 164 + text-transform: uppercase; 165 + } 166 + 167 + /* Lightbox */ 168 + .lightbox { 169 + position: fixed; 170 + inset: 0; 171 + z-index: 100; 172 + background: rgba(0, 0, 0, 0.92); 173 + display: flex; 174 + align-items: center; 175 + justify-content: center; 176 + backdrop-filter: blur(12px); 177 + animation: lb-in 0.25s ease both; 178 + } 179 + 180 + @keyframes lb-in { 181 + from { opacity: 0; } 182 + to { opacity: 1; } 183 + } 184 + 185 + .lightbox-inner { 186 + position: relative; 187 + max-width: min(90vw, 1200px); 188 + max-height: 90svh; 189 + display: flex; 190 + flex-direction: column; 191 + } 192 + 193 + .lightbox-img { 194 + display: block; 195 + max-width: 100%; 196 + max-height: 80svh; 197 + object-fit: contain; 198 + border-radius: 2px; 199 + } 200 + 201 + .lightbox-info { 202 + padding: 1rem 0 0; 203 + color: #e8e8e8; 204 + } 205 + 206 + .lightbox-info h2 { 207 + font-size: 1.1rem; 208 + font-weight: 300; 209 + letter-spacing: 0.05em; 210 + } 211 + 212 + .lightbox-info p { 213 + font-size: 0.85rem; 214 + opacity: 0.6; 215 + margin-top: 0.25rem; 216 + } 217 + 218 + .lightbox-close { 219 + position: absolute; 220 + top: -2.5rem; 221 + right: 0; 222 + background: none; 223 + border: none; 224 + color: rgba(255,255,255,0.6); 225 + font-size: 2rem; 226 + cursor: pointer; 227 + line-height: 1; 228 + transition: color 0.2s; 229 + } 230 + 231 + .lightbox-close:hover { color: #fff; } 232 + 233 + .lightbox-prev, 234 + .lightbox-next { 235 + position: fixed; 236 + top: 50%; 237 + transform: translateY(-50%); 238 + background: none; 239 + border: none; 240 + color: rgba(255,255,255,0.5); 241 + font-size: 3rem; 242 + cursor: pointer; 243 + padding: 1rem; 244 + transition: color 0.2s; 245 + line-height: 1; 246 + } 247 + 248 + .lightbox-prev:hover, 249 + .lightbox-next:hover { color: #fff; } 250 + 251 + .lightbox-prev { left: 0.5rem; } 252 + .lightbox-next { right: 0.5rem; } 253 + 254 + /* View Transitions */ 255 + @media (prefers-reduced-motion: no-preference) { 256 + ::view-transition-old(*), 257 + ::view-transition-new(*) { 258 + animation-duration: 0.35s; 259 + } 260 + } 261 + </style>
+88 -310
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import DepthPhoto from '$lib/components/DepthPhoto.svelte'; 3 - import DepthSlice from '$lib/components/DepthSlice.svelte'; 4 - import { onMount } from 'svelte'; 2 + import HeroShowcase from '$lib/components/HeroShowcase.svelte'; 3 + import LightTable from '$lib/components/LightTable.svelte'; 5 4 6 - const photos = [ 5 + // Hero photos — fullscreen with depth parallax + crossfade transitions 6 + // depthSrc uses generated AVIF depth maps; falls back to sample if not yet generated 7 + const heroPhotos = [ 7 8 { 8 - slug: 'mountain-lake', 9 - title: 'Mountain Lake', 10 - caption: 'Alpine reflections', 11 - src: '/samples/sample.jpg', 12 - depthSrc: '/samples/sample-depth.jpg', 9 + src: '/samples/describe/20230708-A7_00042-Edit.jpg', 10 + depthSrc: '/samples/describe/20230708-A7_00042-Edit-l-depth.avif', 11 + title: 'Lobo de espejo', 12 + caption: 'A soaking wet wolf trots along a misty beach', 13 + saturation: 0, // B&W photo 13 14 }, 14 15 { 15 - slug: 'mountain-lake-2', 16 - title: 'Golden Hour', 17 - caption: 'Last light on the peaks', 18 - src: '/samples/sample.jpg', 19 - depthSrc: '/samples/sample-depth.jpg', 16 + src: '/samples/describe/20240625-A7_00222-HDR.jpg', 17 + depthSrc: '/samples/describe/20240625-A7_00222-HDR-l-depth.avif', 18 + title: 'Mossy Sentinel', 19 + caption: 'A massive moss-covered log in a temperate rainforest', 20 20 }, 21 21 { 22 - slug: 'mountain-lake-3', 23 - title: 'Still Water', 24 - caption: 'Dawn at the lake', 25 - src: '/samples/sample.jpg', 26 - depthSrc: '/samples/sample-depth.jpg', 22 + src: '/samples/describe/20230708-A7_00067.jpg', 23 + depthSrc: '/samples/describe/20230708-A7_00067-l-depth.avif', 24 + title: 'Lake in the Clouds', 25 + caption: 'Snow-capped peaks wrapped in low cloud above a still Patagonian lake', 26 + }, 27 + { 28 + src: '/samples/describe/20240627-A7_00077-HDR.jpg', 29 + depthSrc: '/samples/describe/20240627-A7_00077-HDR-l-depth.avif', 30 + title: 'Mountain Railway', 31 + caption: 'A narrow-gauge train curves through a dramatic alpine pass', 32 + }, 33 + { 34 + src: '/samples/describe/20231008-A7_00156-HDR.jpg', 35 + depthSrc: '/samples/describe/20231008-A7_00156-HDR-l-depth.avif', 36 + title: 'City Bicycle', 37 + caption: 'A classic dark-blue city bike on a tree-lined sidewalk', 38 + }, 39 + { 40 + src: '/samples/describe/20231021-IMG_4709 (1).jpg', 41 + depthSrc: '/samples/describe/20231021-IMG_4709 (1)-l-depth.avif', 42 + title: 'Coastal Palm', 43 + caption: 'A spiky fan palm above golden limestone cliffs and turquoise sea', 44 + }, 45 + { 46 + src: '/samples/describe/20240508-IMG_1615-Pano-2.jpg', 47 + depthSrc: '/samples/describe/20240508-IMG_1615-Pano-2-l-depth.avif', 48 + title: 'Golden Hour Through the Trees', 49 + caption: 'Warm evening light floods through an arched window of a European palace', 27 50 }, 28 51 ]; 29 52 30 - let currentIndex = $state(0); 31 - let mode = $state<'parallax' | 'focus'>('parallax'); 32 - let uiVisible = $state(true); 33 - let stripOpen = $state(false); 34 - let galleryEl: HTMLDivElement; 35 - let slideEls: HTMLDivElement[] = []; 53 + // Light table — all hero photos + extra picks 54 + const tablePhotos = [ 55 + // Heroes first 56 + ...heroPhotos.map(p => ({ src: p.src, title: p.title, caption: p.caption })), 57 + // Additional picks 58 + { 59 + src: '/samples/describe/20230706-DJI_0145-HDR-Pano-Edit.jpg', 60 + title: 'Patagonian Dusk', 61 + caption: 'An aerial panorama over a glacial lake surrounded by snow-dusted Andean peaks', 62 + }, 63 + { 64 + src: '/samples/describe/20240628-A7_00040.jpg', 65 + title: 'Glacial Inlet', 66 + caption: 'A vast mirror-calm Alaskan inlet reflects snow-capped mountains', 67 + }, 68 + { 69 + src: '/samples/describe/20250323-IMG_4925.jpg', 70 + title: 'Barcelona Tiles', 71 + caption: 'Wet hexagonal pavement tiles embossed with sea creatures glisten after rain', 72 + isHex: true, 73 + }, 74 + { 75 + src: '/samples/describe/20230713-DJI_0412.jpg', 76 + title: 'Rock and Foam', 77 + caption: 'Jagged coastal boulders with turquoise water and white foam', 78 + }, 79 + { 80 + src: '/samples/describe/20240306-IMG_8457-Pano.jpg', 81 + title: 'Gothic Quarter Alley', 82 + caption: 'A narrow Barcelona lane flanked by ornate stone facades', 83 + }, 84 + ]; 36 85 37 - let photo = $derived(photos[currentIndex]); 38 - 39 - function scrollTo(index: number) { 40 - slideEls[index]?.scrollIntoView({ behavior: 'smooth' }); 41 - } 42 - 43 - function navigate(dir: -1 | 1) { 44 - const next = currentIndex + dir; 45 - if (next >= 0 && next < photos.length) scrollTo(next); 46 - } 47 - 48 - function toggleMode() { 49 - mode = mode === 'parallax' ? 'focus' : 'parallax'; 50 - } 51 - 52 - onMount(() => { 53 - let fadeTimer: ReturnType<typeof setTimeout>; 54 - 55 - function resetFade() { 56 - uiVisible = false; 57 - clearTimeout(fadeTimer); 58 - fadeTimer = setTimeout(() => { 59 - uiVisible = true; 60 - }, 2500); 61 - } 62 - 63 - function onMouseMove() { 64 - resetFade(); 65 - } 66 - 67 - function onKeyDown(e: KeyboardEvent) { 68 - if (e.key === 'ArrowDown' || e.key === 'j') { 69 - e.preventDefault(); 70 - navigate(1); 71 - } else if (e.key === 'ArrowUp' || e.key === 'k') { 72 - e.preventDefault(); 73 - navigate(-1); 74 - } else if (e.key === ' ') { 75 - e.preventDefault(); 76 - toggleMode(); 77 - } 78 - } 79 - 80 - window.addEventListener('mousemove', onMouseMove); 81 - window.addEventListener('keydown', onKeyDown); 82 - 83 - const observer = new IntersectionObserver( 84 - (entries) => { 85 - for (const entry of entries) { 86 - if (entry.isIntersecting) { 87 - const idx = slideEls.indexOf(entry.target as HTMLDivElement); 88 - if (idx !== -1) currentIndex = idx; 89 - } 90 - } 91 - }, 92 - { root: galleryEl, threshold: 0.5 } 93 - ); 94 - 95 - for (const el of slideEls) { 96 - if (el) observer.observe(el); 97 - } 98 - 99 - return () => { 100 - clearTimeout(fadeTimer); 101 - window.removeEventListener('mousemove', onMouseMove); 102 - window.removeEventListener('keydown', onKeyDown); 103 - observer.disconnect(); 104 - }; 105 - }); 86 + let currentHeroIndex = $state(0); 106 87 </script> 107 88 108 89 <svelte:head> 109 90 <title>morgan.photos</title> 110 91 </svelte:head> 111 92 112 - <div class="gallery" bind:this={galleryEl}> 113 - {#each photos as p, i} 114 - <div class="slide" bind:this={slideEls[i]}> 115 - {#if mode === 'focus' && i === currentIndex} 116 - {#key `focus-${i}`} 117 - <DepthSlice src={p.src} depthSrc={p.depthSrc} alt={p.title} /> 118 - {/key} 119 - {:else} 120 - {#key `parallax-${i}`} 121 - <DepthPhoto 122 - src={p.src} 123 - depthSrc={p.depthSrc} 124 - alt={p.title} 125 - intensity={0.02} 126 - /> 127 - {/key} 128 - {/if} 129 - </div> 130 - {/each} 131 - </div> 132 - 133 - <div class="overlay" style:opacity={uiVisible ? 1 : 0}> 134 - <span class="name">morgan.photos</span> 135 - 136 - <div class="info"> 137 - <h2>{photo.title}</h2> 138 - {#if photo.caption} 139 - <p>{photo.caption}</p> 140 - {/if} 141 - </div> 93 + <div class="site-name">morgan.photos</div> 142 94 143 - <div class="controls"> 144 - <span class="counter">{currentIndex + 1} / {photos.length}</span> 145 - <div class="mode-toggle"> 146 - <button 147 - class="mode-btn" 148 - class:active={mode === 'parallax'} 149 - onclick={() => { mode = 'parallax'; }} 150 - > 151 - Parallax 152 - </button> 153 - <button 154 - class="mode-btn" 155 - class:active={mode === 'focus'} 156 - onclick={() => { mode = 'focus'; }} 157 - > 158 - Focus 159 - </button> 160 - </div> 161 - <button class="grid-toggle" onclick={() => { stripOpen = !stripOpen; }}> 162 - 163 - </button> 164 - </div> 165 - </div> 95 + <HeroShowcase 96 + photos={heroPhotos} 97 + onindexchange={(i) => { currentHeroIndex = i; }} 98 + /> 166 99 167 - <div class="thumb-strip" class:open={stripOpen}> 168 - {#each photos as p, i} 169 - <button class="thumb-btn" onclick={() => { scrollTo(i); stripOpen = false; }}> 170 - <img 171 - class="thumb" 172 - class:active={i === currentIndex} 173 - src={p.src} 174 - alt={p.title} 175 - /> 176 - </button> 177 - {/each} 178 - </div> 100 + <LightTable photos={tablePhotos} /> 179 101 180 102 <style> 181 - .gallery { 182 - height: 100vh; 183 - overflow-y: scroll; 184 - scroll-snap-type: y mandatory; 185 - scroll-behavior: smooth; 186 - } 187 - 188 - .slide { 189 - height: 100vh; 190 - width: 100vw; 191 - scroll-snap-align: start; 192 - position: relative; 193 - overflow: hidden; 194 - background: #000; 195 - } 196 - 197 - .overlay { 103 + .site-name { 198 104 position: fixed; 199 - inset: 0; 200 - z-index: 10; 201 - pointer-events: none; 202 - transition: opacity 0.6s ease; 203 - } 204 - 205 - .overlay > :global(*) { 206 - pointer-events: auto; 207 - } 208 - 209 - .name { 210 - position: absolute; 211 105 top: 1.5rem; 212 106 left: 1.5rem; 107 + z-index: 50; 108 + color: rgba(255, 255, 255, 0.7); 213 109 font-size: 0.8rem; 214 110 font-weight: 300; 215 - letter-spacing: 0.15em; 216 - color: var(--color-text-muted); 217 - } 218 - 219 - .info { 220 - position: absolute; 221 - bottom: 2rem; 222 - left: 2rem; 223 - } 224 - 225 - .info h2 { 226 - font-size: 1rem; 227 - font-weight: 400; 228 - letter-spacing: 0.05em; 229 - } 230 - 231 - .info p { 232 - font-size: 0.8rem; 233 - color: var(--color-text-muted); 234 - margin-top: 0.25rem; 235 - } 236 - 237 - .controls { 238 - position: absolute; 239 - bottom: 2rem; 240 - right: 2rem; 241 - display: flex; 242 - align-items: center; 243 - gap: 1rem; 244 - } 245 - 246 - .counter { 247 - font-size: 0.75rem; 248 - color: var(--color-text-dim); 249 - font-variant-numeric: tabular-nums; 250 - } 251 - 252 - .mode-toggle { 253 - display: flex; 254 - gap: 2px; 255 - background: rgba(255, 255, 255, 0.06); 256 - border-radius: 6px; 257 - padding: 2px; 258 - } 259 - 260 - .mode-btn { 261 - all: unset; 262 - font-size: 0.65rem; 263 - padding: 0.2rem 0.5rem; 264 - border-radius: 4px; 265 - color: var(--color-text-dim); 266 - cursor: pointer; 267 - transition: all 0.2s; 268 - text-transform: uppercase; 269 - letter-spacing: 0.08em; 270 - } 271 - 272 - .mode-btn:hover { 273 - color: var(--color-text-muted); 274 - } 275 - 276 - .mode-btn.active { 277 - background: rgba(255, 255, 255, 0.1); 278 - color: var(--color-text); 279 - } 280 - 281 - .grid-toggle { 282 - all: unset; 283 - cursor: pointer; 284 - color: var(--color-text-dim); 285 - font-size: 0.9rem; 286 - padding: 0.25rem; 287 - transition: color 0.2s; 288 - } 289 - 290 - .grid-toggle:hover { 291 - color: var(--color-text); 292 - } 293 - 294 - .thumb-strip { 295 - position: fixed; 296 - bottom: 0; 297 - left: 0; 298 - right: 0; 299 - z-index: 20; 300 - background: rgba(0, 0, 0, 0.85); 301 - backdrop-filter: blur(10px); 302 - padding: 0.5rem 1rem; 303 - display: flex; 304 - gap: 0.5rem; 305 - justify-content: center; 306 - transform: translateY(100%); 307 - transition: transform 0.3s ease; 308 - } 309 - 310 - .thumb-strip.open { 311 - transform: translateY(0); 312 - } 313 - 314 - .thumb-btn { 315 - all: unset; 316 - cursor: pointer; 317 - } 318 - 319 - .thumb { 320 - width: 48px; 321 - height: 48px; 322 - border-radius: 4px; 323 - object-fit: cover; 324 - opacity: 0.5; 325 - transition: opacity 0.2s; 326 - border: 1px solid transparent; 327 - display: block; 328 - } 329 - 330 - .thumb:hover { 331 - opacity: 0.8; 332 - } 333 - 334 - .thumb.active { 335 - opacity: 1; 336 - border-color: rgba(255, 255, 255, 0.3); 111 + letter-spacing: 0.12em; 112 + text-transform: lowercase; 113 + pointer-events: none; 114 + mix-blend-mode: overlay; 337 115 } 338 116 </style>
static/samples/describe/20220618-A7_08505-2.jpg

This is a binary file and will not be displayed.

static/samples/describe/20220618-A7_08505.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230706-DJI_0145-HDR-Pano-Edit.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230708-A7_00042-Edit-l-depth.avif

This is a binary file and will not be displayed.

static/samples/describe/20230708-A7_00042-Edit.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230708-A7_00044.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230708-A7_00067-l-depth.avif

This is a binary file and will not be displayed.

static/samples/describe/20230708-A7_00067.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230708-A7_00211-HDR.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230708-A7_00214.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230712-DJI_0181-Panorama HDR.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230713-DJI_0392-Enhanced-NR.jpg

This is a binary file and will not be displayed.

static/samples/describe/20230713-DJI_0412.jpg

This is a binary file and will not be displayed.

static/samples/describe/20231008-A7_00156-HDR-l-depth.avif

This is a binary file and will not be displayed.

static/samples/describe/20231008-A7_00156-HDR.jpg

This is a binary file and will not be displayed.

static/samples/describe/20231021-IMG_4709 (1)-l-depth.avif

This is a binary file and will not be displayed.

static/samples/describe/20231021-IMG_4709 (1).jpg

This is a binary file and will not be displayed.

static/samples/describe/20240128-IMG_6962-Pano.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240306-IMG_8457-Pano.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240503-IMG_1165.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240508-IMG_1615-Pano-2-l-depth.avif

This is a binary file and will not be displayed.

static/samples/describe/20240508-IMG_1615-Pano-2.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240508-IMG_1615-Pano.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240508-IMG_1690-Pano.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240520-IMG_2844.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240625-A7_00222-HDR-l-depth.avif

This is a binary file and will not be displayed.

static/samples/describe/20240625-A7_00222-HDR.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240625-A7_00296-HDR.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240626-IMG_6566.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240627-A7_00077-HDR-l-depth.avif

This is a binary file and will not be displayed.

static/samples/describe/20240627-A7_00077-HDR.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240628-A7_00040.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240713-IMG_0543.jpg

This is a binary file and will not be displayed.

static/samples/describe/20240926-A7_00573.jpg

This is a binary file and will not be displayed.

static/samples/describe/20250323-IMG_4925.jpg

This is a binary file and will not be displayed.

static/samples/describe/jxls.zip

This is a binary file and will not be displayed.

+114
static/samples/describe/metadata.jsonc
··· 1 + { 2 + "20220618-A7_08505-2.jpg": { 3 + "title": "Embrace at the Miradouro", 4 + "description": "A couple embraces at a Lisbon viewpoint, the city's terracotta rooftops and the Tagus River stretching behind them, Cristo Rei visible on the far shore under a stormy sky.", 5 + }, 6 + "20220618-A7_08505.jpg": { 7 + "title": "Lisbon Overlook", 8 + "description": "Two figures hold each other at an ornate iron railing above Lisbon's old city, the 25 de Abril Bridge spanning the wide Tagus in the distance beneath dramatic clouds.", 9 + }, 10 + "20230706-DJI_0145-HDR-Pano-Edit.jpg": { 11 + "title": "Patagonian Dusk", 12 + "description": "An aerial panorama over a glacial lake surrounded by snow-dusted Andean peaks, golden storm light breaking through heavy clouds and scattering across the dark water.", 13 + }, 14 + "20230708-A7_00042-Edit.jpg": { 15 + "title": "Lobo de espejo", 16 + "description": "A soaking wet wolf trots along a misty beach in black and white, water droplets flying from its coat, the grey treeline fading into fog behind it.", 17 + }, 18 + "20230708-A7_00044.jpg": { 19 + "title": "Bear Crossing", 20 + "description": "A black bear swims through a glassy, mist-covered lake at dawn, its head just above the surface, dense boreal forest dissolving into the morning haze beyond.", 21 + }, 22 + "20230708-A7_00067.jpg": { 23 + "title": "Lake in the Clouds", 24 + "description": "A tall, gnarled tree stands on a forested peninsula jutting into a still Patagonian lake, snow-capped peaks wrapped in low cloud rising steeply from the far shore.", 25 + }, 26 + "20230708-A7_00211-HDR.jpg": { 27 + "title": "First Snow", 28 + "description": "A ground-level view along a snow-dusted road center line, large flakes falling through the frame, dark trees blurring into the grey background of an early snowstorm.", 29 + }, 30 + "20230708-A7_00214.jpg": { 31 + "title": "Snowfall Curve", 32 + "description": "A snow-covered road bends through a blizzard, a 60 km/h sign and curve warning visible through the curtain of falling snow at the edge of a dark forest.", 33 + }, 34 + "20230712-DJI_0181-Panorama HDR.jpg": { 35 + "title": "Rio Coastline from Above", 36 + "description": "An aerial view of Arpoador's rocky headland separating Ipanema from the open Atlantic, the city's beach boulevard curving toward misty mountains in the soft morning light.", 37 + }, 38 + "20230713-DJI_0392-Enhanced-NR.jpg": { 39 + "title": "Wave Break", 40 + "description": "Looking straight down at coastal rocks, white surf churns through dark crevices while a lone walkway traces the cliff edge, the surrounding ocean a deep jewel green.", 41 + }, 42 + "20230713-DJI_0412.jpg": { 43 + "title": "Rock and Foam", 44 + "description": "A tight aerial view of jagged coastal boulders with turquoise water and white foam wedged between the formations, the geometry of the rocks filling the entire frame.", 45 + }, 46 + "20231008-A7_00156-HDR.jpg": { 47 + "title": "City Bicycle", 48 + "description": "A classic dark-blue city bike with a wire basket leans against a post on a tree-lined sidewalk, autumn light casting long shadows across the warm stone pavement.", 49 + }, 50 + "20231021-IMG_4709 (1).jpg": { 51 + "title": "Coastal Palm", 52 + "description": "A spiky fan palm dominates the foreground while behind it golden limestone cliffs drop into a calm, vivid turquoise sea under a pale blue sky.", 53 + }, 54 + "20240128-IMG_6962-Pano.jpg": { 55 + "title": "City Under Storm", 56 + "description": "Dense high-rise towers fill a mountain valley as dramatic storm clouds roll in, shafts of amber light breaking through the overcast and illuminating the urban sprawl below.", 57 + }, 58 + "20240306-IMG_8457-Pano.jpg": { 59 + "title": "Gothic Quarter Alley", 60 + "description": "A narrow Barcelona lane flanked by ornate stone facades draped with flowering balcony plants, dappled sunlight reaching the cobblestones where a few pedestrians linger.", 61 + }, 62 + "20240503-IMG_1165.jpg": { 63 + "title": "Hanging Gardens", 64 + "description": "Looking up a sun-warmed ochre courtyard wall, black steel planter boxes overflow with succulents and tropical plants, framed by a vivid strip of blue sky above.", 65 + }, 66 + "20240508-IMG_1615-Pano-2.jpg": { 67 + "title": "Golden Hour Through the Trees", 68 + "description": "Warm evening light floods through an arched window of an ornate European palace, silhouetting a lone figure on the balustrade while misty backlight halos the surrounding foliage.", 69 + }, 70 + "20240508-IMG_1615-Pano.jpg": { 71 + "title": "Palace in Golden Light", 72 + "description": "A gilded eagle crowns an ornate neoclassical building glimpsed through leafy branches, warm light streaming through the archways in shafts that dissolve into evening haze.", 73 + }, 74 + "20240508-IMG_1690-Pano.jpg": { 75 + "title": "Barcelona at Dusk", 76 + "description": "A motorcyclist rides toward the camera on a broad city boulevard at golden hour, pedestrians and traffic filling the frame, a hilltop monument glowing on the horizon.", 77 + }, 78 + "20240520-IMG_2844.jpg": { 79 + "title": "Barceloneta Afternoon", 80 + "description": "A crowded urban beach buzzes with sunbathers and swimmers, the marina and port facilities visible across a breakwater, towering cumulus clouds building over distant mountains.", 81 + }, 82 + "20240625-A7_00222-HDR.jpg": { 83 + "title": "Mossy Sentinel", 84 + "description": "A massive moss-covered log leans dramatically in a temperate rainforest, shafts of filtered light cutting through the tall canopy and illuminating the vibrant green understory.", 85 + }, 86 + "20240625-A7_00296-HDR.jpg": { 87 + "title": "Forest Grotto", 88 + "description": "A single broad leaf catches the only light in a dark, moss-draped rock crevice, the surrounding stone and ferns rendered in deep greens and shadows.", 89 + }, 90 + "20240626-IMG_6566.jpg": { 91 + "title": "Golden Hour Village", 92 + "description": "A small coastal settlement glows with warm reflected light across a glassy channel, forested mountains rising steeply behind the cluster of white houses in the late-evening haze.", 93 + }, 94 + "20240627-A7_00077-HDR.jpg": { 95 + "title": "Mountain Railway", 96 + "description": "Looking back along the carriages of a narrow-gauge train as it curves through a dramatic alpine pass, snow-streaked peaks and dense evergreen forest framing the journey.", 97 + }, 98 + "20240628-A7_00040.jpg": { 99 + "title": "Glacial Inlet", 100 + "description": "A vast, mirror-calm Alaskan inlet reflects snow-capped mountains and cloud shadows, boat wake ripples spreading across the still surface in long silver lines.", 101 + }, 102 + "20240713-IMG_0543.jpg": { 103 + "title": "Steel and Glass Towers", 104 + "description": "Looking straight up between modern skyscrapers, their glass facades and exposed steel frames converge toward a blue sky streaked with thin clouds.", 105 + }, 106 + "20240926-A7_00573.jpg": { 107 + "title": "Skechers on the Steps", 108 + "description": "A child's brightly colored sneakers mid-stride on rough concrete steps, warm side light casting sharp shadows and highlighting the vivid orange and green soles.", 109 + }, 110 + "20250323-IMG_4925.jpg": { 111 + "title": "Barcelona Tiles", 112 + "description": "Wet hexagonal pavement tiles embossed with sea creatures glisten after rain, a single fallen orange leaf providing a vivid focal point against the intricate grey stone pattern.", 113 + }, 114 + }
static/samples/jxls/20220618-A7_08505-2.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20220618-A7_08505.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230706-DJI_0145-HDR-Pano-Edit.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230708-A7_00042-Edit.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230708-A7_00044.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230708-A7_00067.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230708-A7_00211-HDR.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230708-A7_00214.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230712-DJI_0181-Panorama HDR.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230713-DJI_0392-Enhanced-NR.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20230713-DJI_0412.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20231008-A7_00156-HDR.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20231021-IMG_4709 (1).jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240128-IMG_6962-Pano.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240306-IMG_8457-Pano.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240503-IMG_1165.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240508-IMG_1615-Pano-2.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240508-IMG_1615-Pano.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240508-IMG_1690-Pano.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240520-IMG_2844.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240625-A7_00222-HDR.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240625-A7_00296-HDR.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240626-IMG_6566.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240627-A7_00077-HDR.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240628-A7_00040.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240713-IMG_0543.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20240926-A7_00573.jxl

This is a binary file and will not be displayed.

static/samples/jxls/20250323-IMG_4925.jxl

This is a binary file and will not be displayed.

static/samples/metadata.jsonc

This is a binary file and will not be displayed.

+8
vite.config.ts
··· 10 10 preview: { 11 11 port: 3470, 12 12 }, 13 + optimizeDeps: { 14 + esbuildOptions: { 15 + target: 'esnext', 16 + }, 17 + }, 18 + build: { 19 + target: 'esnext', 20 + }, 13 21 });