diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3034dc..bfafa17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
All notable changes to Workout Lens are documented here.
+## [1.5.16] — 2026-05-19
+
+### Accessibility
+- **Fix WCAG AA contrast violations across codebase (issue #262)** — systematic audit identified four categories of failures:
+ - **Filled accent backgrounds**: `--accent` (#ee2c80, 3.95:1 vs white — FAIL) was used as `background` with white text on the Home CTA button, MuscleMapConfirm Today/Other-day pills and Save CTA, Settings save button, and Report add-to-library button. Replaced with `--accent-active` (#b5116a, ~6.45:1 vs white — PASS) in all five locations.
+ - **`--accent-soft` text in light mode**: `--accent-soft: #ff7eb6` used as text colour on `--accent-bg-14` backgrounds produces ~1.77:1 in light mode (FAIL). Added `--accent-soft: #b5116a` override to the `[data-theme="g10"]` block in `carbon-tokens.css`; dark mode keeps the existing `#ff7eb6` (~7.7:1 on dark bg — PASS).
+ - **`--exercise` label text on `--exercise-soft` background**: 10px mono label in `OvelsePicker` used `--exercise` (#1a8c4e in light mode) on a pale `--exercise-soft` background (~3.79:1 — FAIL). Changed to `--cds-text-primary` for that label; green identity preserved via border and icon.
+ - **Focus rings suppressed on raw inputs**: inline `outline: none` on custom-styled `` elements in `ExFlyt`, `GruppetimePicker`, `OvelsePicker`, and `Planlegger` overrode the app-level CSS rule. Removed all four inline suppressions and broadened `app.css` to cover `input:not(.cds--text-input):not(.cds--search-input):focus-visible`.
+
## [1.5.15] — 2026-05-19
### Accessibility
diff --git a/CLAUDE.md b/CLAUDE.md
index e5d0c29..fbdf86c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,459 +1,183 @@
# BodyMapTraining — CLAUDE.md
-## Remember this if you are an AI
-**Verification before closing issue**
-All issues must be verified by the developer before you can close them on github. Either on dev, qa or prod (user decides). Regardless of method, AI must wait until user has verified fix to close issues.
-
-**Update docs before every push or PR**
-Before pushing to master or opening a PR, update all three of:
-1. **`CLAUDE.md`** — architecture decisions, component descriptions, utility exports, known pitfalls
-2. **`README.md`** — user-facing feature summary and deployment notes
-3. **`CHANGELOG.md`** — add an entry under the current version (or create one) describing what changed and why
-
-**Issue format**
-All GitHub issues follow this structure:
-- **Title:** `As a [user/developer] I want to [action] so I can [benefit]`
-- **`## Summary`** — one paragraph describing the problem and goal
-- **`## Priority`** — High / Medium / Low (include for developer/infra issues)
-- **`## UI spec (Carbon g100)`** — bullet-point spec for any UI changes (Carbon rules apply)
-- **`## Data model`** — SQL schema snippet for any new or changed tables
-- **`## Acceptance criteria`** — GitHub task-list checkboxes (`- [ ]`) covering all done conditions
-- **`## Out of scope`** — explicit exclusions to prevent scope creep (optional but recommended for larger issues)
+## AI rules
+- **Never close a GitHub issue** without developer verification on dev, QA, or prod.
+- **Before every push or PR**, update `CLAUDE.md`, `README.md`, and `CHANGELOG.md`.
+- **Issue format:** Title: `As a [user] I want to [action] so I can [benefit]`. Sections: Summary, Priority, UI spec (Carbon g100), Data model, Acceptance criteria, Out of scope.
+## Project
+**Workout Lens** — gym instructors photograph a whiteboard workout, Claude Vision identifies muscles trained, app shows a body map + recommendations. Live at [workout.umulig.org](https://workout.umulig.org).
-## Glossary
-
-Canonical definitions for domain terms. When a term is ambiguous in an issue or conversation, refer here — or ask for clarification before implementing.
-
-### People & roles
-
-| Term | Definition |
-|---|---|
-| **User** | The person logged into Workout Lens. A gym instructor employed at a sporty.no gym. Maps to `auth.uid()`, `sessions.trainer_id`, `user_id` across all tables. |
-| **Trainer** | Avoid this term. It is ambiguous — could mean the app user or the gym class instructor. If someone says "trainer" in an issue or conversation, ask: do you mean the app user, or the instructor who led the class? In code, `trainer_id` is a legacy DB column name that refers to the app user. |
-| **Instructor** | The person who *leads* a gym class, sourced from sporty.no. Stored in `gym_calendar.instructor`. Has no account in the app. Example: "Linda Hatlevik." When unqualified, "instructor" always means this — the class leader, not the app user. |
-| **Co-instructor** | Another app user registered at the same gym (`sporty_business_unit_id`). Their sessions are cross-readable via RLS. |
-| **Display name** | A user's visible name in the app. Stored in `profiles.display_name`. Auto-set to email prefix on first login. |
+**Stack:** React 19 + Vite · IBM Carbon Design System · Supabase (magic-link auth + PostgreSQL) · Anthropic Claude API (proxied via Azure Function) · Azure Static Web Apps · GitHub Actions CI/CD · `react-i18next` (nb default, en, fa/RTL)
-### Training concepts
+## Muscle IDs (17)
+```
+chest, shoulders_front, shoulders_side, biceps, forearms, abs, obliques, quads, calves
+traps, rear_delts, lats, triceps, lower_back, glutes, hamstrings, calves_back
+```
+Defined in `MUSCLES` in `app/src/lib/bodymap.js`. Primary = solid green, secondary = blue diagonal hatch.
+## Glossary
| Term | Definition |
|---|---|
-| **Session** | One logged workout. One row in `sessions`. Logged by a user (`trainer_id`). Optionally linked to a gym class (`gym_calendar_id`). |
-| **Gym class** | A scheduled class from sporty.no. Stored in `gym_calendar`. Has a name, instructor, start/end time. Synced by `sportySync.js`. |
-| **Session exercise** | One exercise performed within a session. Stored in `session_exercises`. Has name, sets, reps, and muscle activations. |
-| **Library exercise** | A saved, reusable exercise with a standardised name and default muscle map. Stored in `exercise_library`. Can be referenced by templates. |
-| **Template** | A named, reusable workout skeleton owned by a user. Stored in `session_templates`. Contains ordered template exercises. |
-| **Template exercise** | An exercise slot inside a template. Stored in `session_template_exercises`. Name and muscles are a denormalised snapshot — renaming the library source doesn't affect it. |
-| **Week plan** | An assignment of templates to days of a specific ISO week. Stored in `week_plans` + `week_plan_days`. |
-
-### Muscle concepts
+| **User** | Logged-in gym instructor. Maps to `auth.uid()`, `sessions.trainer_id`. |
+| **Trainer** | Avoid — ambiguous. `trainer_id` in DB = the app user (legacy column name). |
+| **Instructor** | Person who *leads* a gym class (from sporty.no). No app account. |
+| **Co-instructor** | Another app user at the same gym. Can read each other's sessions via RLS. |
+| **Session** | One logged workout row in `sessions`. |
+| **Gym class** | Sporty.no scheduled class in `gym_calendar`, synced by `sportySync.js`. |
+| **Template** | Named reusable workout skeleton in `session_templates`. |
+| **Week plan** | Templates assigned to days of an ISO week (`week_plans` + `week_plan_days`). |
+| **Business unit** | Gym location — `sporty_business_unit_id = 8` (hardcoded). |
+| **Period** | Report filter duration — 7, 30, or 90 days. |
+
+## Carbon design system — hard rules
+- **Sentence case** for all labels
+- **0px border-radius** on buttons/inputs/cards — exceptions: pills `var(--r-pill)`, cards `var(--r-card)`, tiles `var(--r-tile)`
+- **No emoji** — use `@carbon/icons-react` exclusively
+- **IBM Plex everywhere** — no system-font fallbacks
+- **Semantic tokens only** (`var(--cds-*)` or `var(--wl-*)`) — raw hex breaks the theme toggle
+- **No gradients** — solid colors only
+- **Filter chips** — always `flexWrap: wrap`; never `overflowX: auto` without a constrained parent
-| Term | Definition |
-|---|---|
-| **Muscle ID** | One of 17 fixed string keys (e.g. `chest`, `lats`, `quads`). The canonical identifier used in the DB, prompts, and bodymap. Full list in `MUSCLES` in `bodymap.js`. |
-| **Primary muscle** | A muscle directly targeted by an exercise. `muscle_activations.activation_type = 'primary'`. Shown as solid green on the body map. |
-| **Secondary muscle** | A muscle engaged in a supporting/stabilising role. `activation_type = 'secondary'`. Shown as blue diagonal hatch on the body map. |
-| **Muscle activation** | A DB record linking a session exercise to a muscle ID with a type. Stored in `muscle_activations`. |
+### Key custom tokens
+`--accent` (#ee2c80 magenta, decorative only) · `--accent-active` (#b5116a, WCAG AA ~6.45:1 vs white — **use this for filled backgrounds with white text**) · `--accent-soft` (#ff7eb6 dark / #b5116a light, text on tinted bg) · `--exercise` (#7af2a4 dark / #1a8c4e light green) · `--heat-1..5` (green scale) · `--r-card` (16px) · `--r-pill` (999px) · `--r-tile` (10px) · `--cond` (IBM Plex Condensed)
-### System concepts
+**WCAG rule:** Never use `--accent` (#ee2c80) as a `background` with white/light text — it fails AA (3.95:1). Use `--accent-active` instead for any filled interactive element (buttons, active pills, CTAs).
-| Term | Definition |
-|---|---|
-| **Business unit** | A gym location in sporty.no. Identified by `sporty_business_unit_id` (hardcoded as `8`). Used to scope RLS policies and the sporty sync. |
-| **Gym calendar** | The sporty.no schedule mirrored in the `gym_calendar` table. Populated by `sportySync.js` three times daily. |
-| **Recommendation** | A Claude-generated exercise suggestion based on untrained muscle gap analysis for a period. Cached in `recommendation_cache` keyed by prompt version + period + muscle coverage. |
-| **Period** | A filter duration on the Report page — 7, 30, or 90 days back from today. |
-| **View** | Front or back side of the body SVG. Not to be confused with React "views" (the full-page components). |
-
-## Project overview
-**Workout Lens** — a workout-logging app. User photographs a handwritten training program from a gym whiteboard (sporty.no format), the app analyses the image via Claude Vision, displays which muscles were trained on a body figure, and gives next-session recommendations.
-
-## Tech stack
-- **Frontend:** React 19 + Vite (in `app/`)
-- **Design system:** IBM Carbon Design System (`@carbon/react`, `@carbon/icons-react`) — see [Carbon design system](#carbon-design-system) section
-- **Auth + DB:** Supabase (magic-link login, Supabase Auth + PostgreSQL)
-- **AI:** Anthropic Claude API — proxied via Azure Function (server-side); model IDs managed in `app/src/lib/prompts.js`
-- **Hosting:** Azure Static Web Apps — **live at [workout.umulig.org](https://workout.umulig.org)**
-- **CI/CD:** GitHub Actions — push to `master` → auto-deploy to Azure SWA
-- **i18n:** `react-i18next` — three locales: `nb` (Norwegian, default), `en` (English), `fa` (Persian/RTL); locale files in `app/public/locales/`; singleton in `app/src/lib/i18n.js`; all date/time formatting via `Intl.DateTimeFormat`; use `toIsoDate()` and `isoWeekMonday()` from `utils.js` for date-string / ISO-week needs (no `date-fns`)
-
-## Muscle ID system (17 total)
-```
-chest, shoulders_front, shoulders_side, biceps, forearms, abs, obliques, quads, calves
-traps, rear_delts, lats, triceps, lower_back, glutes, hamstrings, calves_back
-```
-Each has a `view` (front/back) and Norwegian `label` in the `MUSCLES` object in `app/src/lib/bodymap.js`.
-
-## Carbon design system
-
-Uses `@carbon/react` and `@carbon/icons-react`. IBM Plex fonts (Sans, Mono, Serif, Condensed) bundled locally in `app/public/fonts/` — no Google Fonts, no CDN.
-
-- `app/src/styles/carbon-tokens.css` — all Carbon CSS variables for g10 (light) and g100 (dark) themes, plus `@font-face` declarations; font URLs use `/fonts/...` (Vite public-dir absolute paths)
-- `app/src/theme.jsx` — exports only `ThemeProvider`, which sets `data-theme="g10"` or `data-theme="g100"` on ``, persists to `localStorage`. Default (no saved preference): respects OS `prefers-color-scheme` — dark OS → g100, light OS → g10. `ThemeCtx` lives in `hooks.js`; `useTheme` is imported from there, not from `theme.jsx`.
-- `Login.jsx` → Carbon `TextInput`, `Button`, `InlineNotification`, `Email` icon; `getDailyQuote()` renders a date-aware motivational quote below the subtitle — English only (hardcoded; language preference is unknown before login); keyed by `MM-DD` for special dates (`01-01`, `12-24`), falls back to a per-weekday quote; 13px italic `var(--cds-text-secondary)`
-- `MuscleMap.jsx` → orchestrator (352 lines): `useReducer` + all 4 `useEffect` hooks + `addImage`/`handleFiles`/`analyze`/`confirm`/`recommend` callbacks + step-indicator strip; delegates rendering to `MuscleMapUpload`, `MuscleMapConfirm`, `MuscleMapResult`; exports `initialState`, `reducer`, `localDateStr`. Sub-components: `MuscleMapUpload.jsx` (dropzone, image grid, ghost shortcuts, analyze CTA); `MuscleMapConfirm.jsx` (layer-02 wrapper, today/other-day pill, date picker, gym-class selector, exercise list, confidence dots, save CTA; includes `getConfidenceColor`); `MuscleMapResult.jsx` (KPI strip, save status, body map, muscle chips, exercise list, recommendations).
-- `History.jsx` → orchestrator (525 lines): all state (sessions, selectedDate, muscleFilter, sessionEdits Map, classHistory Map), all callbacks, session-row header rendering; delegates expanded panel to `SessionEditPanel` and calendar to `MonthGrid`. `MonthGrid.jsx` (103 lines): 7-column CSS grid heatmap, today/selected outlines, interactive day buttons; includes `calHeatColor`. `SessionEditPanel.jsx` (218 lines): gym-class selector, `BodyPanel`, hover-detail card, exercise list via `ExerciseRowWithAutocomplete`, re-upload button, class-history panel, dirty-state save/discard bar; imports `checkGymCalendarConflict` directly. Per-session edit state is `Map` (no global `editMode` boolean); `PageHeading` has `minHeight: 72` to prevent layout shift; all date formatting via `Intl.DateTimeFormat`.
-- `SetSammen.jsx` → landing page for the «Sett sammen»-tab. Two-column grid of `ActionCard` components. Two-color system: magenta (`--accent`) = gruppetimer, green (`--exercise`) = øvelser. Props: `onShowGruppetimePicker`, `onShowOvelsePicker`.
-- `GruppetimePicker.jsx` → lists all templates with live search, mini front-view `BodySVG` thumbnail per row, and a featured magenta «Ny gruppetime» card that expands to a `TextInput` + create form inline. On row click → `onEditTemplate(tpl)`.
-- `OvelsePicker.jsx` → lists all library exercises with region filter chips (Alle / Overkropp / Kjerne / Underkropp / Kondisjon; chips with count=0 are hidden except «Alle»), debounced search, and a featured green «Ny øvelse» card. Exercise rows show up to 3 primary muscle names + «BRUKT I N GT» count (colored `--accent-soft` to visually separate it from the muscle names). Clicking a row opens `ExerciseForm` directly for editing (no intermediate detail screen). Uses `fetchExerciseTemplateCounts()` to batch-load template usage counts on mount.
-- `GruppetimeEditor.jsx` → dedicated editor for group-class templates (issue #174, sprint 4). **Separate from `TemplateSessionEditor` — do not merge.** Features: live `BodyPanel` coverage, gap-hint chips for untrained muscles, up/down reorder handles per exercise row, «Velg øvelse» (opens `ExFlyt`) + «Ny øvelse» (manual) add controls as green bar-buttons, inline template name rename via `TextInput`, creator + last-used metadata footer. Saves via `replaceTemplateExercises` + `updateTemplateDetails`. `template_type` column exists on `session_templates` but has no UI consumer yet.
-- `ExFlyt.jsx` → slide-up overlay modal for adding exercises to a GruppetimeEditor template. Search existing library exercises or quick-create a new entry with AI muscle inference. Closes via `onClose`; adds via `onAdd(exercise)`.
-- `TemplatePicker.jsx` → Carbon `Button`, `InlineLoading`, `InlineNotification`
-- `TemplateSessionEditor.jsx` → `layer-02` + 2px accent top border container; `SectionLabel renderIcon={Edit}` header; Carbon `TextInput` for template name (inline rename); step indicator in use mode ("Steg 2 av 3"); no "Lagre mal" in use mode; body map via `BodyPanel`; exercise rows via `ExerciseRowWithAutocomplete`; library search via `LibraryPicker`
-- `MuscleMapConfirm.jsx` → wrapped in `layer-02` + 2px accent top border container; `SectionLabel renderIcon={Edit}` header; Carbon `DatePicker`/`DatePickerInput` for session date (defaults to today, max = today)
-- `BodySVG` / `HeatmapBodySVG` muscle highlights: primary → `var(--heat-4)` solid green, secondary → diagonal blue hatch (`#001d6c` base + `#4589ff` lines). `HeatmapBodySVG` accepts `onHover(id|null)` and `hovered` props — when `onHover` is provided the internal floating tooltip is suppressed and the caller manages the detail card.
-- `Home.jsx` → `SectionLabel` + `PageHeading` headings; last session card with gym-class identity hero; 7-day weekly strip with heat colors — clicking a day that has a session navigates to History pre-selected on that date; `fetchThisWeekSessions` in `db.js`
-- `Report.jsx` → `SectionLabel` eyebrow with period + active day filters on two separate `display:block` spans; three separate `flexWrap: wrap` filter rows (period / weekdays / session types) with `1px solid var(--border-subtle-wl)` top borders between groups; "Nullstill filter" always rendered (opacity-toggled); KPI tiles → heatmap body → hover detail → heat legend → frequency table → gap callout card (with `AccentChip` per untrained muscle) → recommendation button → recs list; when all primary muscles trained shows positive fallback message; when some muscles secondary-only shows those as blue tags; recommendation rows have 3px accent left strip + round `+` button that saves the exercise inline via `saveLibraryExercise`; "Oppdater anbefalinger" ghost button (`Renew` icon) below the recs list — re-runs Claude call and overwrites the cache entry; no `StickyCta`; recs are persisted in the shared `recommendation_cache` Supabase table (see data model) and restored on mount/filter-change via `fetchRecsCache`; prefill prop applied on mount via `useRef` — supports `periodDays`, `selectedDays`, `selectedTypes`, `weekday`, `sessionType`; `KpiTile` (42px Plex Light value); `muscleLastDate` in useMemo
-- `PageShell.jsx` → exports: `SectionLabel` (mono 12px, 0.16em tracking, 3px `var(--accent)` left border; accepts optional `renderIcon` prop — renders the Carbon icon at 14px before the label text), `PageHeading` (Cond 700 28px), `PageTitle` (alias for SectionLabel), `AccentChip` (magenta pill: `var(--accent-bg-14)` bg, `var(--accent-soft)` text), `StickyCta` (sticky bottom bar with top border), `BackButton`; `NavBtn` is a `forwardRef` component accepting `l1` and `l2` props — renders a 2-line Plex Condensed (8px) label below the icon; nav bar height is 56px; nav icons in order: Camera → RecentlyViewed → Analytics → EventSchedule (Planlegger) → Notebook (Sett-sammen) → Settings — 6 icons each 48px wide; theme toggle and logout removed from header (now in Settings view); `ChangelogModal` no longer rendered here. `useNavHints` moved to `app/src/lib/hooks.js` (issue #253)
-- `carbon-tokens.css` → added `--heat-1..5` green scale (#044317 → #42be65); WL custom tokens: `--accent` (#ee2c80 magenta), `--accent-hover` (#d9246f), `--accent-active` (#b5116a — WCAG AA safe with white text ~6:1; use for filled active/selected states), `--surface-card`, `--border-subtle-wl`, `--text-muted-wl`, `--accent-bg-08/14/30`, `--accent-soft`, `--r-card` (16px), `--r-pill` (999px), `--r-tile` (10px), `--cond` (IBM Plex Sans Condensed), `--exercise` (#7af2a4 green, g10 override #1a8c4e), `--exercise-soft` (rgba 12%), `--exercise-mid` (rgba 35%); g10 light-mode overrides for all WL tokens
-- `app.css` → global `html, body { overflow-x: hidden }` to prevent horizontal viewport bleed from chip rows; do not use `overflow: hidden` on direct parents of `flexWrap: wrap` chip containers — it clips instead of scrolling
-### Hard rules (must not regress)
-- **Sentence case** for all labels — `Add exercise`, not `Add Exercise`
-- **0px border-radius** on buttons, inputs, cards — exceptions: Tags/pill chips use `var(--r-pill)` (999px), cards use `var(--r-card)` (16px), tiles use `var(--r-tile)` (10px)
-- **No emoji** — use `@carbon/icons-react` exclusively
-- **IBM Plex everywhere** — no system-font fallbacks visible in the rendered page
-- **Semantic tokens** (`var(--cds-*)` or `var(--wl-*)`) not raw hex — otherwise the theme toggle breaks
-- **No gradients** in product UI — solid colors only
-- **Focus ring** = 2px solid `#0f62fe` outline (Carbon handles this via its component styles)
-- **Filter chips** — always use `flexWrap: wrap`; never `overflowX: auto` on a flex chip container without a constrained parent (it silently fails on mobile Chromium and clips instead of scrolling)
+Skeleton dark-mode tokens must be added to `[data-theme="g100"]` in `carbon-tokens.css` — Carbon emits them under `.cds--g100` class only (see pitfall #164).
### Token cheat sheet
| Concept | Token |
|---|---|
-| Page background | `var(--cds-background)` |
-| Card/tile surface | `var(--cds-layer-01)` |
+| Page bg | `var(--cds-background)` |
+| Card | `var(--cds-layer-01)` |
| Nested card | `var(--cds-layer-02)` |
| Border | `var(--cds-border-subtle-01)` |
-| Strong border / input border | `var(--cds-border-strong-01)` |
+| Input border | `var(--cds-border-strong-01)` |
| Primary text | `var(--cds-text-primary)` |
-| Secondary/muted text | `var(--cds-text-secondary)` |
-| Interactive (blue) | `var(--cds-interactive)` |
+| Muted text | `var(--cds-text-secondary)` |
+| Interactive | `var(--cds-interactive)` |
| Error | `var(--cds-support-error)` |
-| Body font | `var(--cds-font-sans)` |
-| Mono font | `var(--cds-font-mono)` |
-
-### Adding more Carbon components
-Refer to the official IBM Carbon documentation and `app/src/styles/carbon-tokens.css` for available tokens. The `@carbon/react` package ships full TypeScript types — use them as the component API reference.
-
-## Backlog
-
-Tracked in [GitHub Issues](https://github.com/ChristopherRotnes/BodyMapTraining/issues). Run `gh issue list` for current open work.
-
-## Session data model — edit flow (issue #19)
-
-`updateSession(sessionId, exercises, gymCalendarId)` in `db.js`:
-1. Deletes all `session_exercises` for the session (cascades to `muscle_activations`)
-2. Re-inserts enabled exercises + their `muscle_activations`
-3. Updates `gym_calendar_id` on the `sessions` row
-
-The sessions table has `UNIQUE (gym_calendar_id)` — updating to a gym class that already has a different session raises a Postgres 23505 error, shown to the user as a friendly message.
-`saveSession` accepts an optional `sessionDate` param (ISO `yyyy-MM-dd`); defaults to today for backwards compat.
-
-`fetchGymSessionsByDate(dateStr)` generalises `fetchTodayGymSessions` — same query but parameterised. `fetchTodayGymSessions` now delegates to it.
+## Key architecture decisions
-## Exercise data model
-```typescript
-{
- id: number | string, // number from Claude parse; string (Date.now()) for manually added rows
- name: string, // exact name from whiteboard / user-edited
- standardName: string, // normalised name
- primary: string[], // muscle IDs returned by Claude (or from library)
- secondary: string[], // muscle IDs returned by Claude (or from library)
- enabled: boolean // toggled in confirm/template step
-}
-```
-Sets and reps are not tracked — group class instructors log *what exercises were in the program*, not volume. DB columns (`sets`, `reps`, `default_sets`, `default_reps`) exist and are nullable.
+**Shared modules** (import from these, never redefine locally):
+- `app/src/lib/bodymap.js` — constants/utils: `MUSCLES`, `SHAPES`, `EX_DB`, `calcMuscles`, `useIsMobile`
+- `app/src/lib/bodymap.jsx` — only `BodySVG` and `HeatmapBodySVG` (use explicit `.jsx` extension)
+- `app/src/lib/hooks.js` — `useDebouncedSearch`, `useFetch`, `useTheme`, `useNavHints`
+- `app/src/lib/utils.js` — `callClaude`, `inferMusclesFromName`, `buildMuscleMapFromExercises`, `buildMuscleMapFromSession`, `buildRecMuscleMap`, `extractMuscles`, `toIsoDate`, `toWeekIso`, `isoWeekMonday`, `weekIsoToMonday`, `getIntlLocale`
+- `app/src/lib/prompts.js` — `CLAUDE_MODEL_VISION`, `CLAUDE_MODEL_TEXT`, `RECS_PROMPT_VERSION`, all prompt builders
-## Exercise library + session templates data model (issue #38)
+**Never** add new debounce `useEffect`s — use `useDebouncedSearch`.
+**Never** write inline `session_exercises(... muscle_activations(...))` SELECT strings in `db.js` — use `SESSION_EXERCISES_SELECT` / `SESSION_EXERCISES_FULL_SELECT`.
-Three new Supabase tables:
+**i18n:** All date/time via `Intl.DateTimeFormat` + `getIntlLocale()`. Never hardcode `"no-NO"` or use `date-fns` locale objects.
-```sql
-exercise_library -- named exercises with muscle maps
- id, user_id, name, primary_muscles text[], secondary_muscles text[],
- default_sets text, default_reps text, created_at
+**API security:** All Claude calls must go through `callClaude()` in `utils.js`. The proxy (`app/api/claude.js`) requires `X-Supabase-Token` header — NOT `Authorization: Bearer` (Azure SWA intercepts that — see pitfall #57).
-session_templates -- named session skeletons
- id, user_id, name, sort_order int, used_at timestamptz, created_at
+**Azure Functions entry:** `app/api/index.js` must import every new function file — Azure v4 only loads what `main` references. API files must use raw `fetch` to Supabase REST — never `import { createClient } from '@supabase/supabase-js'`.
-session_template_exercises -- ordered exercises within a template
- id, template_id → session_templates, library_exercise_id → exercise_library (nullable),
- name text (denormalised snapshot), primary_muscles text[], secondary_muscles text[],
- sets text, reps text, sort_order int
-```
+**Sporty sync:** `sportySync.js` timer triggers 04:00, 11:00, 14:00 UTC. Timer guarded by `AZURE_FUNCTIONS_ENVIRONMENT === 'Production'` — skipped in local dev.
-Name + muscles are denormalised into `session_template_exercises` so renaming a library exercise doesn't silently change existing templates.
+**Recs cache:** Bump `RECS_PROMPT_VERSION` in both `prompts.js` AND `recsCacheCleanup.js` whenever the recommendation prompt or model changes. A CI test (`recsVersion.test.js`) fails if they drift.
-`replaceTemplateExercises(templateId, exercises)` in `db.js` does a full delete-and-reinsert — the canonical update path for template exercise lists.
+**CI/CD:** Frontend pre-built in GitHub Actions runner with `VITE_*` env vars, uploaded as `app/dist/`. Do NOT let Oryx build the frontend — it strips `VITE_*` vars.
-`touchTemplate(id)` updates `used_at` to now — called on "Bruk økt" so templates sort by recency in TemplatePicker.
+**Supabase client:** `createClient` must pass `global: { headers: { apikey: supabaseKey } }` — the v2 fetch interceptor doesn't reliably add it in browser (see pitfall #9).
-## Week plan data model (issue #59)
+**FK constraint:** `session_templates.user_id` and `exercise_library.user_id` reference `profiles(id)`, not `auth.users(id)`. Do not change these FKs.
-Two new Supabase tables:
+## Data models
-```sql
-week_plans
- id, user_id, week_iso text (e.g. "2026-W19"), created_at
- UNIQUE (user_id, week_iso)
+### Session edit flow
+`updateSession(sessionId, exercises, gymCalendarId)` in `db.js`: deletes all `session_exercises` (cascades to `muscle_activations`), re-inserts enabled exercises + activations, updates `gym_calendar_id`. `sessions` has `UNIQUE (gym_calendar_id)` → 23505 error shown as friendly message on conflict.
-week_plan_days
- id, plan_id → week_plans (on delete cascade), day_of_week int (1=Mon…7=Sun),
- template_id → session_templates (on delete set null, nullable), sort_order int
+### Exercise (in-memory shape)
+```typescript
+{ id: number|string, name: string, standardName: string, primary: string[], secondary: string[], enabled: boolean }
```
+Sets/reps not tracked — app logs *what* exercises were in the program, not volume.
-`week_plan_days.template_id` nullable — an empty slot is a valid row with `template_id = null`. RLS on both tables restricts all operations to the owning user (`auth.uid() = user_id` / exists check via join).
-
-## Gym-wide shared templates and exercise library
-
-`session_templates` and `exercise_library` are **gym-wide**: any co-instructor at the same gym (via `user_gyms` join) can SELECT, INSERT, UPDATE, and DELETE. `user_id` is retained on both tables as "created by" for attribution display only — it is not an ownership gate.
-
-**FK constraint:** `session_templates.user_id` and `exercise_library.user_id` reference `profiles(id)`, not `auth.users(id)`. PostgREST cannot traverse `auth.users → profiles`, so the `profiles!user_id(display_name)` join would fail at runtime if pointed at `auth.users`. Do not change these FKs back to `auth.users`.
-
-**Editing an exercise does NOT rewrite historical sessions.** `muscle_activations` rows are permanent snapshots written at log time with no FK to `exercise_library`. Correcting a muscle mapping in the library only affects future sessions.
-
-`db.js` functions:
-| Function | Description |
-|---|---|
-| `fetchWeekPlan(weekIso)` | Fetches `week_plans` + `week_plan_days` with joined template data. Returns `{ plan, days }`. |
-| `saveWeekPlan(weekIso, assignments)` | Upserts `week_plans`, deletes + reinserts all `week_plan_days`. `assignments: [{ day_of_week, template_id }]`. |
-| `deleteWeekPlan(weekIso)` | Deletes the `week_plans` row (cascade removes days automatically). |
-| `fetchSessionsForWeek(weekIso)` | Fetches all `sessions` (with `session_exercises` + `muscle_activations.activation_type`) whose `session_date` falls within the ISO week (Mon–Sun). `fetchThisWeekSessions()` now delegates to this with `toWeekIso(new Date())`. Powers Planlegger's "Trent denne uken" body map (#143). |
-| `fetchExerciseTemplateCounts()` | Returns `{ [exercise_id]: number }` — distinct template count per library exercise, using Set deduplication on `template_id`. Used by OvelsePicker to show «BRUKT I N GT». |
-
-## Recommendation cache data model (issue #150)
-
-Shared lookup table — no `user_id` column. Any authenticated user whose filters resolve to the same muscle coverage pattern reuses the cached Claude response without an extra API call.
-
+### DB tables (abbreviated)
```sql
-recommendation_cache
- cache_key text PRIMARY KEY, -- v{RECS_PROMPT_VERSION}_{periodDays}_{sessionCount}_{trainedIds}_{untrainedIds}
- recs jsonb NOT NULL, -- array of { name, primary[], secondary[], tip }
- fetched_at timestamptz NOT NULL DEFAULT now(),
- written_by uuid NOT NULL DEFAULT auth.uid() REFERENCES profiles(id) -- owner (issue #235)
+exercise_library -- id, user_id, name, primary_muscles[], secondary_muscles[], created_at
+session_templates -- id, user_id, name, sort_order, used_at, created_at
+session_template_exercises -- id, template_id, library_exercise_id(nullable), name(snapshot), primary_muscles[], secondary_muscles[], sort_order
+week_plans -- id, user_id, week_iso, created_at; UNIQUE(user_id, week_iso)
+week_plan_days -- id, plan_id, day_of_week(1-7), template_id(nullable), sort_order
+recommendation_cache -- cache_key(PK), recs jsonb, fetched_at, written_by
```
-`cache_key` encodes everything Claude sees: prompt version, period, session count, and sorted trained/untrained muscle ID lists. Changing any of these produces a different key → natural cache miss → fresh fetch.
-
-`db.js` functions:
-| Function | Description |
-|---|---|
-| `fetchRecsCache(cacheKey)` | SELECT by `cache_key`, returns `recs` array or `null`. Silent on error. |
-| `saveRecsCache(cacheKey, recs)` | UPSERT on conflict. Fire-and-forget (errors are silent). |
-
-**Cache invalidation:** No explicit invalidation on session changes — if trained/untrained muscles change, the key changes naturally. The weekly `recsCacheCleanup` Azure Function deletes entries older than 7 days (TTL) and entries from a stale `RECS_PROMPT_VERSION`. **Bump `RECS_PROMPT_VERSION` in both `prompts.js` and `recsCacheCleanup.js` whenever the recommendation prompt or model changes.**
-
-## Key architecture decisions
-- **i18n:** `app/src/lib/i18n.js` initialises `i18next` with `fallbackLng: "nb"` and three resource bundles (`nb`, `en`, `fa`). All components use `useTranslation()` for strings. All locale-aware date/time rendering uses `Intl.DateTimeFormat` with a `getIntlLocale()` helper that maps `"nb" → "no"` (the IETF tag `Intl` expects). Never use hardcoded locale strings like `"no-NO"` or `date-fns` locale objects — they break when the user switches language. The `i18n` singleton can be imported directly (`import i18n from "../lib/i18n"`) for `i18n.language` access outside hooks. RTL (`dir="rtl"`) is applied to `` automatically on language change.
-- **Shared muscle/SVG module — split into two files (issue #253):**
- - `app/src/lib/bodymap.js` — all non-component exports: `MUSCLES`, `SHAPES`, `EX_DB`, `BODY_PATH`, `BODY_POLY`, color constants (`PRIMARY_FILL`, `PRIMARY_HOVER`, `PRIMARY_STROKE`), `calcMuscles`, `useIsMobile`. Import from here for constants and utilities.
- - `app/src/lib/bodymap.jsx` — only `BodySVG` and `HeatmapBodySVG` components (imports from `bodymap.js`). Import using the explicit `.jsx` extension when importing these components. Do not re-add non-component exports here.
-- **Shared hooks:** `app/src/lib/hooks.js` — exports `useDebouncedSearch(delayMs?)` (returns `{ search, setSearch, debouncedSearch }`; debounced value is raw — trim/lowercase at use-site), `useFetch(fn, deps?)` (returns `{ data, loading, error, setData }`; `setData` allows optimistic updates; use only for data fetched once per mount without mutation-driven refetches), `ThemeCtx` (the React context used by `ThemeProvider`), `useTheme()` (returns `{ theme, setTheme }` from `ThemeCtx`), and `useNavHints()` (returns `[hints: boolean, toggle(val): void]`; reads/writes `localStorage` key `wl-nav-hints`). Import from here; do not copy these patterns locally.
-- **Shared utilities:** `app/src/lib/utils.js` — exports `toBase64`, `getMediaType`, `buildMuscleMapFromExercises` (with EX_DB fallback, for confirm/edit steps), `buildMuscleMapFromSession` (reads saved DB session for History read mode), `buildRecMuscleMap` (for recommendation body maps), `isInvalidNum` (validates sets/reps as integers 1–99), `callClaude(body)` (authenticated fetch to `/api/claude` — returns raw `Response`; always call `await res.json()` to read the body), `inferMusclesFromName(name)` (calls Claude Sonnet text API to infer muscle IDs for a single exercise name — returns `{ primary, secondary }` or `null`; handles markdown code fences defensively), `extractMuscles(session)` (splits `muscle_activations` into primary/secondary Sets, removes primary from secondary), `toWeekIso(date)` (Date → `"2026-W19"` ISO week string), `weekIsoToMonday(weekIso)` (`"2026-W19"` → Monday `Date`), `isoWeekMonday(date)` (Date → Monday `Date` of that ISO week, local time), `toIsoDate(date)` (Date → `"yyyy-MM-dd"` string using local time getters — replaces `date-fns` `format`), `getIntlLocale()` (maps `i18n.language` to the IETF tag `Intl` expects, e.g. `"nb" → "no"`). Do not redefine these locally in component files.
-- **Shared Claude config:** `app/src/lib/prompts.js` — exports `CLAUDE_MODEL_VISION` (sonnet-4-6, for image analysis), `CLAUDE_MODEL_TEXT` (sonnet-4-6, for recommendations), `RECS_PROMPT_VERSION` (integer — bump whenever `buildPeriodRecommendPrompt` or the model changes; old cache entries are swept by the weekly cleanup job; **must also be bumped in `app/api/recsCacheCleanup.js`** — a CI test in `app/api/__tests__/recsVersion.test.js` fails if the two values drift), `ANALYZE_PROMPT`, `buildRecommendPrompt(trained, untrained)`, `buildPeriodRecommendPrompt(periodDays, sessionCount, trainedLabels, untrainedLabels)`, `buildMuscleInferencePrompt(name)` (cheap text-only call for single-exercise muscle inference — strips `<>` from the name and wraps it in `…` XML tags per Anthropic's prompt injection boundary pattern; 3 regression tests in `prompts.test.js`). All model IDs and prompt text live here; update in one place.
-- Claude returns muscle IDs directly in JSON — local keyword matching (EX_DB) was abandoned because Norwegian abbreviations and whiteboard variants didn't match reliably. EX_DB is kept only as fallback for manually added exercises.
-- SVG body uses `BODY_PATH` (bezier curves, viewBox `0 0 160 360`) — improved silhouette with curved shoulders, arms, waist and hips. Still simplified, not anatomically precise. `SHAPES` entries are either ellipses (`{ cx, cy, rx, ry }`) or SVG paths (`{ d }`); the render loop handles both. Key muscles with path shapes: `traps` (trapezoid with neck notch), `lats` (wing paths). `BodySVG` renders primary muscles as solid green glow, secondary as diagonal blue stripes (``).
-- `useIsMobile(breakpoint=500)` — exported hook from `bodymap.js`. Below breakpoint: single body view with Front/Bak toggle. Above: side-by-side. Consumed via `BodyPanel` — do not use directly in page components.
-- **Shared exercise row:** `app/src/components/ExerciseRow.jsx` — renders one editable exercise row (checkbox, inline name edit, delete). Props: `exercise`, `onChange(updates)`, `onDelete()`, `layer` ("layer-01"/"layer-02"), `validateNumbers`, `autoFocusName`, `onNameBlur` (optional callback fired when the name input blurs — used by `ExerciseRowWithAutocomplete` to trigger muscle inference). The outer row div has no click handler — only the Checkbox toggles `enabled` (prevents accidental untick when editing fields). Used by `MuscleMap.jsx`, `History.jsx`, and `TemplateSessionEditor.jsx`.
-- **Planlegger:** `app/src/components/Planlegger.jsx` — weekly training planner view (issue #59). State: `weekOffset` (±week navigation), `assignments` (`{ [dow 1-7]: template | null }`), `templates`, `weekSessions` (logged sessions for the visible ISO week — issue #143), `pickerDow`, `saving`, `saveError`, `hoveredMuscle`. Computed via `useMemo`: `monday`, `weekIso`, `weekLabel` (built inline with `Intl.DateTimeFormat` for the locale-aware month abbreviation + `t("planlegger.weekLabel", ...)`), `untrainedThisWeekIds` (muscle IDs not trained in any logged session for the visible ISO week — derived from `weekSessions` via `extractMuscles`; issue #143), `projectedExerciseMap` (union of all assigned templates' exercises via `buildMuscleMapFromExercises`), `sessionCount`, `muscleGroupCount`, `untrainedMuscleIds`, `showForslag` (≥2 untrained muscles), `forslagTemplates` (up to 3 templates from library covering untrained muscles). Layout: week nav chevrons → `PageHeading` → `SectionLabel "IKKE TRENT DENNE UKEN"` → wrap row of mono pill chips (History-style: `var(--r-pill)`, `var(--border-subtle-wl)`, `var(--text-muted-wl)`, `var(--cds-font-mono)` 11px) listing muscles not yet trained that week (or a single mono message when all 17 are trained) → `SectionLabel "PROJISERT DEKNING"` → projected `HeatmapBodySVG` (side-by-side/toggle) → fixed-height 48px hover-detail container (always rendered, prevents layout shift) → optional Forslag card → `SectionLabel "UKESPLAN"` → 7 × DayRow → inline `TemplatePicker` bottom-sheet overlay. No sticky save/delete bar — plan auto-saves on every add/remove; `deleteWeekPlan` is called automatically when all slots are cleared. Persists via `fetchWeekPlan` / `saveWeekPlan` / `deleteWeekPlan` in `db.js`; loads logged sessions via `fetchSessionsForWeek` in parallel with the plan fetch. Duration (`N MIN`) omitted — `session_templates` has no duration column.
-- **IntroModal:** `app/src/components/IntroModal.jsx` — one-time 5-slide onboarding modal (issue #162). Controlled by `open`/`onClose` props from `App.jsx`. Resets `step` to 0 via `useEffect` whenever `open` becomes true. `dismiss()` sets `localStorage` key `wl-intro-seen=1` then calls `onClose()`; the ×-close button and "Hopp over" also call `dismiss()`. Slide data is a static constant array of `{ Icon, titleKey, bodyKey }`. Step indicator and replay hint rendered in body below slide content. Responsive via an inline `