diff --git a/docs/plans/2025-12-27-view-transitions-and-cache.md b/docs/plans/2025-12-27-view-transitions-and-cache.md new file mode 100644 index 0000000..5219cca --- /dev/null +++ b/docs/plans/2025-12-27-view-transitions-and-cache.md @@ -0,0 +1,745 @@ +# View Transitions & Record Cache Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add smooth view transitions between profile grid and gallery detail, powered by a URI-keyed record cache for instant rendering. + +**Architecture:** A reactive record cache stores gallery/profile data keyed by AT URI. A query cache stores ordered lists of URIs for timeline/profile views. The router wraps navigation in the View Transitions API, and components render instantly from cache before fetching missing data. + +**Tech Stack:** Lit 3.x, View Transitions API, vanilla JS Map-based caching. + +--- + +## Phase 1: Record Cache Service + +### Task 1: Create Record Cache Service + +**Files:** +- Create: `src/services/record-cache.js` + +**Step 1: Create the record cache module** + +```javascript +// src/services/record-cache.js + +const cache = new Map(); +const listeners = new Map(); + +export const recordCache = { + /** + * Get a cached record by URI + * @param {string} uri - AT Protocol URI + * @returns {object|undefined} Cached record or undefined + */ + get(uri) { + return cache.get(uri); + }, + + /** + * Set/merge record data. Dispatches update event. + * @param {string} uri - AT Protocol URI + * @param {object} data - Partial or full record data + */ + set(uri, data) { + const existing = cache.get(uri) || {}; + const merged = { ...existing, ...data }; + cache.set(uri, merged); + this.#notify(uri, merged); + }, + + /** + * Check if a URI is cached + * @param {string} uri - AT Protocol URI + * @returns {boolean} + */ + has(uri) { + return cache.has(uri); + }, + + /** + * Subscribe to changes for a specific URI + * @param {string} uri - AT Protocol URI + * @param {function} callback - Called with updated data + */ + subscribe(uri, callback) { + if (!listeners.has(uri)) { + listeners.set(uri, new Set()); + } + listeners.get(uri).add(callback); + }, + + /** + * Unsubscribe from changes + * @param {string} uri - AT Protocol URI + * @param {function} callback - The callback to remove + */ + unsubscribe(uri, callback) { + const uriListeners = listeners.get(uri); + if (uriListeners) { + uriListeners.delete(callback); + if (uriListeners.size === 0) { + listeners.delete(uri); + } + } + }, + + /** + * Notify all subscribers of a URI change + * @private + */ + #notify(uri, data) { + const uriListeners = listeners.get(uri); + if (uriListeners) { + for (const callback of uriListeners) { + callback(data); + } + } + }, + + /** + * Clear all cached data (useful for logout) + */ + clear() { + cache.clear(); + } +}; +``` + +**Step 2: Verify module loads** + +Run: `npm run dev` +Open browser console, run: `import('/src/services/record-cache.js').then(m => console.log(m.recordCache))` +Expected: Object with get, set, has, subscribe, unsubscribe, clear methods + +**Step 3: Commit** + +```bash +git add src/services/record-cache.js +git commit -m "feat: add record cache service with reactive subscriptions" +``` + +--- + +### Task 2: Create Query Cache Service + +**Files:** +- Create: `src/services/query-cache.js` + +**Step 1: Create the query cache module** + +```javascript +// src/services/query-cache.js + +const queries = new Map(); + +export const queryCache = { + /** + * Get cached query result + * @param {string} queryId - Query identifier (e.g., "timeline", "profile:handle") + * @returns {{ uris: string[], cursor: string|null, hasMore: boolean }|undefined} + */ + get(queryId) { + return queries.get(queryId); + }, + + /** + * Set query result (replaces existing) + * @param {string} queryId - Query identifier + * @param {{ uris: string[], cursor: string|null, hasMore: boolean }} result + */ + set(queryId, result) { + queries.set(queryId, { + uris: result.uris || [], + cursor: result.cursor || null, + hasMore: result.hasMore ?? true + }); + }, + + /** + * Append to existing query result (for pagination) + * @param {string} queryId - Query identifier + * @param {{ uris: string[], cursor: string|null, hasMore: boolean }} result + */ + append(queryId, result) { + const existing = queries.get(queryId); + if (existing) { + queries.set(queryId, { + uris: [...existing.uris, ...(result.uris || [])], + cursor: result.cursor || null, + hasMore: result.hasMore ?? true + }); + } else { + this.set(queryId, result); + } + }, + + /** + * Check if query is cached + * @param {string} queryId - Query identifier + * @returns {boolean} + */ + has(queryId) { + return queries.has(queryId); + }, + + /** + * Clear all query cache (useful for logout or refresh) + */ + clear() { + queries.clear(); + } +}; +``` + +**Step 2: Verify module loads** + +Run: `npm run dev` +Open browser console, run: `import('/src/services/query-cache.js').then(m => console.log(m.queryCache))` +Expected: Object with get, set, append, has, clear methods + +**Step 3: Commit** + +```bash +git add src/services/query-cache.js +git commit -m "feat: add query cache service for timeline/list pagination" +``` + +--- + +## Phase 2: Integrate Cache with API + +### Task 3: Update Timeline Query to Populate Cache + +**Files:** +- Modify: `src/services/grain-api.js` + +**Step 1: Read current grain-api.js to understand structure** + +Identify the timeline/feed method and its response shape. + +**Step 2: Import caches and update timeline method** + +Add at top of file: +```javascript +import { recordCache } from './record-cache.js'; +import { queryCache } from './query-cache.js'; +``` + +In the timeline/feed method, after receiving data, add cache population: + +```javascript +// After mapping gallery data, cache each gallery +galleries.forEach(gallery => { + recordCache.set(gallery.uri, gallery); +}); + +// Cache the query result +queryCache.set('timeline', { + uris: galleries.map(g => g.uri), + cursor: data.pageInfo?.endCursor || null, + hasMore: data.pageInfo?.hasNextPage ?? false +}); +``` + +Note: Exact implementation depends on current method structure. The key is: +1. Each gallery record gets cached by its URI +2. The timeline query gets cached with the list of URIs + +**Step 3: Verify cache population** + +Run: `npm run dev` +Load the timeline in browser. +Open console, run: +```javascript +import('/src/services/record-cache.js').then(m => { + console.log('Cached records:', [...m.recordCache.cache?.entries?.() || []].length || 'check internal'); +}); +``` +Expected: Records are cached after timeline loads + +**Step 4: Commit** + +```bash +git add src/services/grain-api.js +git commit -m "feat: populate record cache from timeline query" +``` + +--- + +### Task 4: Update Profile Query to Populate Cache + +**Files:** +- Modify: `src/services/grain-api.js` + +**Step 1: Update getProfile method to cache galleries** + +In the `getProfile` method, after mapping gallery data: + +```javascript +// Cache each gallery with partial data (first image only from profile view) +galleries.forEach(gallery => { + // Only set if not already cached with more complete data + if (!recordCache.has(gallery.uri)) { + recordCache.set(gallery.uri, gallery); + } +}); + +// Cache the profile's gallery list +queryCache.set(`profile:${handle}`, { + uris: galleries.map(g => g.uri), + cursor: null, + hasMore: false +}); +``` + +**Step 2: Verify cache population** + +Run: `npm run dev` +Navigate to a profile page. +Open console and verify galleries are cached. + +**Step 3: Commit** + +```bash +git add src/services/grain-api.js +git commit -m "feat: populate record cache from profile query" +``` + +--- + +### Task 5: Update Gallery Detail to Use Cache + +**Files:** +- Modify: `src/components/pages/grain-gallery-detail.js` + +**Step 1: Import record cache** + +Add at top: +```javascript +import { recordCache } from '../../services/record-cache.js'; +``` + +**Step 2: Add helper to build gallery URI** + +```javascript +#buildUri() { + // Construct AT URI from handle and rkey + // Format: at://did/social.grain.gallery/rkey + // We may need the DID, or use handle-based resolution + // For now, use a placeholder pattern that matches what's cached + return this._gallery?.uri || null; +} +``` + +**Step 3: Update loading logic to check cache first** + +Modify the `#loadGallery` method: + +```javascript +async #loadGallery() { + if (!this.handle || !this.rkey) return; + + // Check cache first - timeline/profile may have already loaded this + // We need to find by rkey since we don't have full URI yet + const cachedUri = this.#findCachedUri(); + if (cachedUri) { + const cached = recordCache.get(cachedUri); + if (cached) { + this._gallery = cached; + // If we have full data (photos array), skip fetch + if (cached.photos && cached.photos.length > 0) { + this._loading = false; + return; + } + // Otherwise render partial and continue to fetch + this._loading = false; + } + } + + try { + if (!this._gallery) { + this._loading = true; + } + this._error = null; + const gallery = await grainApi.getGalleryDetail(this.handle, this.rkey); + + // Cache the full result + recordCache.set(gallery.uri, gallery); + this._gallery = gallery; + } catch (err) { + this._error = err.message; + } finally { + this._loading = false; + } +} + +#findCachedUri() { + // Look through timeline cache for matching rkey + const timelineQuery = queryCache.get('timeline'); + if (timelineQuery) { + for (const uri of timelineQuery.uris) { + if (uri.endsWith(`/${this.rkey}`)) { + return uri; + } + } + } + + // Check profile cache + const profileQuery = queryCache.get(`profile:${this.handle}`); + if (profileQuery) { + for (const uri of profileQuery.uris) { + if (uri.endsWith(`/${this.rkey}`)) { + return uri; + } + } + } + + return null; +} +``` + +Also add import for queryCache: +```javascript +import { queryCache } from '../../services/query-cache.js'; +``` + +**Step 4: Subscribe to cache updates for optimistic UI** + +Add subscription in connectedCallback: + +```javascript +connectedCallback() { + super.connectedCallback(); + // Subscribe will be set up after we know the URI +} + +updated(changedProperties) { + const handleChanged = changedProperties.has('handle') && this.handle !== changedProperties.get('handle'); + const rkeyChanged = changedProperties.has('rkey') && this.rkey !== changedProperties.get('rkey'); + if ((handleChanged || rkeyChanged) && this.handle && this.rkey) { + this.#loadGallery(); + this.#setupSubscription(); + } +} + +#currentUri = null; + +#setupSubscription() { + // Clean up old subscription + if (this.#currentUri) { + recordCache.unsubscribe(this.#currentUri, this.#onCacheUpdate); + } + + // Set up new subscription + const uri = this.#findCachedUri() || this._gallery?.uri; + if (uri) { + this.#currentUri = uri; + recordCache.subscribe(uri, this.#onCacheUpdate); + } +} + +#onCacheUpdate = (data) => { + this._gallery = data; +}; + +disconnectedCallback() { + if (this.#currentUri) { + recordCache.unsubscribe(this.#currentUri, this.#onCacheUpdate); + } + super.disconnectedCallback(); +} +``` + +**Step 5: Verify instant loading from cache** + +Run: `npm run dev` +1. Load timeline +2. Click a gallery +Expected: Gallery detail renders instantly (no spinner flash) + +**Step 6: Commit** + +```bash +git add src/components/pages/grain-gallery-detail.js +git commit -m "feat: use record cache for instant gallery detail rendering" +``` + +--- + +## Phase 3: View Transitions + +### Task 6: Add View Transitions to Router + +**Files:** +- Modify: `src/router.js` + +**Step 1: Wrap navigation in startViewTransition** + +Update the `push` method: + +```javascript +push(path) { + if (location.pathname === path) return; + + const navigate = () => { + history.pushState(null, '', path); + this.#navigate(); + window.dispatchEvent(new CustomEvent('grain:navigate')); + }; + + // Use View Transitions API if available + if (document.startViewTransition) { + document.startViewTransition(navigate); + } else { + navigate(); + } +} +``` + +**Step 2: Also update replace method** + +```javascript +replace(path) { + if (location.pathname === path) return; + + const navigate = () => { + history.replaceState(null, '', path); + this.#navigate(); + window.dispatchEvent(new CustomEvent('grain:navigate')); + }; + + if (document.startViewTransition) { + document.startViewTransition(navigate); + } else { + navigate(); + } +} +``` + +**Step 3: Verify basic transition works** + +Run: `npm run dev` +Navigate between pages. +Expected: Subtle cross-fade between pages (default view transition behavior) + +**Step 4: Commit** + +```bash +git add src/router.js +git commit -m "feat: wrap router navigation in View Transitions API" +``` + +--- + +### Task 7: Add Transition Name to Gallery Thumbnail + +**Files:** +- Modify: `src/components/molecules/grain-gallery-thumbnail.js` + +**Step 1: Add view-transition-name to the image** + +Update styles to include transition name via CSS custom property: + +```javascript +static styles = css` + :host { + display: block; + } + a { + display: block; + } + img { + display: block; + width: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; + background: var(--color-bg-elevated); + view-transition-name: var(--transition-name, none); + } +`; +``` + +**Step 2: Set transition name dynamically before navigation** + +```javascript +#handleClick(e) { + e.preventDefault(); + + // Set view transition name just before navigating + const img = this.shadowRoot.querySelector('img'); + if (img) { + img.style.viewTransitionName = 'gallery-hero'; + } + + router.push(`/profile/${this.handle}/gallery/${this.rkey}`); +} +``` + +**Step 3: Commit** + +```bash +git add src/components/molecules/grain-gallery-thumbnail.js +git commit -m "feat: add view-transition-name to gallery thumbnail" +``` + +--- + +### Task 8: Add Transition Name to Gallery Detail Carousel + +**Files:** +- Modify: `src/components/organisms/grain-image-carousel.js` + +**Step 1: Add view-transition-name to first slide image** + +Update the render method to add transition name to first image: + +```javascript +render() { + const hasPortrait = this.#hasPortrait; + const minAspectRatio = this.#minAspectRatio; + + const carouselStyle = hasPortrait + ? `aspect-ratio: ${minAspectRatio};` + : ''; + + return html` + + ${this.photos.length > 1 ? html` +
+ +
+ ` : ''} + `; +} +``` + +**Step 2: Verify shared element transition** + +Run: `npm run dev` +1. Go to a profile page with galleries +2. Click a gallery thumbnail +Expected: The thumbnail image smoothly animates/expands into the carousel position + +**Step 3: Commit** + +```bash +git add src/components/organisms/grain-image-carousel.js +git commit -m "feat: add view-transition-name to carousel for shared element transition" +``` + +--- + +### Task 9: Handle Back Navigation Transition + +**Files:** +- Modify: `src/components/pages/grain-gallery-detail.js` + +**Step 1: Set transition name on first image before navigating back** + +The back button currently just calls `router.push()`. We need to ensure the carousel image has the transition name set before the transition starts. + +Since we're using inline styles in the carousel, this should already work. But we should verify the transition works in both directions. + +**Step 2: Verify bidirectional transition** + +Run: `npm run dev` +1. Profile → Gallery detail (forward): thumbnail expands to carousel +2. Gallery detail → Profile (back button): carousel shrinks back to thumbnail position + +If back transition doesn't work, it may be because the thumbnail doesn't have its transition name set when rendering. In that case, we need to coordinate transition names more carefully. + +**Step 3: Optional - Add transition name to thumbnails on render** + +If back transitions don't work smoothly, update thumbnail to always have a transition name that activates during navigation: + +```javascript +// In grain-gallery-thumbnail.js +render() { + return html` + + ${this.alt + + `; +} +``` + +And update carousel to match: +```javascript +style=${index === 0 ? `view-transition-name: gallery-hero-${this.rkey};` : ''} +``` + +Note: This requires passing `rkey` to the carousel. Only implement if simple approach doesn't work. + +**Step 4: Commit if changes made** + +```bash +git add src/components/molecules/grain-gallery-thumbnail.js src/components/organisms/grain-image-carousel.js +git commit -m "feat: coordinate transition names for bidirectional navigation" +``` + +--- + +## Phase 4: Timeline Integration (if not already cached) + +### Task 10: Verify Timeline Card Navigation Uses Cache + +**Files:** +- Modify: `src/components/organisms/grain-gallery-card.js` (if exists and links to detail) + +**Step 1: Check if timeline cards link to gallery detail** + +Read the grain-gallery-card.js to see if it navigates to detail view. + +**Step 2: Add transition name if timeline cards navigate** + +If cards navigate to detail, add view-transition-name to the card's primary image, similar to thumbnail approach. + +**Step 3: Commit if changes made** + +```bash +git add src/components/organisms/grain-gallery-card.js +git commit -m "feat: add view transition to timeline gallery cards" +``` + +--- + +## Summary + +**Files created:** +- `src/services/record-cache.js` - URI-keyed reactive record cache +- `src/services/query-cache.js` - Query/list cache for pagination + +**Files modified:** +- `src/services/grain-api.js` - Populate caches from API responses +- `src/components/pages/grain-gallery-detail.js` - Use cache for instant render +- `src/router.js` - Wrap navigation in View Transitions API +- `src/components/molecules/grain-gallery-thumbnail.js` - Add transition name +- `src/components/organisms/grain-image-carousel.js` - Add matching transition name + +**User experience:** +- Timeline → Gallery: Instant render, smooth image expansion +- Profile → Gallery: Instant partial render, image expansion, data loads in +- Direct URL: Spinner, then full render +- Back navigation: Smooth transition back to grid diff --git a/public/sw.js b/public/sw.js index 93519a8..f8375e2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -27,11 +27,14 @@ self.addEventListener('fetch', (event) => { // Skip non-GET requests if (event.request.method !== 'GET') return; - // Skip API requests (network-first) - if (event.request.url.includes('/graphql')) return; + const url = new URL(event.request.url); - event.respondWith( - caches.match(event.request) - .then((cached) => cached || fetch(event.request)) - ); + // Only serve shell assets from cache, let everything else go to network + // This prevents unbounded image caching that crashes PWA + if (url.origin === self.location.origin && SHELL_ASSETS.includes(url.pathname)) { + event.respondWith( + caches.match(event.request) + .then((cached) => cached || fetch(event.request)) + ); + } }); diff --git a/src/components/atoms/grain-avatar.js b/src/components/atoms/grain-avatar.js index 18945e3..a729bf8 100644 --- a/src/components/atoms/grain-avatar.js +++ b/src/components/atoms/grain-avatar.js @@ -5,7 +5,7 @@ export class GrainAvatar extends LitElement { src: { type: String }, alt: { type: String }, size: { type: String }, - _hasError: { type: Boolean, state: true } + _hasError: { state: true } }; static styles = css` @@ -56,23 +56,19 @@ export class GrainAvatar extends LitElement { } } - _onError() { + #onError() { this._hasError = true; } - _renderFallback() { - return html` - - `; - } - render() { if (!this.src || this._hasError) { - return this._renderFallback(); + return html` + + `; } return html` @@ -80,8 +76,8 @@ export class GrainAvatar extends LitElement { class=${this.size} src=${this.src} alt=${this.alt} - loading="lazy" - @error=${this._onError} + decoding="async" + @error=${this.#onError} > `; } diff --git a/src/components/atoms/grain-image.js b/src/components/atoms/grain-image.js index 4d49041..68db68e 100644 --- a/src/components/atoms/grain-image.js +++ b/src/components/atoms/grain-image.js @@ -31,23 +31,11 @@ export class GrainImage extends LitElement { height: 100%; object-fit: cover; opacity: 0; - transition: opacity 0.3s ease; + transition: opacity 0.2s ease; } img.loaded { opacity: 1; } - .placeholder { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient( - 135deg, - var(--color-bg-elevated) 0%, - var(--color-bg-secondary) 100% - ); - } `; constructor() { @@ -64,11 +52,11 @@ export class GrainImage extends LitElement { return html`
-
${this.alt diff --git a/src/components/molecules/grain-gallery-thumbnail.js b/src/components/molecules/grain-gallery-thumbnail.js index 76248f5..80ade86 100644 --- a/src/components/molecules/grain-gallery-thumbnail.js +++ b/src/components/molecules/grain-gallery-thumbnail.js @@ -15,13 +15,14 @@ export class GrainGalleryThumbnail extends LitElement { } a { display: block; + aspect-ratio: 3 / 4; + background: var(--color-bg-elevated); } img { display: block; width: 100%; - aspect-ratio: 3 / 4; + height: 100%; object-fit: cover; - background: var(--color-bg-elevated); } `; @@ -39,7 +40,8 @@ export class GrainGalleryThumbnail extends LitElement { ${this.alt `; diff --git a/src/components/molecules/grain-pull-to-refresh.js b/src/components/molecules/grain-pull-to-refresh.js new file mode 100644 index 0000000..9d9b99a --- /dev/null +++ b/src/components/molecules/grain-pull-to-refresh.js @@ -0,0 +1,127 @@ +import { LitElement, html, css } from 'lit'; +import '../atoms/grain-spinner.js'; + +const THRESHOLD = 60; +const MAX_PULL = 100; + +export class GrainPullToRefresh extends LitElement { + static properties = { + refreshing: { type: Boolean }, + _pulling: { state: true }, + _pullDistance: { state: true } + }; + + static styles = css` + :host { + display: block; + overflow: hidden; + } + .container { + position: relative; + } + .indicator { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; + height: 0; + overflow: visible; + pointer-events: none; + } + .content { + transition: transform 0.2s; + } + .content.pulling { + transition: none; + } + `; + + #startY = 0; + #currentY = 0; + + constructor() { + super(); + this.refreshing = false; + this._pulling = false; + this._pullDistance = 0; + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('touchstart', this.#onTouchStart, { passive: true }); + this.addEventListener('touchmove', this.#onTouchMove, { passive: false }); + this.addEventListener('touchend', this.#onTouchEnd, { passive: true }); + } + + disconnectedCallback() { + this.removeEventListener('touchstart', this.#onTouchStart); + this.removeEventListener('touchmove', this.#onTouchMove); + this.removeEventListener('touchend', this.#onTouchEnd); + // Reset state to avoid stale values on reconnect + this._pulling = false; + this._pullDistance = 0; + super.disconnectedCallback(); + } + + #onTouchStart = (e) => { + if (this.refreshing) return; + if (window.scrollY > 0) return; + + this.#startY = e.touches[0].clientY; + this._pulling = true; + }; + + #onTouchMove = (e) => { + if (!this._pulling || this.refreshing) return; + + this.#currentY = e.touches[0].clientY; + const diff = this.#currentY - this.#startY; + + if (diff > 0 && window.scrollY === 0) { + e.preventDefault(); + // Apply resistance + this._pullDistance = Math.min(diff * 0.5, MAX_PULL); + } else { + this._pullDistance = 0; + } + }; + + #onTouchEnd = () => { + if (!this._pulling) return; + + if (this._pullDistance >= THRESHOLD) { + this.dispatchEvent(new CustomEvent('refresh', { bubbles: true, composed: true })); + } + + this._pulling = false; + this._pullDistance = 0; + }; + + render() { + const indicatorY = this._pullDistance - 30; + const showSpinner = this._pullDistance > 10 || this.refreshing; + const opacity = this.refreshing ? 1 : Math.min(this._pullDistance / THRESHOLD, 1); + + return html` +
+
+ ${showSpinner ? html`` : ''} +
+
+ +
+
+ `; + } +} + +customElements.define('grain-pull-to-refresh', GrainPullToRefresh); diff --git a/src/components/organisms/grain-gallery-card.js b/src/components/organisms/grain-gallery-card.js index 1e8fe16..a8e9f92 100644 --- a/src/components/organisms/grain-gallery-card.js +++ b/src/components/organisms/grain-gallery-card.js @@ -72,13 +72,14 @@ export class GrainGalleryCard extends LitElement { }); } + get #rkey() { + return this.gallery?.uri?.split('/').pop() || ''; + } + #handleClick() { const { gallery } = this; if (!gallery?.uri || !gallery?.handle) return; - - // Extract rkey from uri (at://did:plc:xxx/social.grain.gallery/rkey) - const rkey = gallery.uri.split('/').pop(); - router.push(`/profile/${gallery.handle}/gallery/${rkey}`); + router.push(`/profile/${gallery.handle}/gallery/${this.#rkey}`); } render() { @@ -98,6 +99,7 @@ export class GrainGalleryCard extends LitElement {
diff --git a/src/components/organisms/grain-header.js b/src/components/organisms/grain-header.js index aa7e92f..172b328 100644 --- a/src/components/organisms/grain-header.js +++ b/src/components/organisms/grain-header.js @@ -17,6 +17,8 @@ export class GrainHeader extends LitElement { background: var(--color-bg-primary); border-bottom: 1px solid var(--color-border); z-index: 100; + overflow: hidden; + view-transition-name: header; } header { display: flex; diff --git a/src/components/organisms/grain-image-carousel.js b/src/components/organisms/grain-image-carousel.js index 6a52e85..56026ac 100644 --- a/src/components/organisms/grain-image-carousel.js +++ b/src/components/organisms/grain-image-carousel.js @@ -5,6 +5,7 @@ import '../molecules/grain-carousel-dots.js'; export class GrainImageCarousel extends LitElement { static properties = { photos: { type: Array }, + rkey: { type: String }, _currentIndex: { state: true } }; @@ -66,6 +67,11 @@ export class GrainImageCarousel extends LitElement { } } + #shouldLoad(index) { + // Load current slide and 1 slide ahead/behind for smooth swiping + return Math.abs(index - this._currentIndex) <= 1; + } + render() { const hasPortrait = this.#hasPortrait; const minAspectRatio = this.#minAspectRatio; @@ -77,12 +83,13 @@ export class GrainImageCarousel extends LitElement { return html`