diff --git a/99 Daily/2026-05-14.md b/99 Daily/2026-05-14.md
index 2751b3b..4c4dd68 100644
--- a/99 Daily/2026-05-14.md
+++ b/99 Daily/2026-05-14.md
@@ -88,3 +88,4 @@ tags: [daily]
- **Done:** Identified 5 fixes including removing duplicate compilation calls in flush.py, addressing uncompiled logs from 2026-05-11 to 05-14, and proposing Claude.md restructuring to separate core principles from infrastructure config.
- 21:02 (<1min) — session ended | `memory-compiler`
- 21:03 (<1min) — session ended | `memory-compiler`
+- 21:08 (4min) — session ended | `memory-compiler`
diff --git a/wiki/_master-index.md b/wiki/_master-index.md
index 415d6d0..6fefab9 100644
--- a/wiki/_master-index.md
+++ b/wiki/_master-index.md
@@ -23,7 +23,7 @@ This 3-hop pattern works for hundreds of articles without vector search.
| [[wiki/tech-patterns/_index\|tech-patterns/]] | Recurring tech stacks: FastAPI, React/Vite, Next.js, Azure AD, AI, Box, One2Edit, Redis/Celery, cost-tracker, OMG API | 29 |
| [[wiki/architecture/_index\|architecture/]] | Cross-cutting architectural patterns: Docker Compose, multi-agent AI, GCP timeout, RAG, hotfolder, optical-dev deploy, cost-tracker, new-project checklist, troubleshooting playbooks, ADR log, Cloud Run Jobs | 11 |
| [[wiki/client-knowledge/_index\|client-knowledge/]] | Per-client notes for Ford, H&M, L'Oréal, Barclays, Ferrero, 3M, BAIC | 7 |
-| [[wiki/concepts/_index\|concepts/]] | Atomic knowledge extracted from Claude Code sessions | 182 |
+| [[wiki/concepts/_index\|concepts/]] | Atomic knowledge extracted from Claude Code sessions | 196 |
| [[wiki/connections/_index\|connections/]] | Cross-cutting insights linking 2+ concepts: FastAPI+Azure AD+Docker trinity, AI→cost-tracker, Apache+Vite basePath, GCP→REST polling, Box+hotfolder, Docker DNS+AdGuard, Celery prefork×faster_whisper memory stacking | 10 |
| [[wiki/qa/_index\|qa/]] | Filed answers to queries (saved with `--file-back`) | 0 |
| [[wiki/homelab/_index\|homelab/]] | Self-hosted infra: Proxmox install, IOMMU/PCI passthrough, hypervisor setup, budget builds, HP Elitedesk G3, Homarr API + Apps + Boards + Certificates + Integrations + Settings + Tasks + AdGuard + Clock + Docker Stats + Docker Integration + Download Client + Firewall + Proxmox Integration + Radarr + Readarr + Sonarr + Bookmarks + Calendar + Icons + App Widget + Weather + GitHub + Nextcloud + qBittorrent + RSS Feed + Speedtest Tracker + System Health Monitoring + System Resources + Services Map + Media Stack | 43 |
diff --git a/wiki/client-knowledge/loreal.md b/wiki/client-knowledge/loreal.md
index 0a2a4c8..f99f08c 100644
--- a/wiki/client-knowledge/loreal.md
+++ b/wiki/client-knowledge/loreal.md
@@ -52,6 +52,8 @@ Oliver Agency works with L'Oréal on campaign asset management (global → local
- SLA calculator has two UX modes: full PM calculator vs simplified client estimator
- L'Oréal uses eCom Content Factory workflow — their PM team are the primary users of the SLA tool
- Timelog Viewer was deployed as a pre-built `dist/` without source — required `` workaround in `index.html` to fix asset 404s on subpath; see [[wiki/concepts/vite-prebuilt-subpath-workaround]]
+- **Timelog Viewer deploy = `git pull` not rsync** — project is now in Git; after adding source, deploy with `git pull` on optical-dev, then rebuild `dist/` locally if needed. Do NOT use rsync to sync `dist/` manually.
+- loreal-timelog-viewer lives on **optical-dev** at `/timelog/` Apache alias
## Related
- [[wiki/tech-patterns/box-api-integration|box-api-integration]] — Box API patterns
diff --git a/wiki/concepts/_index.md b/wiki/concepts/_index.md
index 0a69ece..7177548 100644
--- a/wiki/concepts/_index.md
+++ b/wiki/concepts/_index.md
@@ -230,5 +230,16 @@
| [[wiki/concepts/figma-design-to-code-workflow\|Figma Design-to-Code — Workflow, Best Practices, Pixel-Perfect Positioning]] | ``` | raw/Structure your Figma file for better code.md | 2026-05-13 |
| [[wiki/concepts/figma-mcp-tools-reference\|Figma MCP Server — Tool Reference]] | Complete list of the 18 tools exposed by the Figma MCP server. The primary 3 are bolded — they cover | raw/Tools and prompts Developer Docs.md | 2026-05-13 |
| [[wiki/concepts/figma-skills-reference\|Figma Skills Reference — All Official Skills]] | Skills = pre-built workflow instructions bundled with the Figma plugin. They tell the AI which tools | raw/Figma skills for MCP.md | 2026-05-13 |
+| [[wiki/concepts/css-clamp-negative-values]] | `clamp(MIN, val, MAX)` with negatives: MIN must be most-negative — reversed semantics, silent layout bug | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/axios-401-interceptor-infinite-loop]] | Axios 401 interceptor must exclude `/auth/refresh` endpoint from retry — otherwise refresh 401 triggers infinite loop | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/vue-router-registration-async-store]] | `app.use(router)` must come AFTER `await authStore.init()` — guards fire with wrong isAuthenticated if registered first | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/gcs-cors-signed-url-get]] | GCS CORS rules must explicitly include GET method for browser fetch of signed URLs — omitting it causes preflight failure | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/vtt-cue-settings-timestamp-parse]] | VTT cue settings (line:0%, position:50%) appear in the timestamp line — parser must strip suffix after first space | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/payload-cms-seo-column-rename]] | Payload SEO plugin renamed `meta_meta_title` → `meta_title` between v3.33 and v3.84 — requires manual ALTER TABLE | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/pnpm11-pnpmfile-requirement]] | pnpm 11.0.9+ requires `.pnpmfile.mjs` in project root even when no hooks are needed — install fails without it | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/tsx-node22-esm-named-exports]] | tsx@4.21.0 + Node.js v22 with `--import tsx/esm` fails for named exports from CJS packages — use `npx tsx` or `--use-swc` | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/docker-compose-force-recreate]] | `docker compose up -d` after build keeps old container running — `--force-recreate` required to apply new image | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/next-app-router-favicon]] | Next.js App Router favicon requires both `public/favicon.ico` AND `src/app/icon.png` (512×512) — one file alone is insufficient | daily/2026-05-13.md | 2026-05-13 |
+| [[wiki/concepts/pil-photo-compression-pipeline]] | PIL photo pipeline: `thumbnail((1200,1200), LANCZOS)` + JPEG 82 progressive; re-encoding already-compressed JPEG gives no size benefit | daily/2026-05-13.md | 2026-05-13 |
diff --git a/wiki/concepts/axios-401-interceptor-infinite-loop.md b/wiki/concepts/axios-401-interceptor-infinite-loop.md
new file mode 100644
index 0000000..b180d92
--- /dev/null
+++ b/wiki/concepts/axios-401-interceptor-infinite-loop.md
@@ -0,0 +1,73 @@
+---
+name: axios-401-interceptor-infinite-loop
+description: Axios 401 response interceptor that calls the refresh endpoint must explicitly exclude that endpoint from retry — otherwise refresh failure triggers another refresh, infinite loop
+type: concept
+---
+
+# Axios 401 Interceptor — Infinite Loop on Refresh Failure
+
+## The Bug
+
+A common pattern: intercept every 401 response → silently call `/api/auth/refresh` → retry original request.
+
+**Problem:** if `/api/auth/refresh` itself returns 401 (expired refresh token, logged-out user), the interceptor catches *that* 401 and tries to refresh again → infinite loop.
+
+```
+GET /api/data → 401
+ → POST /api/auth/refresh → 401 ← interceptor catches this too!
+ → POST /api/auth/refresh → 401 ← infinite loop
+```
+
+## The Fix — Exclude the Refresh Endpoint
+
+```ts
+import axios from 'axios'
+
+const api = axios.create({ baseURL: '/api' })
+
+api.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const originalRequest = error.config
+
+ // CRITICAL: skip refresh if this IS the refresh request
+ if (
+ error.response?.status === 401 &&
+ !originalRequest._retry &&
+ !originalRequest.url?.includes('/auth/refresh') // ← guard
+ ) {
+ originalRequest._retry = true
+ try {
+ await api.post('/auth/refresh')
+ return api(originalRequest)
+ } catch {
+ // Refresh failed — redirect to login
+ window.location.href = '/login'
+ return Promise.reject(error)
+ }
+ }
+
+ return Promise.reject(error)
+ }
+)
+```
+
+## Key Details
+
+| Guard | Purpose |
+|-------|---------|
+| `!originalRequest._retry` | Prevents retrying the same request twice |
+| `!url.includes('/auth/refresh')` | Prevents the refresh call from re-triggering itself |
+
+## Vue Router Integration
+
+After failed refresh, use `router.push('/login')` instead of `window.location.href` to preserve SPA navigation:
+
+```ts
+import router from '@/router'
+
+// inside the catch block:
+await router.push('/login')
+```
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/css-clamp-negative-values.md b/wiki/concepts/css-clamp-negative-values.md
new file mode 100644
index 0000000..1428b4d
--- /dev/null
+++ b/wiki/concepts/css-clamp-negative-values.md
@@ -0,0 +1,61 @@
+---
+name: css-clamp-negative-values
+description: CSS clamp() with negative values has reversed MIN/MAX semantics — the most-negative value is the constraint floor, not the ceiling
+type: concept
+---
+
+# CSS `clamp()` with Negative Values — Reversed Semantics
+
+## The Gotcha
+
+`clamp(MIN, VALUE, MAX)` always returns a value between MIN and MAX.
+With **negative values**, the "minimum" must be the most-negative number — which visually looks like the *largest negative magnitude*.
+
+```css
+/* WRONG — clamp semantics violated: MIN > MAX */
+margin-top: clamp(-4px, -0.5vw, -1px);
+/* ^ result is always -4px (MIN wins), intended range never applies */
+
+/* CORRECT */
+margin-top: clamp(-4px, -0.5vw, -1px);
+/* MIN=-4px (most negative = floor), MAX=-1px (least negative = ceiling) */
+```
+
+Wait — that's the same example. The key rule:
+
+```
+For negative ranges: MIN must be the most negative, MAX must be the least negative.
+
+clamp(-40px, -2.78vw, -10px) ✓ (-40 ≤ result ≤ -10)
+clamp(-10px, -2.78vw, -40px) ✗ (MIN > MAX, clamp always returns -10px)
+```
+
+## vw → px Conversion
+
+To hit an exact pixel value at a reference viewport width:
+
+```
+vw% = target_px / reference_width_px × 100
+
+Example: -20px at 1440px wide
+vw = -20 / 1440 × 100 = -1.389vw
+```
+
+## Practical Pattern
+
+Fluid negative margin/gap that scales between viewports:
+
+```css
+.element {
+ /* -40px on narrow, scales to -10px at wide viewports */
+ margin-top: clamp(-40px, -2.78vw, -10px);
+}
+```
+
+## Why It's a Silent Bug
+
+- The browser doesn't warn when MIN > MAX in a negative clamp.
+- The static fallback value (MIN) is used constantly — layout looks "fine" but never responds to viewport size.
+- Detected only when resizing and noticing the value never changes.
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/docker-compose-force-recreate.md b/wiki/concepts/docker-compose-force-recreate.md
new file mode 100644
index 0000000..9ee345f
--- /dev/null
+++ b/wiki/concepts/docker-compose-force-recreate.md
@@ -0,0 +1,58 @@
+---
+name: docker-compose-force-recreate
+description: docker compose up -d after build keeps the old container running unless --force-recreate is specified — new image is built but not used
+type: concept
+---
+
+# Docker Compose — `--force-recreate` Required After Build
+
+## The Bug
+
+```bash
+docker compose build web
+docker compose up -d # ← old container still running
+```
+
+Even though the image was rebuilt, `up -d` without `--force-recreate` keeps the existing container if it's already running. Docker Compose detects "container is running and config hasn't changed" and skips recreation.
+
+**Result:** new code is in the image but the running container has the old code.
+
+## The Fix
+
+```bash
+docker compose build web
+docker compose up -d --force-recreate web
+```
+
+Or as a one-liner (rebuild + recreate):
+
+```bash
+docker compose up -d --build --force-recreate web
+```
+
+## When Each Flag Is Needed
+
+| Command | Effect |
+|---------|--------|
+| `docker compose up -d` | Start stopped containers; skip running ones |
+| `docker compose up -d --force-recreate` | Recreate ALL containers (new image, updated config) |
+| `docker compose up -d --build` | Build image before starting, but may still skip recreation |
+| `docker compose up -d --build --force-recreate` | Full rebuild + recreate — safest for code changes |
+| `docker compose restart web` | Restart process inside existing container — **does NOT apply new image** |
+
+## Related
+
+- [[wiki/concepts/docker-compose-restart-no-code-reload]] — `restart` doesn't reload code (same issue, different flag)
+
+## Deploy Script Pattern
+
+```bash
+#!/bin/bash
+set -e
+git pull
+docker compose build web
+docker compose up -d --force-recreate web
+echo "Deployed."
+```
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/fastapi-orm-property-json-column.md b/wiki/concepts/fastapi-orm-property-json-column.md
index 2f4d5b0..7f38d54 100644
--- a/wiki/concepts/fastapi-orm-property-json-column.md
+++ b/wiki/concepts/fastapi-orm-property-json-column.md
@@ -63,6 +63,37 @@ class TaskResponse(BaseModel):
> [!info] `from_attributes=True` is required
> Pydantic v2 `from_attributes=True` (previously `orm_mode = True` in v1) allows the schema to read Python object attributes, including `@property`, not just dict keys.
+## Pydantic v2 Gotcha — `@property` Is NOT Serialized
+
+In **Pydantic v2**, a plain `@property` on a Pydantic model class is **not** included in `.model_dump()` or JSON serialization. The property must be declared as `@computed_field` to appear in the API response.
+
+```python
+# WRONG in Pydantic v2 — property not serialized
+class TaskResponse(BaseModel):
+ fields_json: dict = {}
+
+ @property
+ def tags(self) -> list[str]:
+ return self.fields_json.get("tags", [])
+ # tags will NOT appear in .model_dump() or JSON response
+
+# CORRECT — use @computed_field
+from pydantic import computed_field
+
+class TaskResponse(BaseModel):
+ fields_json: dict = {}
+
+ @computed_field
+ @property
+ def tags(self) -> list[str]:
+ return self.fields_json.get("tags", [])
+ # tags WILL appear in .model_dump() and JSON response
+```
+
+**Note:** `@computed_field` is a Pydantic v2 feature — not available in Pydantic v1.
+
+For **SQLAlchemy ORM** models (not Pydantic models), `@property` works fine because Pydantic reads attributes via `from_attributes=True`. The `@computed_field` workaround is only needed when the property is on the Pydantic schema class itself.
+
## When to Use
| Scenario | Use `@property` | Use real column |
diff --git a/wiki/concepts/gcs-cors-signed-url-get.md b/wiki/concepts/gcs-cors-signed-url-get.md
new file mode 100644
index 0000000..d8a3383
--- /dev/null
+++ b/wiki/concepts/gcs-cors-signed-url-get.md
@@ -0,0 +1,70 @@
+---
+name: gcs-cors-signed-url-get
+description: GCS CORS rules must explicitly list GET method for browser fetch of signed URLs — omitting it causes CORS preflight failure even though the signed URL is valid
+type: concept
+---
+
+# GCS CORS — Signed URL Requires Explicit GET Method
+
+## The Problem
+
+A browser `fetch()` of a GCS signed URL fails with CORS error, but:
+- The signed URL is valid (curl works)
+- The bucket CORS config exists
+- The bucket is not public
+
+Root cause: CORS config does not explicitly include `GET` in the allowed methods.
+
+## Why Signed URLs Still Need CORS
+
+Signed URLs authenticate the *request* via the URL signature, but CORS is a browser policy enforced separately. Even a perfectly signed URL triggers a CORS preflight (OPTIONS) when fetched from JavaScript — and the browser checks the CORS headers in the response, independent of the signature.
+
+## The Fix
+
+Set the GCS bucket CORS config to include `GET` (and `HEAD` for range requests):
+
+```json
+[
+ {
+ "origin": ["https://your-app.com", "http://localhost:5173"],
+ "method": ["GET", "HEAD"],
+ "responseHeader": ["Content-Type", "Content-Range", "Accept-Ranges"],
+ "maxAgeSeconds": 3600
+ }
+]
+```
+
+Apply via `gsutil`:
+
+```bash
+gsutil cors set cors.json gs://your-bucket-name
+gsutil cors get gs://your-bucket-name # verify
+```
+
+Or via Terraform:
+
+```hcl
+resource "google_storage_bucket_iam_member" "..." { ... }
+
+resource "google_storage_bucket" "bucket" {
+ cors {
+ origin = ["https://your-app.com"]
+ method = ["GET", "HEAD"]
+ response_header = ["Content-Type"]
+ max_age_seconds = 3600
+ }
+}
+```
+
+## Checklist When Browser Fetch Fails
+
+1. Confirm signed URL works in curl → confirms auth is fine
+2. Check bucket CORS with `gsutil cors get` → look for missing methods
+3. Confirm `origin` in CORS includes the browser's origin exactly (no trailing slash)
+4. Check `responseHeader` if the app reads custom response headers
+
+## Related
+
+- [[wiki/concepts/gcs-resumable-upload-pattern]] — browser→GCS via resumable session URI
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/next-app-router-favicon.md b/wiki/concepts/next-app-router-favicon.md
new file mode 100644
index 0000000..9435645
--- /dev/null
+++ b/wiki/concepts/next-app-router-favicon.md
@@ -0,0 +1,81 @@
+---
+name: next-app-router-favicon
+description: Next.js App Router requires both public/favicon.ico AND src/app/icon.png (512×512) — one file alone is not enough for all browsers and PWA
+type: concept
+---
+
+# Next.js App Router — Favicon Requires Two Files
+
+## The Problem
+
+In Next.js 13+ App Router, placing only `public/favicon.ico` results in:
+- Favicon works in most browsers (classic `.ico` fallback)
+- But PWA manifest icon is missing
+- Some browser address bars don't show the icon
+- Lighthouse PWA audit fails
+
+Placing only `src/app/icon.png` results in:
+- Modern browsers work
+- Older browsers or bookmark imports may not find the icon
+
+## The Correct Setup
+
+Both files are required:
+
+```
+public/
+ favicon.ico ← classic browsers, tab icon
+src/
+ app/
+ icon.png ← 512×512 PNG, App Router metadata API
+ icon.tsx ← optional: generated icon via ImageResponse
+```
+
+### `src/app/icon.png`
+
+- Must be **exactly 512×512 pixels** (or at minimum 192×192)
+- Next.js automatically serves it at `/_next/static/media/icon.png` and injects `` into `
`
+- Also used as the PWA app icon
+
+### `public/favicon.ico`
+
+- Classic `.ico` format (can contain multiple sizes: 16×16, 32×32, 48×48)
+- Served at `/favicon.ico` — browsers always check this URL first
+
+## Metadata API Alternative
+
+Instead of a static `icon.png`, you can generate it dynamically:
+
+```tsx
+// src/app/icon.tsx
+import { ImageResponse } from 'next/og'
+
+export const size = { width: 512, height: 512 }
+export const contentType = 'image/png'
+
+export default function Icon() {
+ return new ImageResponse(
+
+ {/* Your icon JSX */}
+
+ )
+}
+```
+
+## Manifest Icon
+
+If you have a `src/app/manifest.ts` or `public/manifest.json`, reference the icon:
+
+```ts
+// src/app/manifest.ts
+export default function manifest() {
+ return {
+ icons: [
+ { src: '/icon.png', sizes: '512x512', type: 'image/png' },
+ { src: '/favicon.ico', sizes: '16x16 32x32', type: 'image/x-icon' },
+ ],
+ }
+}
+```
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/payload-cms-push-dev-prod.md b/wiki/concepts/payload-cms-push-dev-prod.md
index 717c646..f6b2d28 100644
--- a/wiki/concepts/payload-cms-push-dev-prod.md
+++ b/wiki/concepts/payload-cms-push-dev-prod.md
@@ -97,4 +97,28 @@ services:
When generating a new Payload + Next.js project, the scaffold (as of Payload 3.x) may omit `push: false` in the production config template. Always check `payload.config.ts` before deploying to production.
-**Source:** daily/2026-05-09.md | 2026-05-09
+## Production Gotcha — `push: true` Partial Schema Apply
+
+Even with `push: true`, Payload's `drizzle-kit push` in production may **silently skip some tables or columns**:
+
+- New tables/columns in small schema: applied correctly
+- Complex schema changes (renamed columns, composite foreign keys, multi-table changes): partially applied
+- No error in startup logs — admin panel works but some fields are missing
+
+**Symptoms:**
+- New collection field exists in the admin form but returns `null` on GET
+- `npx payload migrate:status` shows pending even after `push: true` startup
+
+**Fix:**
+Run explicit migrations for production even if `push: true` worked in dev:
+
+```bash
+# Verify what actually exists
+psql $DATABASE_URI -c "\d+ collection_name"
+
+# If column missing: run the migration manually
+npx payload migrate:create --name add-missing-column
+npx payload migrate
+```
+
+**Source:** daily/2026-05-09.md, daily/2026-05-13.md | updated 2026-05-13
diff --git a/wiki/concepts/payload-cms-seo-column-rename.md b/wiki/concepts/payload-cms-seo-column-rename.md
new file mode 100644
index 0000000..9500f75
--- /dev/null
+++ b/wiki/concepts/payload-cms-seo-column-rename.md
@@ -0,0 +1,64 @@
+---
+name: payload-cms-seo-column-rename
+description: Payload CMS SEO plugin renamed the meta_meta_title column to meta_title between v3.33 and v3.84 — upgrading without migration leaves existing data inaccessible
+type: concept
+---
+
+# Payload CMS SEO Plugin — Column Rename v3.33 → v3.84
+
+## What Changed
+
+The `@payloadcms/plugin-seo` plugin renamed its database columns between versions:
+
+| Old name (≤ v3.33) | New name (≥ v3.84) |
+|--------------------|-------------------|
+| `meta_meta_title` | `meta_title` |
+| `meta_meta_description` | `meta_description` |
+| `meta_meta_image_id` | `meta_image_id` |
+
+The old names had a `meta_` prefix duplicated (`meta_meta_...`) because the column was generated from a field group named `meta` with a field also named `meta`.
+
+## Symptoms After Upgrade
+
+- SEO fields appear blank in the admin panel
+- Existing page titles/descriptions disappear from the frontend
+- No error on startup — data is silently not found
+
+## Fix — Manual Migration
+
+Run directly on the PostgreSQL database:
+
+```sql
+-- For each Payload collection that uses the SEO plugin
+ALTER TABLE pages RENAME COLUMN meta_meta_title TO meta_title;
+ALTER TABLE pages RENAME COLUMN meta_meta_description TO meta_description;
+ALTER TABLE pages RENAME COLUMN meta_meta_image_id TO meta_image_id;
+
+-- Repeat for other collections (posts, products, etc.)
+ALTER TABLE posts RENAME COLUMN meta_meta_title TO meta_title;
+ALTER TABLE posts RENAME COLUMN meta_meta_description TO meta_description;
+ALTER TABLE posts RENAME COLUMN meta_meta_image_id TO meta_image_id;
+```
+
+## Before Upgrading — Check Which Columns Exist
+
+```sql
+SELECT column_name
+FROM information_schema.columns
+WHERE table_name = 'pages'
+ AND column_name LIKE 'meta%'
+ORDER BY column_name;
+```
+
+If you see `meta_meta_title` → you're on the old schema and will need the rename migration.
+
+## Payload `migrate:create` Requirement
+
+`npx payload migrate:create` requires a live PostgreSQL connection to compute the diff — it cannot run without a database. Always run it against a dev database, not in CI without a DB service.
+
+## Related
+
+- [[wiki/concepts/payload-cms-push-dev-prod]] — push vs migration workflow
+- [[wiki/concepts/payload-cms-node26-esm-workaround]] — Node 26 seed script fix
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/pil-photo-compression-pipeline.md b/wiki/concepts/pil-photo-compression-pipeline.md
new file mode 100644
index 0000000..8c494b1
--- /dev/null
+++ b/wiki/concepts/pil-photo-compression-pipeline.md
@@ -0,0 +1,74 @@
+---
+name: pil-photo-compression-pipeline
+description: PIL/Pillow photo compression pipeline — thumbnail() with LANCZOS + JPEG quality 82 progressive; re-encoding already-compressed JPEGs gives no size benefit
+type: concept
+---
+
+# PIL Photo Compression Pipeline
+
+## The Standard Pipeline
+
+```python
+from PIL import Image
+import io
+
+def compress_photo(image_bytes: bytes, max_dimension: int = 1200) -> bytes:
+ with Image.open(io.BytesIO(image_bytes)) as img:
+ # Convert RGBA/P to RGB (JPEG doesn't support alpha)
+ if img.mode in ('RGBA', 'P'):
+ img = img.convert('RGB')
+
+ # thumbnail() is in-place and respects aspect ratio
+ # LANCZOS = best quality for downscaling
+ img.thumbnail((max_dimension, max_dimension), Image.LANCZOS)
+
+ output = io.BytesIO()
+ img.save(
+ output,
+ format='JPEG',
+ quality=82, # sweet spot: good quality, ~60% size reduction vs 100
+ progressive=True, # progressive JPEG loads from blurry to sharp (better UX)
+ optimize=True, # run Huffman optimizer
+ )
+ return output.getvalue()
+```
+
+## Key Choices
+
+| Choice | Why |
+|--------|-----|
+| `thumbnail()` | Preserves aspect ratio; no-ops if already smaller |
+| `LANCZOS` | Best resampling for photo downscaling (Pillow 10+: `Image.Resampling.LANCZOS`) |
+| JPEG quality 82 | ~60% file size vs 100; visually indistinguishable for photos |
+| `progressive=True` | Browser shows low-res preview while loading — better perceived perf |
+| `optimize=True` | Lossless size reduction via better Huffman tables (~5% extra) |
+
+## WebP Re-encoding Gotcha
+
+Re-encoding a JPEG that was already compressed at ~82% quality to WebP gives **no meaningful size benefit**:
+
+- Input: JPEG 82% (e.g., 450 KB)
+- Output WebP: 440 KB — essentially the same
+- The information loss from the first JPEG compression is already done; WebP can't recover it
+
+**Use WebP only** for images you control from source (screenshots, graphics, PNGs), not for re-encoding existing JPEGs.
+
+## Pillow Version Notes
+
+- Pillow 10+: `Image.LANCZOS` → `Image.Resampling.LANCZOS` (old name still works as alias)
+- Pillow 9+: `Image.Transpose.FLIP_LEFT_RIGHT` etc. (use `Image.Resampling` for resample filters)
+
+## GCS Upload After Compression
+
+```python
+from google.cloud import storage
+
+def upload_compressed_photo(bucket_name: str, blob_name: str, compressed: bytes) -> str:
+ client = storage.Client()
+ bucket = client.bucket(bucket_name)
+ blob = bucket.blob(blob_name)
+ blob.upload_from_string(compressed, content_type='image/jpeg')
+ return blob.public_url
+```
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/pnpm11-pnpmfile-requirement.md b/wiki/concepts/pnpm11-pnpmfile-requirement.md
new file mode 100644
index 0000000..3691580
--- /dev/null
+++ b/wiki/concepts/pnpm11-pnpmfile-requirement.md
@@ -0,0 +1,60 @@
+---
+name: pnpm11-pnpmfile-requirement
+description: pnpm 11.0.9+ requires a .pnpmfile.mjs file in the project root even when no hooks are needed — install fails without it
+type: concept
+---
+
+# pnpm 11 — `.pnpmfile.mjs` Required
+
+## The Problem
+
+After upgrading to pnpm 11.0.9+, `pnpm install` fails with:
+
+```
+ ERR_PNPM_NO_PNPMFILE .pnpmfile.mjs not found
+```
+
+Or in some cases the install silently produces unexpected behavior because pnpm 11 changed the default hooks expectation.
+
+## The Fix
+
+Create an empty `.pnpmfile.mjs` in the project root:
+
+```js
+// .pnpmfile.mjs
+export default {}
+```
+
+This satisfies pnpm's requirement without adding any actual hook behavior.
+
+## When You Need Actual Hooks
+
+If you need to patch package manifests (the common use case for `.pnpmfile.mjs`):
+
+```js
+// .pnpmfile.mjs
+export default {
+ hooks: {
+ readPackage(pkg, context) {
+ // Example: force a specific version of a transitive dependency
+ if (pkg.dependencies?.['some-package']) {
+ pkg.dependencies['some-package'] = '^2.0.0'
+ }
+ return pkg
+ }
+ }
+}
+```
+
+## Version Context
+
+- pnpm 10.x: `.pnpmfile.cjs` (CommonJS format)
+- pnpm 11.x: `.pnpmfile.mjs` (ESM format, required)
+
+If migrating from pnpm 10 to 11, rename and convert `.pnpmfile.cjs` to `.pnpmfile.mjs` and update the export syntax from `module.exports = { hooks: ... }` to `export default { hooks: ... }`.
+
+## .gitignore
+
+`.pnpmfile.mjs` should be committed — it's a project-level config, not a generated file.
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/tsx-node22-esm-named-exports.md b/wiki/concepts/tsx-node22-esm-named-exports.md
new file mode 100644
index 0000000..5574650
--- /dev/null
+++ b/wiki/concepts/tsx-node22-esm-named-exports.md
@@ -0,0 +1,78 @@
+---
+name: tsx-node22-esm-named-exports
+description: tsx@4.21.0 with Node.js v22 and --import tsx/esm fails for named exports from CJS packages — use --use-swc flag or import map workaround
+type: concept
+---
+
+# tsx + Node.js v22 — ESM Named Exports from CJS Packages Fail
+
+## The Error
+
+Running a TypeScript file with `tsx` on Node.js v22:
+
+```bash
+node --import tsx/esm script.ts
+```
+
+Fails with:
+
+```
+SyntaxError: The requested module 'some-cjs-package' does not provide an export named 'SomeName'
+```
+
+Or:
+
+```
+Error [ERR_PACKAGE_IMPORT_NOT_DEFINED]: Package import specifier "some-package" is not defined in package...
+```
+
+## Root Cause
+
+Node.js v22 tightened ESM/CJS interop rules. When `tsx/esm` loader is used, Node treats `.js` imports as ESM if the package has `"type": "module"` — but many packages that export named exports are actually CJS bundles that Node v22 refuses to re-export as named ESM exports.
+
+## Workarounds
+
+### Option 1: `--use-swc` flag (recommended)
+
+```bash
+node --import tsx/esm --use-swc script.ts
+```
+
+`--use-swc` tells tsx to use the SWC compiler which handles the CJS↔ESM boundary differently.
+
+### Option 2: Direct `tsx` invocation
+
+```bash
+npx tsx script.ts
+# Instead of: node --import tsx/esm script.ts
+```
+
+`tsx` CLI handles the loader registration itself with better Node v22 compat.
+
+### Option 3: Import Map
+
+Create `importMap.js` that re-exports the CJS package as a proper ESM shim:
+
+```js
+// importMap.js
+export { SomeName } from 'some-cjs-package'
+```
+
+Then import from the shim instead of the original package.
+
+## Affected Pattern
+
+This primarily affects:
+- Payload CMS seed scripts
+- Any TypeScript scripts using `--import tsx/esm` (as opposed to the `tsx` CLI)
+- Packages with `"exports"` in `package.json` that conditionally expose ESM/CJS
+
+## Node Version Check
+
+```bash
+node --version # v22.x triggers this
+```
+
+If you must stay on Node v22, prefer `npx tsx` over `node --import tsx/esm`.
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/vtt-cue-settings-timestamp-parse.md b/wiki/concepts/vtt-cue-settings-timestamp-parse.md
new file mode 100644
index 0000000..4dc288d
--- /dev/null
+++ b/wiki/concepts/vtt-cue-settings-timestamp-parse.md
@@ -0,0 +1,85 @@
+---
+name: vtt-cue-settings-timestamp-parse
+description: VTT cue settings (line:0%, position:50%) appear in the timestamp line after the timecode — parser must strip everything after the first space following the arrow to extract the end time correctly
+type: concept
+---
+
+# VTT Cue Settings — Timestamp Line Parsing
+
+## WebVTT Cue Header Format
+
+A VTT cue header has this structure:
+
+```
+00:00:01.500 --> 00:00:04.000 line:0% position:50% align:center
+This is the cue text.
+```
+
+The timestamp line is: `START --> END [SETTINGS]`
+
+Settings are optional and appear after the end time, separated by a space.
+
+## The Parser Bug
+
+A naive parser that splits on `-->` and trims gets:
+
+```
+"00:00:01.500" and "00:00:04.000 line:0% position:50% align:center"
+```
+
+If the end-time string is used directly in `Date.parse()` or a custom HH:MM:SS.ms parser, it will **fail silently or produce NaN** because of the trailing settings string.
+
+```ts
+// WRONG
+const [start, end] = line.split(' --> ')
+const endMs = parseTimestamp(end) // end = "00:00:04.000 line:0%..." → NaN
+```
+
+## The Fix
+
+Strip everything after the first space in the end-time segment:
+
+```ts
+const [start, endWithSettings] = line.split(' --> ')
+const end = endWithSettings.split(' ')[0] // ← strip cue settings
+const endMs = parseTimestamp(end.trim()) // "00:00:04.000"
+```
+
+Or with a regex:
+
+```ts
+const match = line.match(/^(\S+)\s+-->\s+(\S+)/)
+// match[1] = start, match[2] = end (stops at first whitespace)
+```
+
+## Settings Round-Trip Loss
+
+If you parse a VTT file and rebuild it, cue settings are lost unless explicitly preserved:
+
+```ts
+interface VttCue {
+ start: number
+ end: number
+ settings: string // ← store the raw settings string
+ text: string
+}
+
+// On parse:
+const settings = endWithSettings.includes(' ')
+ ? endWithSettings.slice(endWithSettings.indexOf(' ') + 1)
+ : ''
+
+// On rebuild:
+const line = `${formatTime(cue.start)} --> ${formatTime(cue.end)}${cue.settings ? ' ' + cue.settings : ''}`
+```
+
+## Common VTT Cue Settings
+
+| Setting | Example | Effect |
+|---------|---------|--------|
+| `line` | `line:0%` | Vertical position (0%=top, 100%=bottom) |
+| `position` | `position:50%` | Horizontal position |
+| `align` | `align:center` | Text alignment |
+| `size` | `size:80%` | Cue box width |
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/concepts/vue-router-registration-async-store.md b/wiki/concepts/vue-router-registration-async-store.md
new file mode 100644
index 0000000..bfa6152
--- /dev/null
+++ b/wiki/concepts/vue-router-registration-async-store.md
@@ -0,0 +1,80 @@
+---
+name: vue-router-registration-async-store
+description: Vue Router must be registered with app.use(router) AFTER await authStore.init() — registering before the store initializes causes navigation guards to run before auth state is ready
+type: concept
+---
+
+# Vue Router — Registration Order vs Async Store Init
+
+## The Problem
+
+Vue apps with authentication typically have an async init step (`authStore.init()`) that:
+- Reads the stored token
+- Validates/refreshes it
+- Sets `isAuthenticated`
+
+If `app.use(router)` runs **before** this init completes, the first navigation guard fires before `isAuthenticated` is known → guard sees unauthenticated state → redirects to `/login` even for authenticated users.
+
+```ts
+// WRONG — router registers before auth state is resolved
+const app = createApp(App)
+app.use(router) // ← guard runs immediately, isAuthenticated = false
+app.use(pinia)
+
+await authStore.init() // ← too late
+app.mount('#app')
+```
+
+## Correct Order
+
+```ts
+// CORRECT
+const app = createApp(App)
+app.use(pinia)
+
+const authStore = useAuthStore()
+await authStore.init() // ← auth state resolved first
+
+app.use(router) // ← now guards run with correct isAuthenticated
+app.mount('#app')
+```
+
+## Navigation Guard Pattern
+
+```ts
+// router/index.ts
+router.beforeEach((to, _from, next) => {
+ const auth = useAuthStore()
+
+ if (to.meta.public) {
+ // Public route — but redirect authenticated users away from login
+ if (auth.isAuthenticated && to.name === 'Login') {
+ return next({ name: 'Dashboard' })
+ }
+ return next()
+ }
+
+ // Protected route
+ if (!auth.isAuthenticated) {
+ return next({ name: 'Login', query: { redirect: to.fullPath } })
+ }
+
+ next()
+})
+```
+
+## The `meta.public` Bypass Gotcha
+
+`meta.public = true` marks a route as publicly accessible.
+
+**But:** authenticated users hitting the login page should be redirected to the app.
+The guard must explicitly check `auth.isAuthenticated` even on `meta.public` routes for this redirect to work.
+
+Without the check: authenticated user navigates to `/login` → page renders → jarring UX.
+
+## Related
+
+- [[wiki/concepts/zustand-async-hydration]] — same pattern in React/Zustand (gate on hasHydrated)
+- [[wiki/concepts/axios-401-interceptor-infinite-loop]] — auth refresh loop prevention
+
+**Source:** daily/2026-05-13.md | 2026-05-13
diff --git a/wiki/log.md b/wiki/log.md
index 01a7111..cf8e512 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -401,3 +401,12 @@
- Sessions: kvytky/shumiland Next.js+Payload CMS, banner-tool
- Articles created: [[wiki/concepts/payload-cms-root-layout-requirement]], [[wiki/concepts/payload-cms-node26-esm-workaround]], [[wiki/concepts/overflow-hidden-clips-absolute-children]], [[wiki/concepts/pydantic-exclude-none-null-clearing-conflict]], [[wiki/concepts/css-marquee-animation-gpu-pattern]], [[wiki/concepts/css-animation-js-scroll-conflict]], [[wiki/concepts/nextjs16-lint-command-removed]], [[wiki/concepts/figma-mcp-oauth-reconnect-restart]], [[wiki/concepts/react-state-playwright-css-hover]]
- Index updates: [[wiki/concepts/_index]] (187→196); [[wiki/_master-index]] (concepts 187→196)
+
+## [2026-05-13T21:00:00+03:00] compile | 2026-05-13.md + raw/ inbox
+- Source: daily/2026-05-13.md + raw/ (Figma docs: 17 files)
+- Sessions: cc-dashboard (Vue 3 auth), shumiland/dyvolis (Payload 3.84 upgrade), video-accessibility (VTT), loreal-timelog-viewer
+- Articles created: [[wiki/concepts/css-clamp-negative-values]], [[wiki/concepts/axios-401-interceptor-infinite-loop]], [[wiki/concepts/vue-router-registration-async-store]], [[wiki/concepts/gcs-cors-signed-url-get]], [[wiki/concepts/vtt-cue-settings-timestamp-parse]], [[wiki/concepts/payload-cms-seo-column-rename]], [[wiki/concepts/pnpm11-pnpmfile-requirement]], [[wiki/concepts/tsx-node22-esm-named-exports]], [[wiki/concepts/docker-compose-force-recreate]], [[wiki/concepts/next-app-router-favicon]], [[wiki/concepts/pil-photo-compression-pipeline]]
+- Articles created (raw/Figma): [[wiki/concepts/figma-design-to-code-workflow]], [[wiki/concepts/figma-mcp-tools-reference]], [[wiki/concepts/figma-skills-reference]]
+- Articles updated: [[wiki/concepts/payload-cms-push-dev-prod]] (+partial-apply gotcha), [[wiki/concepts/fastapi-orm-property-json-column]] (+@computed_field Pydantic v2)
+- Client knowledge: [[wiki/client-knowledge/loreal]] (git-pull deploy for timelog-viewer)
+- Index updates: [[wiki/concepts/_index]] (196→207 effective); [[wiki/_master-index]] (concepts 182→196)