Changelog
Everything that shipped,
when it shipped.
Auto-extracted from the build log in our repo. New entries land throughout the day — showing the latest 60. The full record lives in PLAN.md on GitHub.
In the log
60
wake entries indexed
Days shipped
2
with at least one wake
Past 7 days
60
wakes shipped this week
Past 30 days
60
wakes shipped this month
Most recent entry: 21 May 2026. The log started 16 May 2026.
21 May 2026
Stripe orders: idempotency keys on the Checkout + refund POSTs (functional wake — payment-safety / prod-readiness)
- One feature commit pushed (`d79e2c3`, `order-actions.ts`) + this log commit. Functional pick — 08:25 was the 1-in-4 visual, so this wake ran functional. Closes a TODO the code itself flagged: `createStripeCheckoutSession`'s comment read "no idempotency key yet (next wake adds one)"
- **The gap.** Both Stripe write calls in `order-actions.ts` POST without an `Idempotency-Key` header. Stripe explicitly recommends one on every POST: without it a network-level retry — request sent, response lost to a timeout, then resent — is indistinguishable from a fresh call. Two concrete failure modes: (1) `createStripeCheckoutSession` retried → a **duplicate Checkout Session** the buyer could pay through twice; (2) `refundStripeCharge` — the refund button has no in-flight lock, so a photographer who presses it, sees nothing happen (response lost), and presses again issues a **second charge-back** on the same card
- **The fix.** Checkout session POST now sends `Idempotency-Key: checkout_<orderId>` (the order id is freshly minted per `placeOrderAction` call, so it's unique per logical order yet stable across a retry of that same request). Refund POST sends `Idempotency-Key: refund_<orderId>` — `refundStripeCharge` takes a new `idempotencyKey` arg, `setOrderStatusAction` passes the order id. For a repeated key Stripe returns the original object instead of acting again, so each order is charged once and refunded once regardless of retries. The session GET in the refund dance needs no key (GETs are already idempotent)
- + 2 more in the full log
Gallery store: hover lift + ring spinner on the order CTAs (1-in-4 visual wake — microinteraction polish on the commerce surface)
- One feature commit pushed (`6be0b31`, `StoreView.tsx`) + this log commit. The due 1-in-4 visual pick — 07:55 was the last visual, 08:10 ran functional, so this wake was due. Stays on the `/g/<slug>/store` surface the last two wakes have been polishing, this time the buttons rather than the inputs
- **The gap.** All three buttons in the store's product cards — "Order this", "Place order", "Cancel" — carried the `transition` utility class with **nothing actually transitioning**. A dead no-op: no hover, no active, no press feedback on the gallery's commerce surface. Separately, the "Place order" button (the Stripe Checkout hand-off — the highest-stakes click in the whole client flow) signalled its pending state with a bare text swap to "Placing order…", while every other write form in the app (the `/g/<slug>/unlock` form, studio-side forms) had already standardised on a currentColor ring spinner
- **The fix.** "Order this" + "Place order" now use the canonical `.btn-primary` hover idiom — opacity `0.85` + a `1px` lift, settling back on `active`. `enabled:` scopes the lift on the submit button so the disabled pending state never floats. "Place order" pending swaps the text-only label for the app-wide ring spinner (`border-current border-r-transparent animate-spin`, identical to the unlock form) so the in-flight state reads unambiguously as "working", never as a dead tap before the Stripe redirect. "Cancel" gets a subtle `hover:opacity-70` ghost feedback
- + 2 more in the full log
Gallery store order form: restore a focus state on the order fields (functional wake — a11y / prod-readiness on a commerce surface)
- One feature commit pushed (`823654b`, `StoreView.tsx` + `globals.css`) + this log commit. Functional pick — 07:55 was the 1-in-4 visual, so this wake ran functional. Direct parallel of the 07:55 fix on a sibling surface the earlier wake didn't reach
- **The gap.** The 07:55 wake fixed the `/s/<slug>` storefront contact form, but the `/g/<slug>/store` order form had the *identical* defect on a higher-stakes surface. The store renders in the studio's own local palette (`viewerPalette` + inline `accent`/`rule`), and its three order-form inputs — `client_name`, `client_email`, `notes` — carried Tailwind's `focus:outline-none` with nothing in its place. That class out-specifies the global `*:focus-visible` ring and cancelled it. Net: tabbing or clicking into any field of a **payment** form gave zero focus feedback — an a11y defect, and worse here than on the contact form because this form leads to a Stripe charge
- **The fix.** Reuse the shared `.storefront-field` class already in `globals.css` (shipped 07:55). The `<form>` now sets `--sf-focus`/`--sf-ring` inline from `studio.accent` (`<accent>` + `<accent>33` ~20% alpha — the same idiom the `/s/` form uses), and the three inputs swap `focus:outline-none` for `storefront-field`. Focus ring lands in the studio's brand colour, transitioned on the motion tokens. The globals.css class comment was generalised from "/s/<slug> storefront" to cover both surfaces
- + 2 more in the full log
Storefront contact form: restore a focus state on the inquiry fields (1-in-4 visual wake — UI/UX + accessibility polish on a public surface)
- One feature commit pushed (`8d02416`, `globals.css` + `s/[slug]/page.tsx`) + this log commit. This is the due 1-in-4 visual/design pick — 06:40 was the last visual, then 06:55/07:15/07:35 ran functional, so this wake was due
- **The gap.** The public storefront `/s/<slug>` renders entirely in the *studio's own* local palette (accent + rule passed inline per page, not the global `--color-*` tokens). Its contact-form inputs therefore set their border via inline `style` and carried Tailwind's `focus:outline-none`. That class out-specifies the global `*:focus-visible` accent ring (`.focus\:outline-none:focus` beats `*:focus-visible`), so it cancelled it — and nothing replaced it. Net effect: tabbing or clicking into any of the three fields (name / email / message) on a photographer's branded contact form gave **zero focus feedback** — an a11y defect for keyboard users and a visible rough edge for everyone
- **The fix.** New `.storefront-field` class in `globals.css`: on `:focus` it drops the outline, brightens the border to a `--sf-focus` custom property and adds a `0 0 0 3px` halo from `--sf-ring`, both transitioned on the `--dur-base`/`--ease-standard` motion tokens. The `<form>` sets those two properties inline from its own `accent` — `--sf-focus: <accent>`, `--sf-ring: <accent>33` (~20% alpha, the same `${accent}NN` hex-append idiom the file already uses for the inquiry-sent banner). The three inputs swap `focus:outline-none transition` for the single `storefront-field` class. Result: the focus ring is now in the *studio's* brand colour, not Selene's house accent
- + 2 more in the full log
Gallery store: per-gallery OG image for shared store links (functional wake — competitor-parity polish / prod-readiness)
- One feature commit pushed (`c4f7bd7`, new `store/opengraph-image.tsx` + `store/page.tsx` metadata edit) + this log commit. Not a 1-in-4 visual pick — visual cadence unchanged, next visual still due ~2 wakes out. Closes a gap named in the 03:20 archive note ("store has no opengraph-image.tsx so locked-gallery store links stay image-free")
- **The gap.** The gallery page already ships a file-based `opengraph-image.tsx` (cover photo + title in brand chrome), so a gallery link dropped in iMessage/WhatsApp/Slack unfurls with a real frame from the shoot. The **store route** — `/g/<slug>/store`, exactly the URL a photographer pastes into a client chat with the words "order your prints here" — had **no OG image at all**, so that share fell back to the generic root Selene marketing card. The 03:20 wake gave the store `generateMetadata` (title + description + `openGraph`) but explicitly left the image gap open
- **The fix.** New `store/opengraph-image.tsx` — Next 14 auto-emits it as `<meta property="og:image">` on the store route. It renders the gallery cover (same `cover_photo_id` → first-image fallback resolution and focal-point crop as the gallery OG, so the store share shows the same frame as the gallery share), framed as a **commerce card**: a "Prints & packages" eyebrow, an "Order from `<studio>`" subline, and — when a catalog exists — a `from $N AUD` price chip drawn from `MIN(price_cents)` of active products. That price chip is the beat-them detail: competitors' store unfurls show no commerce signal at all. `formatFloor()` shows whole dollars on an even-dollar floor, two decimals otherwise (a $49.95 floor isn't misrepresented as $50)
- + 3 more in the full log
Per-gallery browser-tab titles for the studio gallery workspace sub-pages (functional wake — prod-readiness / UI-UX)
- One feature commit pushed (`60a798c`, 12 files — 1 new layout + 11 one-line page edits) + this log commit. Picks up the optional pickup carried forward since the 06:15/06:40/06:55 wakes — closes the metadata gap on the `/studio/galleries/[id]/*` workspace. Not a 1-in-4 visual pick — visual cadence unchanged, next visual still due ~2 wakes out
- **The gap.** The 06:55 wake gave the six top-level studio routes (`/studio`, `settings`, `orders`, `inquiries`, `mail`, `onboarding`) per-page tab titles, but the **gallery workspace** — the detail page `/studio/galleries/[id]` plus its 11 sub-pages (`analytics`, `blog-export`, `clients`, `comments`, `orders`, `people/[groupId]`, `picks`, `products`, `sections`, `share`, `upload`) — still exported **no `metadata` at all**. So every browser tab, bookmark and `Cmd+T` history entry for a photographer culling/sharing/selling inside a gallery fell back to the root marketing default, `Selene — Beautiful Galleries for Photographers`. A photographer with three galleries' worth of tabs open couldn't tell any of them apart, let alone which sub-page each was
- **The fix.** New `studio/galleries/[id]/layout.tsx` (server component) runs **one** scoped DB lookup — `SELECT title FROM galleries WHERE id = $1 AND studio_id = $2`, the same studio-scoped guard the detail page uses — and exposes the gallery title as a nested `title` object: `default` = `'<gallery> · Selene'` (used by the detail page and any sub-page that doesn't name itself), `template` = `'%s · <gallery> · Selene'`. A nested `title` object **replaces** the inherited root template for its subtree, so the brand suffix is written exactly once here — no risk of the doubled-brand bug the 06:15 wake fixed. Each of the 11 server-component sub-pages then gets a one-line `export const metadata = { title: '…' }` whose plain string resolves through the layout template (`Analytics · <gallery> · Selene`, `Client picks · <gallery> · Selene`, `Store · <gallery> · Selene`, …). Labels match each page's own `<h1>` (`comments` → "Client notes", `products` → "Store", `picks` → "Client picks")
- + 3 more in the full log
Per-page browser-tab titles across the studio workspace + auth pages (functional wake — prod-readiness / UI-UX)
- One feature commit pushed (`53a2350`, 9 files) + this log commit. Picks up the optional pickup carried forward since the 06:15 wake — closes the per-page `<title>` gap directly downstream of that wake's brand-suffix fix. Not a 1-in-4 visual pick — visual cadence unchanged, next visual still due ~2 wakes out
- **The gap.** Six studio routes — the dashboard (`/studio`), `settings`, `orders`, `inquiries`, `mail`, `onboarding` — exported **no `metadata` at all**, so every browser tab, bookmark, window title and `Cmd+T` history entry for the photographer's daily workspace fell back to the root marketing default, `Selene — Beautiful Galleries for Photographers`. A photographer with three studio tabs open couldn't tell them apart. Three more workspace/auth pages (`login`, `signup`, `studio/galleries/new`) are client components (`'use client'` + `useFormState`) — Next only reads route metadata from **server** components, so they couldn't fix it inline either
- **The fix.** The six server-component pages each got a bare `export const metadata = { title: '…' }` (`Dashboard`, `Settings`, `Orders`, `Inquiries`, `Mail`, `Get started`); the root template appends ` · Selene` once, and `studio/layout.tsx`'s `robots: noindex` is inherited down the tree (Next merges metadata), so no robots needed re-stating. The three client pages each got a pass-through server `layout.tsx` (`return children`) that supplies the title — the same split the `forgot-password` route already uses. `login`/`signup` layouts also carry belt-and-suspenders `noindex` matching the `forgot-password` sibling (`robots.ts` already disallows both); `galleries/new` inherits `noindex` from `studio/layout.tsx`
- + 2 more in the full log
Studio dashboard: branded illustration for the "No archived galleries" empty state (1-in-4 visual wake — empty-state consistency)
- One feature commit pushed (`a0903fb`, new `EmptyArchivedIllustration.tsx` + `studio/page.tsx`) + this log commit. This is the 1-in-4 visual slot, due per the 06:15 carry-forward (05:40 was the last visual; 06:00 + 06:15 ran functional). Advances the still-open `[ ] Empty-state illustration set` brand-workstream item
- **The gap.** The studio dashboard's gallery section holds *two* empty states in the **same UI region**, switched by the Active/Archived filter pill. The default — "No galleries yet" — was already a polished `EmptyState` component: a custom `EmptyGalleriesIllustration`, the staggered fade-up cascade, a primary CTA. But its sibling, "No archived galleries", was a flat `border-dashed` text box — no illustration, no cascade, plain `<p>` copy. Flipping the filter swapped one quality bar for another inside one screen — it read as two different products
- **The fix.** New `EmptyArchivedIllustration.tsx` — editorial blank photo frames tucked into an open archive box (lid set aside against its flank, crescent moon behind), drawn in `EmptyGalleriesIllustration`'s exact grammar: flat editorial `rect`s, `--color-*` tokens so it tracks light/dark, `selene-arch-*` gradient defs (own ids — no `<defs>` collision), the shared dashed "shelf" horizon, stray accent dust marks. The open box + "lid set aside" detail is deliberate — it visually carries the empty-state's own copy, "nothing is ever deleted; restore in a click". The archived branch now renders the shared `EmptyState` component (`illustration` slot = the new SVG; `icon={Archive}` is the never-shown fallback the prop type requires), so both empty states now share one layout, one rhythm, one fade-up cascade, one CTA treatment
- + 2 more in the full log
Page titles: fixed the doubled brand suffix the root template was appending (functional wake — prod-readiness / SEO)
- One feature commit pushed (`8f0d8cf`, 11 files) + this log commit. Picked a real shipped bug over the two big open items (video ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Not a 1-in-4 visual pick — visual cadence unchanged, next visual due ~1 wake out
- **The bug.** The root `layout.tsx` sets `metadata.title.template: '%s · Selene'` — Next applies that template to any plain-string `title` a child page exports. But 11 pages **hardcoded the brand into the string anyway**: `about` had `title: 'About — Selene'`, so the rendered `<title>` became `'About — Selene · Selene'`. Every marketing tab, bookmark, and Google search snippet on the live site read the brand **twice**. Affected: `about`, `pricing`, `privacy`, `terms`, `changelog`, `forgot-password`, `reset-password/[token]`, `studio/account`, plus the `generateMetadata` not-found fallbacks in `g/[slug]`, `g/[slug]/store`, `s/[slug]`
- **The fix.** Every one of the 11 plain-string titles dropped its hardcoded `— Selene` / `· Selene` suffix down to the bare page name (`'About'`, `'Pricing'`, `'Store'`, …). The root template now appends ` · Selene` exactly once. This is the pattern the two `not-found.tsx` files already followed correctly (`'Page not found'`, `'Gallery not available'`) — the rest of the app is now consistent with them
- + 3 more in the full log
Loading skeletons: shimmer sweep rolled onto the studio / admin / storefront routes (functional wake — UI/UX consistency, the carry-forward from 05:40)
- One feature commit pushed (`4af0e33`, four `loading.tsx` files) + this log commit. This is the explicit optional pickup the 05:40 wake left in the carry-forward queue; picked it over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Not a fresh 1-in-4 visual pick — it applies an already-shipped pattern across surfaces; visual cadence still says next visual due ~2 wakes out
- **The gap.** Last wake (05:40) shipped the `.skeleton-shimmer` class — a clipped `::before` sweeps a translucent highlight band left-to-right — and wired it into the `/g/[slug]` client gallery viewer skeleton, replacing Tailwind's flat `animate-pulse` opacity blink. But the **other four** route-level loading skeletons were left on `animate-pulse`: `/studio` (the photographer dashboard), `/studio/galleries/[id]` (gallery detail), `/admin` (the operator console), and `/s/[slug]` (the public studio storefront). So a photographer navigating studio→gallery-detail, or a visitor opening a storefront, got the modern directional shimmer on the *client viewer* but a synchronised opacity blink everywhere else — an inconsistency that reads as two different products
- **The fix.** All four `loading.tsx` files now use the same idiom the `/g/[slug]` skeleton established: `animate-pulse` dropped from the `<main>`, a local `Sk()` helper (`aria-hidden` div carrying `skeleton-shimmer` + the block's own `bg-rule*` tone) wraps every placeholder block. Structural flex/grid containers stay plain `<div>`s — only the leaf placeholders with a `bg-rule*` background became `Sk`. The shared class supplies `position:relative` + `overflow:hidden` so each block clips its own sweep, and `prefers-reduced-motion` freezes the band off-screen, so reduced-motion clients get the static blocks. No new CSS — `.skeleton-shimmer` and the `--skeleton-shine` token already shipped last wake
- + 3 more in the full log
Gallery viewer loading skeleton: shimmer sweep replaces the flat opacity pulse (1-in-4 visual wake — premium loading feel on the showpiece route)
- One feature commit pushed (`1c4d491`, `globals.css` + `g/[slug]/loading.tsx`) + this log commit. This is the 1-in-4 visual slot, due per the 05:20 carry-forward (04:25 was the last 1-in-4; 04:40/05:05/05:20 all ran functional)
- **The gap.** `/g/[slug]/loading.tsx` is the pre-paint surface a wedding client sees when they open their gallery from a share email — the showpiece route, the URL that *is* the product. The skeleton geometry was already good (it mirrors the real viewer chrome — studio header, `min(70vh,720px)` cover hero, masonry grid breaking on the live `.gallery-grid` class), but the motion was Tailwind's `animate-pulse` on the whole `<main>`: a flat opacity blink of every block in lockstep. A synchronised opacity blink reads as a *stalled* page; the modern standard (Linear, Vercel, GitHub, Pixieset's own viewer) is a directional shimmer that reads as *loading in progress*
- **The fix.** New `.skeleton-shimmer` class in `globals.css` — a clipped `::before` sweeps a translucent-white highlight band left-to-right across each block, reusing the existing `selene-shimmer` keyframe (the `translateX(-100%)→(100%)` one already powering the image-decode `.shimmer`). New `--skeleton-shine` design token carries the band colour, tuned per colour scheme (`rgba(255,255,255,0.72)` light / `0.12` dark) so the sweep always *lightens* the `bg-rule` base whichever way the client's OS is set — the gallery skeleton renders in the neutral OS palette since the studio's own dark-mode setting isn't known until the data loads. A local `Sk()` helper in `loading.tsx` wraps every placeholder so the markup stays clean; the big cover-hero `<section>` carries the class directly for one full-width sweep behind the synced per-bar sweeps of the title block
- + 3 more in the full log
Store: explicit AUD currency code on the client-facing checkout surface (functional wake — prod-readiness / commerce honesty)
- One feature commit pushed (`8919b91`, `g/[slug]/store/StoreView.tsx`) + this log commit. Carry-forward was free; picked a contained prod-readiness unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 04:25 was the 1-in-4 visual — this runs functional, next visual due ~1 wake out
- **The gap.** Stripe Checkout charges *every* order in AUD — the currency is hardcoded `aud` in `order-actions.ts` (`line_items[0][price_data][currency]`). Both the order-confirmation paths already spell the currency out: `order-actions.ts` and `stripe-webhook/route.ts` each have an `audPrice()` that renders `$50.00 AUD`. But the **store itself** — the surface where the client looks at a price and decides to pay — had a local `aud()` that returned a bare `$50.00`. A client browsing from the US/UK/EU reads `$` as *their* currency, places the order, then Stripe charges them **A$50.00** and the receipt email confirms "AUD". Ambiguous currency on a real-money surface is a genuine prod-readiness defect, and the inconsistency with the email/webhook is the tell. (Audited the rest: `StoreView` is the only *client-facing* price display — `GalleryView` shows no prices; the 7 other `aud()`/`money()` formatters are all studio-side or admin-side, where the operator knows their own currency, so they're left as-is.)
- **The fix.** The product-card price now renders the number followed by a smaller muted `AUD` suffix (`text-xs font-normal tracking-wide`, `muted` colour, `align-baseline`) so the dollar figure still leads visually but the currency is unambiguous — matching what Stripe Checkout's own page and the confirmation email show. The `aud()` helper still returns just the number; the suffix is a sibling `<span>` so it can carry its own type treatment. Wrapped the price span in `whitespace-nowrap` so "$1,250.00 AUD" never breaks mid-figure — the product name beside it already has `truncate min-w-0` to absorb the extra width
- + 2 more in the full log
Gallery `view_count`: stop counting the photographer's own previews (functional wake — competitor-parity / analytics accuracy)
- One feature commit pushed (`0da30c6`, `g/[slug]/page.tsx`) + this log commit. Carry-forward was free; picked a contained competitor-parity unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 04:25 was the 1-in-4 visual — this runs functional, next visual due ~2 wakes out
- **The gap.** `GalleryPage` called `incrementGalleryView(gallery.id)` on *every* render — including when the photographer themselves opened their own gallery to QA it. `view_count` is the engagement metric the studio reads on the dashboard ("how many times have clients looked at this?"), so a photographer who opens a gallery a dozen times while prepping it silently inflates the very number they use to gauge real client interest. Pixieset, Pic-Time and ShootProof all exclude the studio's own visits from gallery view analytics — Selene was the outlier
- **The fix.** `getCurrentUser()` is now resolved **once up front** (right after the gallery/studio load) into `viewer` + `viewerIsOwner` — it was already being called twice on the page (once inside the expiry branch for the re-extend controls, once lower down for the People-row owner check), so hoisting it is a net **one fewer** call, not an extra one. `incrementGalleryView` is now gated `if (!viewerIsOwner)`. Owner check is strict `viewer?.studio_id === gallery.studio_id` — a different photographer browsing still counts (galleries are private so that's near-impossible anyway), only the *owning* studio's own opens are excluded. The displayed count is unchanged regardless (`incrementGalleryView` is a separate `UPDATE`, never mutates the in-render `gallery` object)
- + 2 more in the full log
robots.ts: disallow `/reset-password/` — keep live token URLs out of crawlers (functional wake — prod-readiness / security)
- One feature commit pushed (`48884b1`, `robots.ts`) + this log commit. Carry-forward was free; picked a contained security/prod-readiness unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 04:25 was the 1-in-4 visual — this runs functional, next visual due ~3 wakes out
- **The gap.** `robots.ts` disallowed `/forgot-password` but **not its sibling `/reset-password/`**. The reset route is `/reset-password/<token>` — every URL embeds a *live, single-use* password-reset token. So well-behaved crawlers (and the email link-scanners / unfurlers that honour robots.txt) were free to fetch token URLs. The page already emits `robots: { index:false }` — but that's the *weaker* guarantee: a crawler has to fetch the token URL first to read the noindex tag. A robots.txt `Disallow` means it never fetches the token URL at all. Inconsistent with `/forgot-password`, which is correctly blocked even though it carries no secret
- **The fix.** Added `/reset-password/` to the `disallow` array (trailing slash so it prefix-matches every `/<token>` beneath the route — there is no bare `/reset-password` page, only the `[token]` segment). Verified there's no token-burning bug behind this: `inspectResetToken` (the GET path) is a pure `SELECT`, only `commitPasswordResetAction` (the POST) consumes — so the crawl-disallow is pure defence-in-depth, not a live-token leak being fixed. Also added the file's first explanatory comment block, documenting why each path is blocked and why `/reset-password/` matters most
- + 2 more in the full log
Gallery grid: heart-pop + accent ring when a client favorites from a tile (1-in-4 visual wake — microinteraction polish on the showpiece route)
- One feature commit pushed (`fd21297`, `globals.css` + `GalleryView.tsx`) + this log commit. Carry-forward was free and the cadence was explicitly due for a visual pick (03:05 was the last 1-in-4; 03:20/03:35/04:10 all ran functional)
- **The gap.** The lightbox celebrates a favorite-add with a full-frame Instagram-style heart burst (`heartBurstId` → `AnimatePresence`, 180px accent heart). But hearting a photo straight from a **grid tile** — the faster, more common gesture while scanning — gave **zero feedback**: the pill heart just snapped from outline to filled. A real consistency gap, and competitor grids (Pixieset/Pic-Time) all animate the favorite affordance
- **The fix.** A restrained grid-scale twin of the lightbox burst, pure CSS so it stays cheap across a 500-tile grid (no per-tile framer-motion node). Two keyframes in `globals.css`: `selene-heart-pop` (the glyph scales 1→1.32→0.9→1 over 420ms) and `selene-heart-ring` (a one-shot accent-bordered ring radiates out of the pill, scale 0.55→2.1, opacity 0.5→0, 520ms). In `PhotoTile`: a `popKey` counter increments on every false→true transition of `favored` (un-favoriting never bumps it — same gesture-meaning rule as the lightbox burst: "I love this" gets celebrated, "I changed my mind" doesn't); re-keying the glyph + ring on `popKey` restarts the CSS animation so consecutive re-adds replay cleanly
- + 3 more in the full log
next.config: close the open image proxy — lock down the unused Next image optimizer (functional wake — prod-readiness / security + cost mandate)
- One feature commit pushed (`2fd96cc`, `next.config.mjs`) + this log commit. Carry-forward was free; picked a contained security/cost unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake
- **The gap.** Selene never uses `next/image` — verified: zero `from 'next/image'` imports, zero `<Image>` (the four `<ImageIcon>` hits are the lucide glyph), every photo is served through the app's own `/api/img/[...key]` route (signed R2 reads + our own resize presets). But the built-in Next image optimizer's HTTP endpoint, `/_next/image`, stayed live regardless, and `next.config.mjs` had `images.remotePatterns: [{ protocol: 'https', hostname: '**' }]` — a **wildcard hostname**. That turned `/_next/image` into an **open image proxy**: any anonymous caller could hit `selene.gallery/_next/image?url=https://any-host/huge.jpg&w=3840&q=100` and have Vercel fetch + re-encode an arbitrary remote image. Two real costs: (1) every such request burns a billed Vercel image-optimization unit — straight against Yasitha's low-margin cost mandate, and trivially scriptable into a bill-spike; (2) a mild SSRF surface (server-side fetch of attacker-chosen URLs). The wildcard was almost certainly a scaffold default that was never tightened once the app settled on its own `/api/img` pipeline
- **The fix.** `images.unoptimized: true` takes the optimizer out of the request path entirely (correct, since nothing uses it — also removes the image-optimization billing surface for good), and `images.remotePatterns: []` makes `/_next/image` reject every remote `url` param with a 400 as defence-in-depth (the allowlist check runs regardless of `unoptimized`). Local-path `<Image>` usage, if ever added later, still works — served as-is. Dropped the now-moot `formats` line. Verified the config still loads + parses via `node -e "import('./next.config.mjs')…"` → `images = {"unoptimized":true,"remotePatterns":[]}`
- + 2 more in the full log
Gallery unlock: explain why an expired one-click link dropped the client at the password form (functional wake — prod-readiness / UX correctness)
- One feature commit pushed (`g/[slug]/page.tsx` + `g/[slug]/unlock/page.tsx`) + this log commit. Carry-forward was free; picked a contained UX-correctness unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 03:05 was the 1-in-4 visual — this runs functional, next visual due ~2 wakes out
- **The gap.** The `/g/[slug]/auth` magic-link route (the one-click entrypoint a client opens straight from a share email) 302s back with `?link=expired|missing|mismatch|gone` on every failure mode — but **nothing ever read that param**. For a non-password gallery the failure is harmless (the client still sees the gallery). The real defect was the **password-protected** path: on a failed magic link the auth route redirects to `/g/<slug>?link=expired`, the gallery page's password gate fires `redirect('/g/<slug>/unlock')` — and **dropped the param**. So a wedding client who opened the share email a week late (magic links expire) got silently bounced to a bare password form, wondering why the "one-click" link was asking for a password. The `?link=` reason was 100% dead for the one case where it mattered
- **The fix.** `page.tsx` now reads `searchParams.link` and forwards it onto the unlock redirect (`/g/<slug>/unlock?link=<reason>`, `encodeURIComponent`'d). The unlock page maps the reason via a new `linkNoticeText()` helper — `expired`/`missing`/`gone` → "Your one-click link from the email has expired. Enter the gallery password below to continue."; `mismatch` → "That one-click link was for a different gallery…" — and renders it as a friendly `Mail`-iconed accent-tinted notice above the password form. Unknown/junk `?link=` values fall through to `null` → no banner, no crash. `UnlockForm` itself is untouched — the notice is server-rendered in the page so it costs zero client JS
- + 2 more in the full log
Gallery store: per-gallery `generateMetadata` — branded tab title + link unfurl (functional wake — prod-readiness / competitor-parity polish)
- One feature commit pushed (`g/[slug]/store/page.tsx`) + this log commit. Carry-forward was free; picked a contained prod-readiness unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 03:05 was the 1-in-4 visual — this runs functional, next visual due ~3 wakes out
- **The gap.** The `/g/<slug>/store` route — the print/package commerce surface — had no `generateMetadata`. The gallery `page.tsx` ships rich per-gallery + per-photo metadata, but the store route exported none, so the browser tab title and any iMessage/WhatsApp/Slack link unfurl fell back to the root-layout default. The store is exactly the kind of URL a photographer pastes to a client ("here's where you can order prints") and a client bookmarks — a generic, off-brand title there is a real rough edge on a commerce page
- **The fix.** New `generateMetadata` resolves the gallery + studio and emits a `Store · <gallery title> · <studio name>` title, an `Order prints and packages from <studio>` description, plus matching `openGraph` + `twitter` (`summary` card) tags so the unfurl reads branded. A missing/unpublished gallery falls back to `Store — Selene` (no crash, no existence leak beyond what the page already does). Privacy is unchanged: the client-gallery `layout.tsx` already sets `robots: { index:false, follow:false }` and child routes inherit it, so this only improves the bookmark/unfurl — it never makes the store page Google-indexable. No OG image is emitted (store has no `opengraph-image.tsx`), which also keeps locked-gallery store links image-free, consistent with the gallery page's locked-state handling
- + 2 more in the full log
Gallery unlock: pending state on the password form — spinner + locked input (1-in-4 visual wake — microinteraction polish on a client-facing surface)
- One feature commit pushed (`UnlockForm.tsx`) + this log commit. This is the 1-in-4 visual slot, due per the 02:50 carry-forward (02:05 was the last visual). Picked a contained microinteraction unit on a real client-facing surface over the two big open items (video ffmpeg worker — server-ops; RLS — architecturally deep)
- **The gap.** The password-gallery unlock form (`/g/<slug>/unlock`) had no submit feedback at all. The `unlockAction` runs a *deliberately-slow* bcrypt compare behind a `consume()` rate-limit check (added 01:15) — so after the client presses "Enter gallery →" the form sat visually frozen for a few hundred ms with zero signal: no spinner, button still looked clickable, input still looked editable. On a slow phone that reads as a dead tap → double-submit. Every studio-side form already has a `useFormStatus()` pending state (`ExpiryForm`'s `SaveDateButton`, etc.); this client-facing one was the gap
- **The fix.** Split the form body into an inner `FormBody` child so a single `useFormStatus()` call drives both surfaces at once: the password `<input>` goes `disabled` (60% opacity) so it can't be edited mid-submit, and the button swaps "Enter gallery →" for a `currentColor` conic ring spinner (`border-current border-r-transparent animate-spin`, so it tints to the button's text colour under any gallery brand) + "Unlocking…", at 75% opacity with `cursor-not-allowed`. Also: a stale "wrong password" error (red border + message) now clears the instant a retry is `pending`, instead of lingering over the fresh attempt
- + 2 more in the full log
ZIP downloads: Unicode-aware in-archive entry names for non-ASCII originals (functional wake — prod-readiness / i18n correctness)
- One feature commit pushed (`6f9ab11`, `download-all/route.ts` + `download-picks/route.ts`) + this log commit. This is the named follow-up flagged at 02:35 — a clean ~1-wake unit, taken while carry-forward was free. Visual cadence: 02:05 was the 1-in-4 visual — this runs functional, next visual now due
- **The gap.** 02:35 fixed the *single-photo* download route's `Content-Disposition` to carry non-ASCII names via RFC 5987, but the two ZIP routes (`download-all` = whole gallery, `download-picks` = the client's favorites) each had their own local `sanitize()` that still ASCII-folded every photo's `original_filename` with `[^A-Za-z0-9._\- ]+`. The ZIP's *own* `Content-Disposition` is fine (gallery slugs are ASCII) — the defect was the **per-entry names inside the archive**: a German/Japanese/Korean photographer's `Hochzeit Müller.jpg` / `結婚式.jpg` landed in the ZIP as `Hochzeit M_ller.jpg` or all-underscores. ZIP has supported UTF-8 entry names since APPNOTE 6.3.0 (the EFS / language-encoding flag, bit 11 of the local file header), and `archiver` sets it natively
- **The fix.** Both `sanitize()` fns now use the same Unicode-aware filter as the single-photo route — `[^\p{L}\p{M}\p{N}._\- ]+` with the `u` flag — keeping letters/combining-marks/numbers from any script while still collapsing genuine path/shell hazards (slashes, control chars, emoji, quotes) to `_`. `archiver` writes the resulting UTF-8 name with the language-encoding flag set, so modern unzip tools (macOS Archive Utility, Windows Explorer, 7-Zip, `unzip -O`) read it correctly. The dedup (`seen` set → `${p.id}-${name}` prefix) and the resized-preset `-web`/`-large` + `.jpg` coercion are untouched
- + 2 more in the full log
Photo download: RFC 5987 `Content-Disposition` so non-ASCII original filenames survive (functional wake — prod-readiness / i18n correctness)
- One feature commit pushed (`c7636c5`, `api/photos/[id]/download/route.ts`) + this log commit. Carry-forward was free; picked a contained correctness/i18n unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 02:05 was the 1-in-4 visual — this runs functional, next visual due ~2 wakes out
- **The gap.** The single-photo download route built its `Content-Disposition` filename by running `original_filename` through a `[^A-Za-z0-9._\- ]` sanitiser — every non-ASCII character became `_`. A photographer in Germany/France/Japan/Korea uploading `Hochzeit Müller.jpg`, `mariée.jpg` or `結婚式.jpg` got back a *visibly broken* download: `Hochzeit M_ller.jpg`, or a name that was entirely underscores. The legacy `filename="…"` Content-Disposition parameter is ASCII-only by spec; the fix has been a web standard since RFC 5987/6266 — emit a second `filename*=UTF-8''…` parameter, which every browser since ~2013 prefers over the ASCII one. Competitors serve a global photographer base; mangled filenames are a small but real prod-readiness defect
- **The fix.** `sanitizeFilename` is now Unicode-aware — `[^\p{L}\p{M}\p{N}._\- ]` with the `u` flag keeps letters, combining marks and numbers from *any* script (so `Müller`, `結婚式`, `naročnik` survive) while still collapsing emoji, slashes, quotes and shell metacharacters to `_`. Its output is the *display* name. A new `contentDisposition()` helper emits the full RFC 6266 header: `filename="<ascii-folded twin>"` for pre-2013 clients **and** `filename*=UTF-8''<percent-encoded>` for everything modern. The `filename*` value is `encodeURIComponent`'d with the four chars it leaves raw (`' ( ) *`) additionally percent-encoded, per RFC 5987's attr-char set. Both download branches (`original` pass-through + the `large`/`web` resized presets) route through it
- + 2 more in the full log
Gallery analytics: per-photo engagement CSV export (functional wake — competitor-parity + photographer workflow)
- One feature commit pushed (`cb93f55`, new `api/exports/analytics/route.ts` + `analytics/page.tsx`) + this log commit. Carry-forward was free; picked a contained functional unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 02:05 was the 1-in-4 visual/design slot — this runs functional, next visual due ~3 wakes out
- **The gap.** `/studio/galleries/[id]/analytics` surfaces the three engagement axes (favorited / downloaded / commented) as **top-5 lists only**. A photographer deciding which frames to push for prints or an album can see the standouts but had no way to read the long tail — photo #6 onward, or the per-photo numbers behind the bars. Every studio-side ledger surface (orders, comments, picks, plus the studio-wide mail + inquiries) already ships a CSV export; the analytics page was the one engagement surface with no way to get the numbers off the screen
- **The fix.** New `/api/exports/analytics?gallery=<id>` route exports the **full per-photo table** — every photo in the gallery with its favorites, downloads, comments and a `total_engagement` sum, ordered most-engaged first so the standouts float to the top of the CSV. Per-photo counts use **correlated subqueries, not joins**, so a photo with many favorites can't fan-out-multiply its downloads/comments tallies (the classic join-multiplication bug). `downloads` counts single-photo pulls only — whole-gallery / favorite-set ZIP rows carry no `photo_id`, matching the "Most downloaded" list on the page itself. Studio-scoped via the `g.studio_id = $1` JOIN guard; a foreign/invalid gallery id returns a header-only CSV rather than 404 (no existence leak). Filename bakes the scope in — `<gallery-slug>-analytics-<YYYY-MM-DD>.csv` — same idiom as the picks/orders/comments exports so a folder of monthly exports sorts cleanly
- + 3 more in the full log
Marketing nav: keep "Sign in" reachable on mobile across /, /pricing, /about (UI/UX wake — landing-surface design + prod-readiness)
- One feature commit pushed (`page.tsx` + `pricing/page.tsx` + `about/page.tsx`) + this log commit. Carry-forward was free; this is the 1-in-4 visual/design slot (00:55 was the prior visual; 01:05/01:15 + the skipped wakes ran functional) — landing-surface nav is design work. Picked it over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake
- **The gap.** Every marketing header hid "Sign in" behind `hidden sm:inline-block` — so on a phone the landing `/` nav showed only Pricing + Start free, `/pricing` showed only Start free, and `/about` had **no Sign in link at all**, at any breakpoint. A returning photographer opening selene.gallery on their phone (the common case — someone checks their galleries on mobile) had no header path back into their studio: they'd have to scroll to the footer or already know the `/login` URL. "Start free" is for new users; a paying customer needs "Sign in", and every competitor (Pixieset, Pic-Time, ShootProof) keeps it in the mobile header. The header is where users look first — the footer link was a fallback, not a fix
- **The fix.** Dropped `hidden sm:inline-block` from the `/login` link on `/` and `/pricing` so Sign in shows at every breakpoint, and **added the missing Sign in link to `/about`** (it was the only marketing header without one, even on desktop). Only the exploratory demo-gallery link still folds away on small screens — it's discovery, not essential, and keeping it hidden means the mobile row stays a clean Pricing + Sign in + Start free (three compact `text-sm` buttons, fits a 360px viewport). `/pricing`'s nav also moved from a fixed `gap-2` to `gap-1 md:gap-2` so its tighter mobile spacing matches `/` and `/about` — the three marketing headers now share one responsive rhythm
- + 2 more in the full log
Gallery unlock: rate-limit the password form against brute-force (functional wake — prod-readiness / security)
- One feature commit pushed (`7ada87d`, `g/[slug]/unlock/actions.ts`) + this log commit. Carry-forward was free; picked a contained security/prod-readiness unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 00:55 was the 1-in-4 visual, 01:05/this functional — next visual due ~2 wakes out
- **The gap.** A password-protected gallery's `unlockAction` ran the bcrypt compare on **every** POST with **no rate limit**. Gallery passwords are short and human-shared (the photographer hands them to a couple), so an unbounded form is a brute-force target — a script could try thousands of guesses. Worse, every guess pays a *deliberately slow* bcrypt hash, so the form was also a CPU-DoS surface: spam the endpoint and you tie up the worker hashing junk. Every other write path in the app already rate-limits — favorites, comments, inquiries, all three download routes call `consume()` — the gallery unlock was the one unprotected credentialled endpoint
- **The fix.** `unlockAction` now calls the existing in-memory `consume()` limiter: **10 attempts / 10 min per (gallery slug, IP)**, checked *before* the DB lookup and the bcrypt compare so a flood burns neither. IP is read the same way `inquiry-actions.ts` does it (`x-forwarded-for` first hop → `x-real-ip` → `'unknown'`). Keyed on the slug as well as IP so hammering one gallery never locks a client out of an unrelated one. The window is generous enough that a client fat-fingering a password the photographer texted them has plenty of headroom; tight enough that scripted guessing stalls fast. On limit, the action returns a friendly `Too many attempts. Wait N minutes…` message into the existing `UnlockState` form state (no new error channel) — pluralised, derived from the limiter's `retryAfterSeconds`
- + 2 more in the full log
Gallery viewer: `D` keyboard shortcut downloads the current photo (functional wake — power-user UI/UX + competitor-parity)
- One feature commit pushed (`b25b0a5`, `GalleryView.tsx`) + this log commit. Carry-forward was free; picked a contained power-user UX unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 00:55 was the 1-in-4 visual — this runs functional, next visual due ~3 wakes out
- **The gap.** The lightbox already carries a rich keyboard map — ←/→, Home/End, PgUp/PgDn, Space (slideshow), `[`/`]` (speed), `F` (favorite), `I` (EXIF), `Z`/`0` (zoom), `M` (fullscreen). But the single most common power-user action — *download the photo I'm looking at* — had **no key**: a client (or a photographer reviewing their own gallery) had to leave the keyboard, find the toolbar Download button, open its size menu and click. Every desktop photo tool a pro already lives in (Lightroom, Photo Mechanic, Capture One) binds a download/export key; the lightbox's own help sheet even listed "Download" in the Sharing section as if it were a key when it was only a button
- **The fix.** The lightbox keydown handler gains a `d`/`D` branch: it builds an `<a href="/api/photos/<id>/download">` (no `?size=` → the route's primary **Original** resolution, identical to the toolbar Download button's default action), appends-clicks-removes it, and the streamed `Content-Disposition: attachment` response downloads the file without navigating away from the gallery. Gated on `allowDownloads` so it can **never bypass a gallery with downloads switched off** — the exact gate the toolbar button, the right-click block and the drag block already sit behind. Works for video items too (the download route serves both). The existing `isTyping` guard already means `D` types a literal `d` inside the client comment box rather than firing
- + 3 more in the full log
Studio dashboard: 7-vs-7 trend delta badges on the stat cards (1-in-4 visual wake — UI/UX polish + competitor-parity dashboard signal)
- One feature commit pushed (`6966072`, `studio/page.tsx`) + this log commit. This was the due 1-in-4 visual wake (00:15 was the prior visual; 00:25/00:35/00:45 ran functional)
- **The gap.** The dashboard's five stat cards (Galleries, Photos, Views, Downloads, Client picks) already carry a 14-day sparkline under each number. But a sparkline shows *shape*, not *direction* — a photographer glancing at the dashboard couldn't tell at a glance whether a metric was accelerating or cooling without parsing the polyline. Every competitor dashboard (Pixieset, Pic-Time) pairs the mini-chart with an explicit period-over-period delta. Selene had the chart and not the number
- **The fix.** A new `TrendBadge` sub-component renders a small pill next to each stat's value: it sums the recent 7 days of the existing timeline array vs the 7 before and shows `+N%` / `-N%`. Three deliberately-handled edge cases keep it from ever showing nonsense — a dead-flat all-zero window renders **nothing** (a brand-new studio stays uncluttered), a zero-then-active window reads as a soft accent **"New"** pill instead of a `+∞%`, and an exactly-equal window reads **"Steady"** in muted grey. Restrained low-opacity palette — emerald tint for up, amber for down — keeps it editorial rather than stock-ticker loud; mid-tone hues survive both the light and dark studio themes. The value + badge sit in a shared `items-baseline` flex row so the badge rides the number's baseline. **Zero extra queries** — the four 14-day timeline arrays were already fetched on the page for the sparklines
- + 2 more in the full log
SEO: FAQPage JSON-LD on `/pricing` — FAQ rich-result eligibility (functional wake — prod-readiness / competitor-parity SEO)
- One feature commit pushed (`028078e`, `pricing/page.tsx`) + this log commit. Carry-forward was free; picked a contained SEO unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 00:15 was the 1-in-4 visual, 00:25/00:35/this functional — next 1-in-4 visual now due
- **The gap.** `/pricing` renders a 5-item FAQ as a native `<details>` accordion ("Is there really a free trial?", per-photo charges, cancellation, custom domains, photo ownership) — high-intent commercial-query copy. But the page emitted **no structured data for it**. Google can only render a FAQ as an expandable rich result in the search listing if the page ships `schema.org/FAQPage` JSON-LD — without it the questions are just body text. Every competitor's pricing page (Pixieset, Pic-Time, ShootProof) ships FAQ structured data; Selene's pricing page already pins a canonical + OG/Twitter metadata but skipped the one piece that wins SERP real estate
- **The fix.** A single `<script type="application/ld+json">` before the footer emits a `FAQPage` node — `@id` `…/pricing#faq`, `url`, `inLanguage`, and `mainEntity` built by **mapping the same module-scope `faqs` array the visible accordion renders** (`faqs.map(f => ({ '@type':'Question', name:f.q, acceptedAnswer:{ '@type':'Answer', text:f.a } }))`). Deriving the structured data from the exact array that paints the UI means the two can never drift — which is also a hard Google requirement: the JSON-LD answer text must match the on-page copy or the rich result is suppressed. Matches the existing JSON-LD pattern on `/`, `/g/[slug]`, `/s/[slug]`, `/changelog`
- + 2 more in the full log
SEO: stop indexing thin/empty studio storefronts — sitemap omits them + the page itself goes noindex,follow (functional wake — prod-readiness / SEO)
- One feature commit pushed (`8ea4967`, `sitemap.ts` + `s/[slug]/page.tsx`) + this log commit. Carry-forward was free; picked a contained prod-readiness unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 00:15 was the 1-in-4 visual — this runs functional, next visual due ~2 wakes out
- **The gap.** The 00:25 wake closed the *per-page* canonical-tag gap. This wake closes the matching *crawl-quality* gap one level up. `sitemap.ts` emitted a `/s/<slug>` entry for **every studio row** — including studios with zero published galleries, whose storefront renders only a thin "Galleries are on the way" placeholder (header + empty-state SVG + contact form, no real content). `robots.ts` allows `/s/` to be crawled, so Google was being actively pointed at thin pages — and a domain's overall ranking carries a quality signal that thin indexed pages drag down. Second, smaller defect: every storefront entry shared `lastModified: now`, so a studio dormant for months and one that published today looked equally fresh to a crawler — the recrawl-priority hint was noise
- **The fix — two layers.** (1) `sitemap.ts`: the studio query becomes `studios JOIN galleries … WHERE g.is_published = TRUE AND g.archived_at IS NULL GROUP BY s.slug` — the INNER JOIN structurally drops every studio with no live gallery, and `MAX(g.published_at)` per studio gives each surviving entry a real `lastModified` (falls back to `now` only if every published gallery predates the `published_at` column). Filter mirrors exactly what the storefront page uses to list galleries. (2) `s/[slug]/page.tsx` `generateMetadata`: one `SELECT COUNT(*)` of published non-archived galleries; if zero, emit `robots: { index: false, follow: true }`. Sitemap omission *alone* doesn't keep a page out of the index — a crawler that reaches it via any inbound link still indexes it — so the page sets `noindex` authoritatively on itself. `follow` stays on so the crawler still walks the page's links. Both flip back to indexable automatically the moment the photographer publishes their first gallery — no manual step
- + 2 more in the full log
SEO: canonical URLs on the landing `/` and storefront `/s/[slug]` — the two highest-value indexable pages were the only ones missing one (functional wake — prod-readiness / SEO)
- One feature commit pushed (`7b9f48d`, `page.tsx` + `s/[slug]/page.tsx`) + this log commit. Carry-forward was free; picked a contained prod-readiness unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 00:15 was the 1-in-4 visual — this runs functional, next visual due ~3 wakes out
- **The gap.** Five public pages already pin a canonical URL — `/privacy`, `/terms`, `/about`, `/changelog`, `/pricing` each carry `alternates: { canonical }`. The **two most important indexable pages did not**: the landing page `/` and the per-studio storefront `/s/[slug]`. The landing page exports no `metadata` of its own at all (it inherits everything from the root layout), and the root layout deliberately can't carry a canonical — it would cascade `canonical: '/'` onto every child page that doesn't override metadata. So `/` had no canonical and the storefront's `generateMetadata` simply never set one. Both are real duplicate-content magnets: `/` is hit by marketing traffic carrying UTM/`?ref=` query strings and is reachable via the `www` alias; `/s/[slug]` is reachable with `?inquiry_sent=1` appended (the post-inquiry redirect lands right back on the storefront) and likewise via `www` — a crawler indexes each variant as a separate page, splitting ranking signal
- **The fix.** `page.tsx` gains a minimal `export const metadata: Metadata = { alternates: { canonical: '/' } }` — Next 14 *merges* metadata across segments field-by-field, so title/description/OG/twitter still come from the root layout untouched; only the canonical is added. `s/[slug]/page.tsx`'s `generateMetadata` gains `alternates: { canonical: url }`, reusing the clean `${APP_URL}/s/${slug}` string already computed for `openGraph.url`. `metadataBase` is already set in the layout, so both resolve to absolute URLs. The studio-not-found early return is left as-is (no canonical on a 404)
- + 2 more in the full log
Gallery viewer: editorial scroll cue on the cinematic cover hero (1-in-4 visual wake — UX polish on the showpiece route)
- One feature commit pushed (`cc80180`, `GalleryView.tsx`) + this log commit. Carry-forward was free; visual cadence: 23:35 was the last 1-in-4 visual, then 23:50 + 00:05 ran functional — a visual was due, this is it. Picked a contained showpiece-route polish unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake
- **The gap.** `/g/[slug]`'s photo cover hero is a full-bleed `<section>` at `min(70vh, 720px)` — cinematic by design. But on a large desktop display that means the **entire photo grid can sit below the fold**: a client lands on the gallery, sees a single big cover image + the title block, and has no in-page signal that there are 200 photographs waiting just past the scroll line. Pixieset, Pic-Time and most editorial photo sites all plant a scroll affordance on a tall cover for exactly this reason — Selene's showpiece route had none
- **The fix.** A new **"View gallery" scroll cue** rests at the foot of the cover: a small `text-eyebrow` label above a glass-backed circular `ChevronDown` that gently bobs (`y:[0,5,0]`, 1.9s loop). It is `AnimatePresence`-gated on a new `heroCueVisible` state — driven off the *existing* rAF-coalesced scroll listener (the same one feeding the back-to-top FAB) — so it **retires the instant the client scrolls past 24px**: it has done its one job the moment they've found the grid. Tapping it `window.scrollTo`s to exactly `heroRef.offsetTop + offsetHeight`, so the grid's first row lands flush at the top of the viewport in a single tap
- + 3 more in the full log
Gallery analytics: Downloads-by-type breakdown — split the headline count by surface + size (functional wake — closes a competitor-parity analytics gap)
- One feature commit pushed (`f02b23d`, `analytics/page.tsx`) + this log commit. Carry-forward was free; picked a contained competitor-parity unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 23:35 was the 1-in-4 visual — this runs functional, next visual due ~2 wakes out
- **The gap.** The per-gallery analytics page (`/studio/galleries/[id]/analytics`) surfaces a single **Downloads** stat card — a flat count of every `downloads` row. But the table has tagged every row with a `kind` for a while: `photo`/`photo-large`/`photo-web` (single photo from the lightbox), `zip`/`zip-large`/`zip-web` (whole-gallery "Download all" ZIP), `selected`/`selected-large`/`selected-web` (the client's favorite-set ZIP). Nine distinct behaviours collapsed into one number — the photographer could see *that* clients downloaded but not *how*: cherry-picking individual frames vs bulk-grabbing the whole gallery vs pulling just their selects, and at what resolution. Pixieset/Pic-Time both break download analytics down by surface; Selene logged the data and threw the dimension away at render
- **The fix.** New **"Downloads by type"** section between the 7-day chart and the top-photos triplet. One `GROUP BY kind` query, then a JS bucketing pass folds the nine kinds two ways — by *surface* (`startsWith` photo/zip/selected) and by *size* (`endsWith` -large/-web, else original). Three proportional fill-bar rows (`DownloadTypeRow` — label · share-width ink bar · `tabular-nums` count, the 7-day chart's bar/ink vocabulary so the two engagement surfaces read as one family): Individual photos / Whole-gallery archive / Favorite-set archive. A muted sub-line breaks the same total down by resolution — `Original N · Large N · Web N` — which doubles as quiet in-product marketing of the size-preset feature the last three wakes shipped across all three download routes
- + 3 more in the full log
20 May 2026
Download my picks: `?size=` presets — original/large/web parity with download-all (functional wake — closes a consistency / competitor-parity gap)
- Two feature commits pushed (`download-picks/route.ts` backend, then `GalleryView.tsx` viewer menu) + this log commit. Carry-forward was free; picked a contained competitor-parity unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 23:35 was the 1-in-4 visual — this runs functional, next visual due ~3 wakes out
- **The gap.** The 21:40 wake gave the gallery-wide **"Download all"** ZIP a `?size=` picker — Original / Large (~3000px) / Web (~1920px) — and the single-photo `/api/photos/[id]/download` route has had the three presets for a while. But the **client favorites ZIP** (`/api/galleries/[id]/download-picks`, the "Download my picks" button in the picks sheet) still only ever shipped **full-res originals**. A client who hearts their 30 selects and just wants a light web-size set to drop in a group chat had no choice — they'd pull tens of MB of masters or nothing. Selene offered the size choice per-photo *and* for the whole gallery but not for the one set a client most wants to pull — exactly the inconsistency the 21:40 wake called out and half-closed
- **Backend.** `download-picks/route.ts` gains the same `?size=` machinery as `download-all`: the shared `PRESETS` table, `parseSize()`, and the `sharp()` resize piped into the archiver entry-by-entry (`src.pipe(sharp().rotate().resize({…fit:'inside',withoutEnlargement:true}).jpeg({…mozjpeg}))`) so it stays lazy — only the photo currently draining sits in memory. ZIP filename + every entry name take a `-large`/`-web` suffix with a coerced `.jpg` extension; the `downloads` analytics row logs `selected-large`/`selected-web` kinds (consistent with download-all's `zip-large`/`zip-web` and the photo route's `photo-large`/`photo-web` — `selected` unchanged for originals)
- + 3 more in the full log
Gallery expired screen: branded `ExpiredIllustration` — close the last terminal-state surface still using a placeholder (1-in-4 visual wake)
- One feature commit pushed (`621c87a`, new `ExpiredIllustration.tsx` + `ExpiredScreen.tsx`) + this log commit. Visual cadence: 22:05 visual, then 22:35 + 23:05 functional — this is the 1-in-4 visual, on time
- **The gap.** Selene has a coherent illustration family — `NotFoundIllustration` (global + gallery 404) and `ErrorIllustration` (route error boundary) are full editorial SVGs: dashed-rule horizon + photo-frame + crescent + dust-mote vocabulary, two-mode (`paper`/`ink`) via CSS variables. But `ExpiredScreen` — a real client-facing terminal surface (gallery link past its expiry date) — opened with a **placeholder**: a bare `·` character inside a 48×48 ringed circle. The one terminal-state screen still wearing a stand-in where every sibling had a designed asset
- **The fix.** New `ExpiredIllustration` joins the family — same horizon/frame/crescent/dust grammar, same `paper`/`ink` mode flip, but its own visual story: the crescent is **setting below the horizon** (drawn twice — a dim full pass for the submerged half, a bright pass clipped to the skyline for the sliver still up) while the framed print stays **whole, upright, and keeps its picture** (a clipped mountain-and-moon scene). That split mirrors the screen's copy exactly — "no longer online" but "all favorites and selections are preserved": the moon sets, the picture stays. Two prints lie stacked flat at the base — an album closed and shelved ("archived", not the error screen's "scattered"). A three-dash dusk reflection fades down below the horizon under the crescent
- + 3 more in the full log
Gallery viewer: real multi-platform share menu on the header Share button (functional wake — closes a competitor-parity / prod-readiness gap)
- One feature commit pushed (`a1f20af`, `GalleryView.tsx`) + this log commit. Carry-forward was free; picked a contained competitor-parity unit over the two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake. Visual cadence: 22:05 visual, 22:35 + this functional — 1-in-4 reserve healthy
- **The gap.** The gallery viewer has *two* share affordances. The per-photo lightbox button (`SharePhotoButton`) is rich — a full Facebook / X / LinkedIn / WhatsApp / Telegram / Reddit / Bluesky / email menu with copy-link and a native OS-share-sheet item on mobile. But the **gallery-level header Share button** was a single line: `onClick={() => navigator.share?.({…})}`. `navigator.share` only exists on iOS Safari + Android Chrome — so on **every desktop browser without the Web Share API** (most desktop Chrome/Firefox) clicking the header Share button did *nothing at all*. The most visible share entry point on the showpiece route was a silent no-op for desktop clients — and Pixieset/Pic-Time both give clients a real whole-gallery share
- **The fix — generalise, don't duplicate.** `SharePhotoButton` was refactored into a reusable `ShareMenuButton` (props: `accent`, `shareUrl`, `caption`, `nativeTitle`, `nativeText`, `triggerIcon`, `triggerClassName`, `ariaLabel`) — every platform handler now reads the injected `shareUrl`/`caption` instead of hard-wired photo state. `SharePhotoButton` becomes a thin wrapper over it (byte-identical behaviour — same `?p=` deeplink, same photo-caption build). New `ShareGalleryButton` wrapper wires the header button to the *same* menu, so the two surfaces share one implementation and can never drift apart again
- + 4 more in the full log
Studio storefront: dedicated loading skeleton for `/s/[slug]` (functional wake — closes the 22:05 carry-forward's named follow-up)
- One feature commit pushed (`81d0f44`, new `src/app/s/[slug]/loading.tsx`) + this log commit. Picks up the 22:05 carry-forward verbatim: "Note: `/s/[slug]` (studio store) likewise has no dedicated `loading.tsx` — a smaller follow-up candidate." Visual cadence: 22:05 was the 1-in-4 visual — this runs functional, first-paint prod-readiness on a public route
- **The gap.** `/s/[slug]` — the photographer's public **storefront**, the route `<slug>.selene.gallery` and a studio's `custom_domain` rewrite to (see `src/middleware.ts`) — had **no `loading.tsx`**. Its server component fetches the studio row, then every published gallery with a cover thumbnail + photo count, then builds the `ProfessionalService` JSON-LD; during all of that Next streamed the *root* `loading.tsx`, the centered Selene-mark pulse. A visitor landing on a photographer's branded domain got a blank Selene splash, then a sudden full-page swap — exactly the gap the 22:05 wake closed for `/g/[slug]`
- **The fix.** New route-level skeleton mirroring the real page chrome: the bordered header (`container-wide py-8 md:py-12 border-b` — avatar circle + wordmark row, two social chips opposite, then the eyebrow chip, the two-line display headline at the real `text-5xl md:text-7xl` rungs, an about paragraph), then the **three-up gallery card grid**. The grid stub deliberately **reuses the live `grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8` layout** — so cards break at the identical breakpoints as the real grid — and each card uses the page's exact `aspect-[4/5] rounded-xl` cover dimension, a title bar, a date·count meta line. Six card stubs (a full three-up row plus a second) hand off to the streamed galleries with no layout jump
- + 3 more in the full log
Gallery viewer: dedicated masonry loading skeleton for `/g/[slug]` (1-in-4 visual wake — first-paint polish on the showpiece route)
- One feature commit pushed (`a479f38`, new `src/app/g/[slug]/loading.tsx`) + this log commit. The 1-in-4 visual reserve the last two carry-forwards flagged: 21:10 visual, then 21:25 + 21:40 functional — a visual was due
- **The gap.** `/g/[slug]` — the gallery viewer, the showpiece, the URL clients actually open — had **no `loading.tsx`**. Its server component does a lot before first paint (gallery row, every photo + EXIF, sections, face groups, studio brand, expiry check, view increment), and during all of that Next streamed the *root* `loading.tsx` — a centered Selene-mark pulse. That gave a client zero sense of the gallery about to arrive: a blank brand screen, then a sudden full-page swap to a cinematic cover hero
- **The fix.** New route-level skeleton that mirrors the real viewer chrome: the studio header (wordmark + sort/density/download-all pills + two round icon buttons), the full-bleed **cover hero** at the viewer's exact `min(70vh, 720px)` height with a bottom-left title block (eyebrow → two-line title → meta line) aligned to `container-wide`, then the masonry photo grid. The grid stub deliberately **reuses the live `.gallery-grid.density-comfortable` class** — so its columns break at the identical media-query breakpoints (1 / 2 / 3 / 4) as the real grid, and the twelve varying-aspect `<figure>` tiles hand off to the streamed photos with no layout jump
- + 3 more in the full log
Download-all ZIP gains `?size=` presets — gallery-wide original/large/web, Pixieset/Pic-Time parity (functional wake — closes a competitor-parity gap)
- Two feature commits pushed (`7fe6160` backend route, then the `GalleryView.tsx` viewer menu) + this log commit. Picks a competitor-parity gap over the carry-forward's two big open items (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep), neither of which fits a ~10-min wake
- **The gap.** The single-photo `/api/photos/[id]/download` route has served three size presets for a while — `original` / `large` (~3000px, JPEG q88) / `web` (~1920px, q85) — and the lightbox already has a `DownloadPhotoButton` dropdown for them. But the gallery-wide **"Download all" ZIP** only ever shipped originals: a plain `<a href=".../download-all">`. Pixieset *and* Pic-Time both let a client pull the whole gallery at a chosen resolution (a 400-photo wedding as full-res masters is multi-GB; the same set at web size is a fraction and fine for a group chat). Selene offered the choice per-photo but not for the bulk action — an inconsistency a client would notice
- **Backend (`7fe6160`).** `download-all/route.ts` gains a `?size=` param mirroring the single-photo route's `PRESETS` table. `original` keeps the existing store-only pass-through stream. For `large`/`web` each master is **piped through `sharp()` on the way into the archive** — `objectStream(key).pipe(sharp().rotate().resize({…fit:'inside',withoutEnlargement:true}).jpeg({…mozjpeg}))` — so it stays lazy: only the photo archiver is currently draining sits in memory, not the whole gallery. `.rotate()` bakes EXIF orientation since resized output is plain JPEG. ZIP filename + every entry name take a `-large`/`-web` suffix with a coerced `.jpg` extension; the `downloads` analytics row logs `zip-large`/`zip-web` kinds (bytes is an honest upper bound for resized — documented inline, the table is analytics not billing)
- + 3 more in the full log
fade-up: fix the `:nth-of-type` cascade in EmptyState + the gallery picks empty state (functional wake — closes the 21:10 carry-forward's named latent follow-up)
- One feature commit pushed (`8779de7`, `EmptyState.tsx` + `GalleryView.tsx`) + this log commit. Picks up the 21:10 carry-forward verbatim: "**Latent follow-up:** `EmptyState.tsx` and `GalleryView`'s ClientWelcome also apply `animate-fade-up` to mixed-tag sibling groups — same `:nth-of-type` fragility, lower-profile surfaces; worth an inline-delay pass." Visual cadence: 21:10 visual, so this runs functional — fixing a deterministic rendering bug is prod-readiness, not a feel call
- **Same bug as the hero, two more surfaces.** Both `EmptyState` (the shared studio empty-state component — used by ~11 call sites: dashboard, mail outbox, picks, orders, gallery-filter, …) and `GalleryView`'s picks/filter empty state apply `.animate-fade-up` to a **mixed-tag** sibling group: `div` (icon/illustration) → `p` (title) → `p` (description) → `div` (actions). `globals.css`'s shared `.animate-fade-up:nth-of-type(N)` fallback counts position *per tag*, so the real delays were: icon = 1st `div` = 0ms, title = 1st `p` = 0ms, description = 2nd `p` = 80ms, actions = 2nd `div` = 80ms. The four-step cascade collapsed into **two pairs popping together** — icon+title, then desc+actions
- **The fix.** Explicit `style={{ animationDelay }}` on all four elements in each group — `0 / 80 / 160 / 240ms` — the pattern the landing hero now uses and that the `globals.css` rung-doc prescribes for mixed-tag groups. In `EmptyState` the `description` prop is optional, so a `STEP = 80` const computes the actions row's slot (`STEP * (description ? 3 : 2)`) — the cascade stays gapless whether or not a description renders. In `GalleryView` all four are unconditional, so the delays are literal. Both groups now reveal as one smooth icon→title→description→actions motion
- + 2 more in the full log
Landing hero: fix the broken staggered entrance cascade (visual wake — closes brand-queue line 30, the landing-hero rework)
- One feature commit pushed (`c52924e`, `page.tsx` + `globals.css`) + this log commit. The 1-in-4 visual reserve the 21:05 carry-forward flagged (last visual 20:25; 20:50 + 21:05 functional — a visual was due). Picks the named candidate — landing hero rework (brand-queue line 30)
- **The bug.** The hero's five entrance elements — eyebrow `<p>`, `<h1>`, subtitle `<p>`, CTA `<div>`, trust-strip `<ul>` — all carry `.animate-fade-up` but **no inline `animationDelay`**. They fell through to `globals.css`'s shared fallback rules, `.animate-fade-up:nth-of-type(N) { animation-delay }`. The trap: `:nth-of-type` counts position among siblings **of the same tag**, and these five are *mixed* tags. So the actual delays were: eyebrow = 1st `<p>` = 0ms, `<h1>` = 1st `<h1>` = 0ms, subtitle = 2nd `<p>` = 80ms, CTAs = 1st `<div>` = 0ms, trust = 1st `<ul>` = 0ms. **Four of the five fired simultaneously** — the staggered reveal was effectively dead on the single most important above-the-fold surface
- **The fix.** Explicit `style={{ animationDelay }}` on all five hero elements — `0 / 80 / 160 / 240 / 320ms` — which is exactly what the `globals.css` block's own comment prescribes ("Cascade the children via inline `animation-delay`") and exactly how `HeroShowcase` already drives its print-stack. Inline style beats the stylesheet rule, so the cascade is now deterministic regardless of tag order. The eyebrow→headline→subtitle→CTAs→trust reveal reads as one motion, and its `0→320ms` span overlaps `HeroShowcase`'s `60→470ms` back-to-front frame assembly so the text and the photo stack resolve together
- + 3 more in the full log
/changelog: Blog/BlogPosting JSON-LD structured data — close the last indexable-page schema gap (functional wake — SEO / prod-readiness)
- One feature commit pushed (`src/app/changelog/page.tsx`, +~60) + this log commit. First wake free of the design-token consolidation workstream (closed at Wake 5, 20:50). Picked a contained, single-file prod-readiness unit over the two big open items the carry-forward names (video upload ffmpeg worker — server-ops; RLS enable — architecturally deep) — neither fits a ~10-min wake cleanly
- **The gap.** Grepped `src/` for `ld+json` — JSON-LD lives on `/` (Organization + SoftwareApplication graph), `/g/[slug]` and `/s/[slug]`, but **not** `/changelog`. The changelog is the public proof-of-shipping-velocity page `/about` links to (a prospect can verify the cadence claim) — and it was the one indexable marketing surface emitting zero structured data. A search engine saw it as one undifferentiated page, not a dated publication
- **The fix.** A `<script type="application/ld+json">` after `<Footer />` (mirroring `page.tsx`'s placement) emitting an `@graph` of two nodes: an inlined `Organization` whose `@id` is `${siteUrl()}/#organization` — *identical* to the landing page's, so Google merges them into one entity rather than inventing a duplicate — and a `Blog` (`@id` `…/changelog#blog`) whose `blogPost` array is the 20 most-recent wakes as `BlogPosting` nodes (`headline`, `datePublished` from `entryIsoDate()`, `url` with the `#anchor`, `description` from the first bullet, `author`/`publisher`/`isPartOf` back-references). Rich-result eligible — the shipping cadence now reads to a crawler as a dated blog
- + 3 more in the full log
Design-token consolidation Wake 5: spacing — the audit that closed the workstream (functional wake — last of the four token families)
- One feature commit pushed (`de393a4`, 2 files) + this log commit. Picks up the 20:25 carry-forward verbatim: "**Wake 5+ = spacing tokens** — the last of the four token families … a contained first unit: audit raw `p-[…]`/`gap-[…]`/`m-[…]` arbitrary spacing values vs Tailwind's stock 4px scale." Visual cadence: 20:25 visual, 20:00/19:38 functional — no reserve pressure, this wake free
- **The audit — and the verdict: spacing is already clean, no sweep exists.** Grepped `src/` for every arbitrary in the spacing families (`p`/`px`/`py`/`pt…`/`m`/`mx…`/`gap`/`gap-x/y`/`space-x/y`/`inset`/`top`/`bottom`/`left`/`right`-`[…]`). The *entire* codebase has **8** of them. Unlike type-scale (~140 hand-rolled `text-[11px]`/`text-[10px]` literals) or motion (a duplicated `cubic-bezier`), there is **no drift to consolidate** — Tailwind's stock 4px scale is used with discipline everywhere a rhythm value belongs. Spacing is the cleanest of the four families; the deliverable is the *verified* audit, not a sweep
- **Why each of the 8 survivors is justified (not drift):** `pt-[12vh]` (CommandPalette — viewport-relative), `bottom-[calc(50%-88px)]` (GalleryView — a calc expression), `left-[12%]`/`top-[9%]` (HeroShowcase — percentage placement) are all **structurally non-tokenizable** — no 4px-scale rung can express vh/calc/%. The remaining four are **precise alignment offsets** derived from non-scale elements: `-left-[27px] md:-left-[35px]` (changelog timeline node = list-pad 24/32px + 3px to optically centre the 10px dot on the 1px rule) and `ml-[22px]` ×2 (BuiltForPhotographers body copy = 11px `SeleneMark` + 12px `gap-3`, less 1px optical). Snapping any of these to the 4px scale would visibly misalign — they are computed values, not rhythm steps
- + 3 more in the full log
Design-token consolidation Wake 4b: type-scale — the off-rung snap-or-widen feel call (visual wake — closes the type-scale leg; Wake 4's parked carry-forward)
- One feature commit pushed (`888ab46`, 12 files, 28 swaps) + this log commit. Picks up the 20:00 carry-forward verbatim: "**Wake 4b (visual — snap-or-widen feel call)** = the *off-rung* sub-`text-xs` literals the exact-match sweep deliberately left alone." Visual cadence: 20:00 + 19:38 functional, 19:15 visual — a visual was due on the 1-in-4 reserve; this fits, exactly the Wake 2→2b split (functional rungs first, the motion-feel call as the following visual)
- **The verdict: SNAP, don't widen** — the inverse of Wake 2b's call, and deliberately so. Wake 2b *widened* the motion scale because `duration-300`/`500` were genuine recurring cross-file patterns that earned named rungs. Here every off-rung literal either sits a sub-pixel fraction off an *existing* rung (snapping is imperceptible) or is single-file (no shared token would result). Nothing earns a new rung — so `tailwind.config.ts` is untouched; everything is absorbed by `text-3xs`/`text-xs`/`text-sm`/`text-base`
- **Micro band → `text-3xs` (10px).** `text-[0.6rem]` (9.6px, ×14 — the biggest cluster: CmdK/CommandPalette kbds, RecentActivity/mail/pricing/gallery-expiry/GalleryListFilter uppercase badge labels) snaps +0.4px; `text-[0.65rem]` (10.4px, ×3) snaps −0.4px; `text-[9px]` (×3 — viewer slot badge, section "hidden" overlay, admin provider chip) snaps +1px **up** — sub-10px is an accessibility floor, so 9px snaps up rather than blessing a 9px rung. Three hand-rolled values (9 / 9.6 / 10.4px) collapse onto one rung — the exact point of consolidation
- + 4 more in the full log
Design-token consolidation Wake 4: type-scale — micro-type `fontSize` rungs (`text-2xs`/`text-3xs`) + exact-match sweep (functional wake — opens the type-scale leg, third of four token families)
- Two feature commits pushed (`tailwind.config.ts` scale, then the 37-file `src/` sweep) + this log commit. Picks up the 19:38 carry-forward verbatim: motion ✅ (Wakes 1→2→2b), colour ✅ (Wake 3), "Wake 4+ = the last two families — type-scale and spacing … a contained first unit: audit the font-size / line-height ladder used across headings vs raw `text-[…]` arbitrary values"
- **The audit.** Grepped `src/` for every `text-[<size>]` arbitrary font-size. Finding: the headings ladder is *fine* — `text-display`/`text-eyebrow` in `globals.css` + Tailwind's `text-xl…7xl` cover it. The real drift is a **micro-typography band below `text-xs` (12px)**: stock Tailwind has no rung under 12px, so the app's dense surfaces (admin data tables, badges, count pills, meta-labels) hand-rolled arbitrary values — `text-[11px]` and `text-[10px]` alone appear **~140× across 37 files**, plus a scatter of `text-[9px]`/`text-[0.6rem]`/`text-[0.65rem]`/`text-[12px]`/`text-[13px]` one-offs. ~140 untyped literals for two values that recur everywhere = real token drift, same shape as the colour-quartet drift Wake 3 found
- **The scale (commit 1).** `tailwind.config.ts` `theme.extend` gains a `fontSize` block with two named micro-rungs — `2xs` (11px, the common dense-table / badge label) and `3xs` (10px, count pills / smallest meta rung). Declared as **bare size strings** (no `[size, lineHeight]` tuple) so they emit *only* `font-size` — byte-identical to the `text-[11px]`/`text-[10px]` arbitraries, exactly preserving the line-height cascade. Documented inline mirroring the motion-token block. Additive `extend` — every stock `text-xs…7xl` utility still resolves
- + 3 more in the full log
Design-token consolidation Wake 3: colour — `viewerPalette()`, the runtime twin of the `--color-*` tokens (functional wake — opens the colour leg of the token-consolidation workstream)
- Two feature commits pushed (`brand.ts` helper + 3-file migration, then the 4-file sweep) + this log commit. Picks up the 19:15 carry-forward verbatim: motion tokens ✅ (Wakes 1→2→2b), "Wake 3+ = the remaining token families — colour, type-scale, spacing … a contained first unit would be auditing the colour tokens already in `globals.css` vs raw hex/`rgb()` literals scattered in components"
- **The audit.** Grepped `src/` for every occurrence of the five token hex values (`#0a0a0a/#fafaf9/#737373/#d4a373/#e7e5e4` + dark `#a3a3a3/#262626`). Finding: the *vast majority* of colour literals are **intentional and structurally cannot be CSS-var tokens** — (a) image generators (`opengraph-image`, `api/og`, email-templates, `manifest.ts`, `icon.svg`, QR routes) run with no CSS cascade; (b) per-studio brand fallbacks (`brand.accent_color || '#d4a373'`) are user-customisable defaults, already centralised as `DEFAULT_ACCENT` in `brand.ts`; (c) the gallery viewer's `dark ? '#0a0a0a' : '#fafaf9'` quartets theme off the **studio's** dark-mode setting, not OS `prefers-color-scheme`, so they can't read `--color-*` (those flip on the wrong signal). Not drift — but (c) was **duplicated, untyped, and re-typed in 7 files**: real consolidation drift of a different kind
- **The helper (commit 1).** New `viewerPalette(dark: boolean): ViewerPalette` in `src/lib/brand.ts` — the runtime-resolved twin of the `globals.css` `--color-ink/paper/muted/rule` scale. Returns the same four hex pairs (`bg`/`fg`/`muted`/`rule`) the viewer files were each re-deriving by hand; documented inline as "keep in lockstep with the `:root` / dark `@media` blocks". One source of truth for the studio-themed surfaces, mirroring how Wake 1–2b gave motion one source of truth
- + 3 more in the full log
Design-token consolidation Wake 2b: widen the motion duration scale to 6 rungs + sweep the 9 off-scale classes (visual wake — closes the carry-forward the 19:00 wake explicitly parked)
- Two feature commits pushed (`globals.css`+`tailwind.config.ts` scale widen, then the `src/` sweep) + this log commit. Picks up the 19:00 carry-forward verbatim: **Wake 2b** = "consciously decide the fate of the 9 off-scale `duration-300`/`duration-500` occurrences." A motion-feel call, so it ran as the 1-in-4 visual wake (last visual 17:45; 18:41 + 19:00 functional — a visual was due)
- **The decision.** Wake 2 left 9 classes untouched because 300ms/500ms had no rung on the curated 4-step scale (150/200/400/700), and snapping is a feel judgement not a mechanical swap. Reviewed all 9 by purpose: `duration-300` (×5) is consistently a **hover transform** — icon/card scale-pops (`group-hover:scale-110`, `scale-[1.02]`), the landing card lift (`-translate-y-0.5`), the onboarding progress bar; `duration-500` (×4) is consistently a **content reveal** — viewer image cross-fades (`transition-opacity`), the gallery-list thumb hover, the onboarding background fade. Both are *recurring, intentional* motion choices, not drift. Snapping 300→200 or 300→400 would speed/slow every hover pop by 33–50%; snapping 500→400 would clip every cross-fade. So the call: **widen the scale, don't snap** — zero behaviour change, and the two values earn named rungs because they're genuine patterns
- **The widen (commit 1).** `:root` in `globals.css` gains `--dur-moderate: 300ms` (hover transforms) + `--dur-relaxed: 500ms` (image cross-fades / opacity + background reveals), each documented in the inline rung-doc block. `tailwind.config.ts` `transitionDuration` mirrors them so `duration-moderate`/`duration-relaxed` utilities and raw CSS still resolve to one source of truth. Scale is now a clean 6-rung ladder: fast/base/moderate/slow/relaxed/slower = 150/200/300/400/500/700
- + 3 more in the full log
Design-token consolidation Wake 2: motion tokens as Tailwind utilities + exact-match duration sweep (functional wake — continues the brand-queue token-consolidation workstream)
- Two feature commits pushed (`8334e57` config, `c24ec4e` sweep) + this log commit. Picks up the 18:41 carry-forward verbatim: Wake 1 added the motion scale (`--ease-*`/`--dur-*`) to `:root` in `globals.css` and migrated the motion values *inside* that file; Wake 2 carries the same vocabulary out to the component layer
- **Config (`8334e57`).** `tailwind.config.ts` `theme.extend` gains `transitionDuration` (`fast`/`base`/`slow`/`slower` → `var(--dur-fast|base|slow|slower)`) and `transitionTimingFunction` (`standard` → `var(--ease-standard)`, `out-expo` → `var(--ease-out-expo)`). This is the consolidation infrastructure: Tailwind utilities (`duration-base`, `ease-out-expo`, …) and raw CSS now resolve to **one** source of truth — the `:root` scale. Additive only; the stock numeric `duration-*` utilities still exist
- **Sweep (`c24ec4e`).** Swept `src/` for raw `duration-*` classes whose ms value maps 1:1 onto a scale rung and swapped them for the named token — **zero behaviour change**, the CSS var resolves to the identical value: `duration-700`→`duration-slower` (×9: the hero / sample-gallery / `/s/[slug]` slow image-reveal + parallax hover transitions), `duration-200`→`duration-base` (×4), `duration-150`→`duration-fast` (×1). 14 occurrences across 10 files
- + 3 more in the full log
Design-token consolidation Wake 1: motion/easing tokens in globals.css + orphaned-file housekeeping (functional wake — opens the brand-queue token-consolidation workstream)
- Two commits pushed: `scripts/seed-prod-demo.ts` committed out of its orphaned untracked state (a complete 190-line idempotent INSERT-only prod `/g/demo` seeder a prior wake left uncommitted — it had been drifting as a dirty-tree file across every wake since), then the motion-token commit + this log commit
- **The work.** Picks the long-parked "design-token consolidation" brand-queue item ([ ] line 31). Per the spec-sized framing every recent carry-forward used, the contained first unit is motion/easing tokens. Adds a curated motion vocabulary to `:root` in `globals.css`: two easing curves — `--ease-out-expo` (`cubic-bezier(0.22, 1, 0.36, 1)`, Selene's signature decel) and `--ease-standard` (symmetric `cubic-bezier(0.4, 0, 0.2, 1)`) — plus a four-rung duration scale `--dur-fast/base/slow/slower` (150/200/400/700ms). All documented inline; they live in base `:root` only since motion doesn't shift in dark mode
- **Migration within globals.css.** The `0.22,1,0.36,1` curve was duplicated as a literal across the `selene-fade-up` and `selene-chip-pop` keyframe consumers — now one name. `.animate-fade-up` → `var(--dur-slower) var(--ease-out-expo)`; `.animate-chip-pop` → `var(--ease-out-expo)`; `.btn` `all 200ms ease` → `var(--dur-base) var(--ease-standard)`; `.lqip` `400ms ease` → `var(--dur-slow) var(--ease-standard)`. The only `cubic-bezier` literals left in the file are the two token definitions
- + 2 more in the full log
EmptySearchIllustration: branded asset for the gallery-filter no-results state (1-in-4 visual wake — closes the studio's last bare EmptyState)
- One feature commit pushed (`ba0cabe`, +134) + this log commit. The 1-in-4 visual reserve flagged by the 17:30 carry-forward (last visual 17:00 — a visual was due). Picks the "empty-state illustration set" brand-queue candidate over design-token consolidation: the latter is a multi-wake workstream that doesn't fit a ~10-min wake cleanly, the former is a single contained asset
- **The gap.** Every `EmptyState` in the studio carried a custom Selene-branded illustration except one: `GalleryListFilter`'s "No galleries match that filter." state — the no-results surface when a photographer searches/filters their library — still fell back to the generic `Search` icon-in-a-circle. The audit this wake (grep of all 11 studio `<EmptyState>` call sites) confirmed it was the **last** bare one. Closing it makes the illustration set complete across the studio
- **`src/components/illustrations/EmptySearchIllustration.tsx`** — 19th illustration in the set, same grammar as the rest so the studio still reads as one product: `var(--color-*)` tokens (dark-mode adapts with no second asset), crescent moon as the mythic anchor (Selene = moon goddess), the dashed "horizon" shelf, accent confetti dust, hand-cut-paper inner-highlight arc, `selene-search-paper`/`selene-search-accent` gradient pair, the hero centre-piece given the accent-fill + ink-stroke treatment
- + 3 more in the full log
Picks page + CSV export: surface list-only (never-hearted) photos — close the INNER JOIN favorites gap (functional wake — closes the carry-forward flagged every wake since named-lists Wake C)
- Two feature commits pushed (`6e3815d` picks page, `bcae8c6` CSV export) + this log commit. The exact "larger wake" every named-lists wake-log since Wake C (16:00) parked as a carry-forward: the named-favorite-lists workstream let a client file a photo into a named list ("For the album", "Maybe", …) **independently of the heart** — but `/studio/galleries/[id]/picks` was `INNER JOIN favorites`, so a photo a client curated into a list but never hearted was **invisible to the photographer**. A real hole in the feature loop — the client does the work, the photographer never sees it
- **Picks page (`6e3815d`).** The main + `totalsRow` + `allCount` queries swap `INNER JOIN favorites` for a **double `LEFT JOIN`** (`favorites` + `favorite_list_items`) gated on a base condition `(f.id IS NOT NULL OR i.id IS NOT NULL)` — that guard is what turns the joins into a "hearted OR list-filed" membership test. Every aggregate is a `COUNT(DISTINCT …)` or a `MAX(…)`, so the favorites×list-items join fan-out never corrupts a count or a date. New `latest_activity_at = GREATEST(MAX(f.created_at), MAX(i.created_at))` drives the three-state freshness rail, so a list-only pick still glows hot (GREATEST ignores the NULL side; the WHERE guard guarantees ≥1 non-null). New `in_lists = COUNT(DISTINCT i.list_id)`; the 'Most picked' sort tiebreaks `picked_by DESC, in_lists DESC` so list-only photos sort sensibly among themselves
- **List-only card affordance.** A photo that reached the page only via a list filing has `picked_by 0` — a "♥ 0" badge would read as "nobody picked this". Those cards now show a `ListChecks`-glyph badge with the list count instead (titled "Filed into N named lists — not hearted"), so the photographer can tell at a glance *why* the frame is there. `distinctClients` also now counts clients who only ever curated named lists (never tapped a heart)
- + 3 more in the full log
Named favorite lists: AddToListMenu on grid tiles — file picks without opening the lightbox (functional wake — closes the named-lists workstream's last small gap)
- One feature commit pushed (`67f1651`, +56/−7 on `GalleryView.tsx`) + this log commit. The functional wake the 17:00 carry-forward flagged. Every named-lists wake since C2 (16:15) named this small gap: a client could file a photo into a named list **only from inside the lightbox** — the grid tile carried just the heart, so a client triaging a 400-photo gallery by eye had to open each frame to sort it
- **`AddToListMenu` now renders on every grid tile**, at the top-left corner, mirroring the lightbox menu. New `variant` prop (`'lightbox' | 'tile'`): the `tile` variant restyles the trigger to match the heart button — `rounded-full p-2 backdrop-blur-md`, `rgba(0,0,0,0.4)` fill, hover-revealed (`opacity-0 group-hover:opacity-100`) — and stays lit (`!opacity-100`) while the menu is open or the photo is already filed somewhere (the accent count badge carries the "filed" signal, distinct from the heart's binary accent fill). The popover opens `left-0` (the trigger pins to the tile's top-left) instead of the lightbox's `right-0`
- **The figure stopped being the clip box.** A grid tile's `<figure>` had `overflow-hidden` — which would have clipped the menu's `w-60` popover at a short landscape tile's bottom edge. `overflow-hidden rounded-md` moved to the **inner image `<div>`** (it still clips the `<img>` + video chrome + shimmer); the figure keeps only `relative group cursor-pointer`. The figure **must** stay the grid's direct column child — `.gallery-grid > figure` in `globals.css` keys `break-inside: avoid` + the inter-tile `margin-bottom` off that exact selector, so swapping the root to a `<div>` would have collapsed the masonry layout. `<figcaption>` also stays a direct figure child (assistive-tech association) and gains `rounded-b-md` so its gradient follows the image div's corners now that the figure no longer rounds
- + 3 more in the full log
landing /: pointer-following sheen/glare on the hero anchor print (1-in-4 visual wake — continues the landing-hero rework)
- One feature commit pushed (`HeroShowcase.tsx`) + this log commit. The 1-in-4 visual reserve flagged by the 16:45 carry-forward (last visual 16:30 — within budget but due). Continues the multi-wake landing-hero rework (13:00 two-column → 13:30 staggered entrance → 16:30 pointer-tilt parallax → this) and the exact "hero glare/sheen following the pointer" candidate the last two wake-logs named
- **Pointer-following sheen on the anchor frame.** The anchor portrait is the one the eye lands on, so it now carries a soft glare that tracks the cursor — a real photographic print catches gallery light as you move past it. A white radial highlight (`radial-gradient(40% 40% at X% Y%, rgba(255,255,255,0.55), transparent 72%)`) lives inside the anchor's image box, its centre set to the pointer's clamped 0–100% position across the plane. Composited with `mix-blend-mode: soft-light` so it lifts midtones gently rather than washing the dark wedding frame flat
- **One batched DOM pass with the tilt.** The same `requestAnimationFrame` write in `handleMove` that sets the plane's `rotateX/rotateY` now also sets the sheen's `background` (gradient centre) + `opacity` — zero extra re-renders, zero second rAF. The sheen centre uses the same clamped `±0.5` pointer input as the tilt, so a grazing pointer keeps the glare pinned to an edge instead of off-frame
- + 4 more in the full log
Named favorite lists — Wake D: photographer picks page surfaces list membership (functional wake — closes the named-lists workstream's photographer-visibility carry-forward)
- Two feature commits pushed (`c01590e` picks page, `a1fd9df` CSV export) + this log commit. Wake D — flagged as a carry-forward by every named-lists wake since Wake C (16:00). The client side is complete end-to-end (create → add → view, Wakes A–C2); the photographer had **zero visibility** into how clients were carving their picks into named lists. A photographer triaging a wedding wants to read "the bride filed these 40 frames into 'For the album' and these 12 into 'Maybe'" — not just an undifferentiated heart count
- **Per-card list chips on `/studio/galleries/[id]/picks`.** Each pick card now renders, below the filename, the named client lists the photo was filed into ("For the album", "Maybe", …) as small accent pills led by a `ListChecks` glyph. Lists are per-client-session, so the same name recurs across clients — the query groups by list *name* with a `COUNT(DISTINCT l.id)` multiplicity, rendered as a `×N` suffix ("For the album ×2" = two clients filed it there). First two names show as pills; any remainder collapses to a `+N` with the overflow names in its `title`. Chips render *only* on filed photos, so a plain hearted-but-unfiled pick (the common case) keeps the card uncluttered
- **One membership query, not an N+1.** A single `favorite_list_items ⋈ favorite_lists` query keyed on `photo_id = ANY($1::text[])` over exactly the photo IDs already on the page (covered by `idx_favorite_list_items_photo`), grouped into a `Map<photo_id, {name,count}[]>`. Naturally scoped to the active filename search / on-hidden filter — no extra filter plumbing
- + 4 more in the full log
landing /: pointer-tilt 3D parallax pass on the hero print-stack (1-in-4 visual wake — continues the landing-hero rework)
- One feature commit pushed (`5e00385`, `HeroShowcase.tsx`) + this log commit. The 1-in-4 visual reserve flagged by the last four functional wakes' carry-forwards (last visual 15:45). Continues the multi-wake landing-hero rework (13:00 two-column layout → 13:30 staggered entrance → this) — the hero print-stack was static after its entrance settled; a photographer judges a gallery tool on whether it *feels* physical in the first 200ms, so the stack now responds to the pointer like a real object you can lean
- **Whole-stack 3D tilt on a fine pointer.** The `aspect-[5/6]` plane carries a `rotateX/rotateY` driven by cursor position (recentred to ±0.5 across the box, capped at ±7° — deliberately barely-there, premium not toy card-flip). The parent wrapper supplies `perspective: 1100px`. Cursor right → the stack faces the cursor; cursor down → the top edge tips away. Pointer input is clamped so grazing just outside the box can't overshoot the cap
- **Genuine per-frame depth parallax, not a flat rotate.** Each frame sits at its own `translateZ` inside a `preserve-3d` chain — anchor pushed `+24px` forward, bottom `-14px`, top `-28px` recessed — so under the tilt the front portrait swings visibly further than the back two prints. A dedicated `depth` `<div>` owns the static `translateZ` so it never collides with the positioning `<div>`'s animated `animate-fade-up` entrance transform or the `<figure>`'s static+hover `rotate` (each layer owns exactly one transform — no clobbering)
- + 4 more in the full log
Named favorite lists — Wake C2: client-facing list switcher (functional wake — closes the client-side named-lists experience end-to-end)
- One feature commit pushed (`8293a6b`, +216/−40 on `GalleryView.tsx`) + this log commit. Wake C2 of the named-favorite-lists workstream — the *view* side of the feature. Wake C shipped the lightbox "Add to list" menu (a client can file picks into named lists); this wake lets them **view one list in isolation**. Pixieset/Pic-Time parity: once picks are carved into "For the album" / "For prints" / "Maybe", the client needs to actually pull up one collection on its own
- **Named-list switcher chip row.** A new `container-wide` section above the grid (after the People row), rendered only once the client has ≥1 named list. An eyebrow "Your lists (N)" with a `ListFilter` glyph, then a horizontally-scrollable chip row: a leading **"All photos"** chip (count = full gallery, active when no list is selected, so one chip is always lit) + one chip per list carrying the list name (truncated, `max-w-[14rem]`) and a count. Active chip = accent-fill + white text, the established button idiom shared with the section jump-nav / Compare / FAB. `role="group"` + `aria-pressed` per chip
- **`selectedListId` state drives a new filter inside `visiblePhotos`.** Narrows the grid + lightbox + keyboard nav + slideshow to the list's membership (built from `favLists[].photoIds`), preserving the active sort. Section banding is skipped while a list filter is on (a list spans sections — banded slivers would read as noise), so the jump-nav collapses. The `favLists`/`favListError` state was hoisted above the `visiblePhotos` memo so the memo can read membership
- + 5 more in the full log
Named favorite lists — Wake C: lightbox "Add to list" menu (functional wake — first client-facing surface of the named-lists workstream)
- One feature commit pushed + this log commit. Wake C of the named-favorite-lists workstream — the first surface a client actually *touches*. Wakes A/B shipped migration 009 + the `src/lib/favorite-lists.ts` data layer + the `/api/favorite-lists` (+ `/items`) routes; this wake spends them. Pixieset/Pic-Time parity: a client carves their picks into named collections ("For the album", "For prints", "Maybe") instead of one undifferentiated heart
- **`AddToListMenu`** — a new self-contained popover component in `GalleryView.tsx`, slotted into the lightbox action rail between the heart and the info button (`ListPlus` trigger icon). Renders every named list as a checkbox-style row: an accent-filled square with a `Check` glyph when the current photo is in that list, the list name, and a per-list photo count. An inline "New list…" affordance expands to a name input (Enter or Add to submit, 60-char cap) — creating a list files the current photo into it immediately so the action is never two steps. A count badge on the trigger shows how many lists already hold the photo. Closes on outside-click / Escape (Escape first backs out of the create input, then the menu); the menu's Escape handler is capture-phase + `stopPropagation` so it doesn't also close the lightbox
- **State lifted to `GalleryView`.** `favLists` loads from `GET /api/favorite-lists?gallery_id=` once on mount (maps `photo_ids` → `photoIds`). `createFavList` resolves to the new list so the menu can chain an add; `togglePhotoInList` does an optimistic membership flip with full rollback if the server rejects — same idiom as the existing `toggleFav`. A transient `favListError` toast (self-clears after 3s, `z-[70]` so it reads above the open lightbox) surfaces create/membership failures, mirroring the pick-limit toast
- + 3 more in the full log
Gallery viewer: themed picks empty-state illustration (1-in-4 visual wake — closes the last bare-text empty state on the showpiece)
- One feature commit pushed (`e3c46c8`) + this log commit. The visual reserve flagged by the last two carry-forwards (last design wake 14:45) — the client `/g/[slug]` viewer's "No picks in the current view" state was a single line of muted text, the last un-illustrated empty state in the product *and* it sits on the showpiece surface
- **`src/components/illustrations/GalleryPicksEmptyIllustration.tsx`** — a themeable sibling of the studio-side `EmptyPicksIllustration`. The studio illustration set keys off `var(--color-*)` design tokens; the client viewer is painted in each studio's own brand colours instead, so those tokens don't apply there. This variant takes the gallery `accent` as a prop and inherits all of its line work from `currentColor` (the empty-state wrapper sets `color` to the viewer's muted ink), so the *one* asset reads correctly on a dark **or** light gallery theme — no second asset, no per-theme branch
- **Visual grammar held to the family** so the studio + viewer illustrations still read as one product: three hearts rising along a dashed arc, a crescent-moon anchor (Selene = Greek moon goddess), a subliminal horizon line, accent dust. Heart geometry is a single shared `HEART_PATH` const reused by the accent-filled hero heart + the two flanking outline hearts
- + 3 more in the full log
Named favorite lists — Wake B: client-facing API routes (`/api/favorite-lists` + `/items`) (functional wake — continues the named-favorite-lists workstream)
- One feature commit pushed + this log commit. Wake B of the named-favorite-lists workstream — two thin route handlers over the Wake A data layer (`src/lib/favorite-lists.ts`), so the client viewer (Wake C) has a wire to call
- **`src/app/api/favorite-lists/route.ts`** — `GET ?gallery_id=…` returns this client's lists each carrying ordered photo IDs (`getFavoriteListsWithItems`, no N+1); `POST {gallery_id,name}` creates; `PATCH` does double duty — `{list_id,name}` renames, `{ordered_ids:[]}` reorders; `DELETE {gallery_id,list_id}` deletes
- **`src/app/api/favorite-lists/items/route.ts`** — `POST {gallery_id,list_id,photo_id}` adds a photo to a list, `DELETE` removes it
- + 2 more in the full log
Named favorite lists — Wake A: migration 009 + data layer (read + csid-scoped mutations) (functional wake — opens the last remaining confirmed schema gap)
- Two feature commits pushed (`a49e39b` migration, data layer commit) + this log commit. **Opens named favorite lists** — the single confirmed schema gap the last four wakes' carry-forwards kept naming once gallery sections closed. Pixieset/Pic-Time parity: a client carves their picks into more than one named collection ("For the album", "For prints", "Maybe", "Parents' favourites") instead of one undifferentiated heart
- **Migration 009 (`db/migrations/009_favorite_lists.sql`) — applied to prod.** Two new tables: `favorite_lists` (id, client_session_id FK CASCADE, name, sort_order, created_at) + `favorite_list_items` join table (id, list_id FK CASCADE, photo_id FK CASCADE, created_at, `UNIQUE(list_id, photo_id)`). Three indexes beyond the PKs/unique. **Design: purely additive.** The existing `favorites` table — and every picks / export / analytics query built on it — is untouched; the heart stays the implicit default list. Membership is its *own join table* (not a `list_id` column on `favorites`) precisely so one photo can sit in the heart **and** any number of named lists at once — a true many-to-many. Zero backfill: every existing gallery keeps working. Verified live on prod Supabase — both tables + all 6 indexes present
- **Data layer (`src/lib/favorite-lists.ts`).** Pure data layer, no cookies / no `'use server'` — every function is keyed on a `client_session_id` (csid). Read side: `getFavoriteLists`, `getFavoriteListsWithCounts` (LEFT JOIN item count, empty lists kept), `getFavoriteListsWithItems` (each list carries its ordered photo IDs via grouped `ARRAY_AGG ... FILTER` — one round trip, no N+1), `getFavoriteListItems`
- + 3 more in the full log
/g/[slug] section jump-nav: scrollspy active-chip highlight (1-in-4 visual wake — polishes the just-shipped sections feature)
- Single-file visual wake on `src/app/g/[slug]/GalleryView.tsx` (+~75 net), one feature commit pushed. The section jump-nav shipped last wake (14:30 Wake C `ac7d220`) as a flat row of chips with **no indication of which band the client is currently scrolled into** — Pixieset and Pic-Time both light the active section. This wake closes that gap, the 1-in-4 visual reserve due after three functional wakes (14:30, 14:15, 13:45 — last visual was 13:30)
- **Scrollspy via IntersectionObserver.** New effect observes the band anchor divs; `rootMargin: '-72px 0px -68% 0px'` clears the sticky chip bar and shrinks the observation zone to a strip near the top edge. When two adjacent bands straddle the strip mid-scroll the **lower** one wins (last in document order) — matches the "what am I scrolling *into*" intent. Drives a new `activeBandKey` state. Observer reconnects cleanly when `bands` recomputes (fires its initial callback on `observe`, so the highlight never goes stale)
- **Active chip styling.** The active chip fills with `studio.accent` + white text + dimmed-white count, the established accent-fill button idiom (matches the Compare / FAB buttons); inactive chips keep the subtle wash. `transition` carries a smooth fade between states. `aria-current="true"` on the active chip. Falls back to lighting the first chip until the observer's first callback resolves, so one chip is always lit
- + 4 more in the full log
Gallery sections/sets — Wake C: client viewer renders labelled bands + jump-nav (functional wake — closes the sections item end-to-end)
- One feature commit + this log commit. Picks up the Wake B (`5faeae8`) carry-forward verbatim: the owner could carve a gallery into sections (Wakes A+B) but `/g/[slug]` still rendered one flat grid. This wake makes the client viewer honour them — closing the gallery-sections capability gap fully (Wakes A backend → B owner UI → C client viewer)
- **Plumbing.** `page.tsx` fetches `getGallerySections(gallery.id)` and surfaces `section_id` on every `photosForClient` row (both image + video branches); passes `sections=[{id,name}]` (already in sort order — the page resolves order before the boundary, the viewer never re-sorts). `Photo` type gains `sectionId`; new exported `GallerySection` type
- **Banding folded into `visiblePhotos`, not bolted onto the render.** When the gallery has sections *and* the client hasn't picked a custom sort, the existing `visiblePhotos` memo regroups the filtered list so each section's photos are contiguous — unsectioned first (rank 0), then sections in the owner's order, stable sort so within-band order is untouched. Doing it here (not just in JSX) keeps the lightbox, keyboard nav and slideshow index-locked to what the eye sees, so `openIdx` never drifts. Orphan photos (section deleted out from under them) fold back into the unsectioned band via `?? 0`
- + 3 more in the full log