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.map((photo, index) => 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`
+
+
+
+ `;
+}
+```
+
+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`
-