Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2c96467
fix: surface Anthropic error detail in UI error messages
claude May 14, 2026
0207329
fix: auto-compress images before sending to Anthropic
claude May 14, 2026
d748b25
fix: scale image to 2048px before compressing, not iterative quality-…
claude May 14, 2026
0b1ba9c
fix: normalize ALL CAPS exercise names from whiteboard to title case
claude May 14, 2026
f4363d1
fix: increase max image dimension to 3000px, revert name normalization
claude May 14, 2026
3e0f136
fix: remove leftover normalizeExName calls after revert
claude May 14, 2026
06f08b8
fix: use FileReader path for full-res conversion, canvas only to redu…
claude May 14, 2026
3d8edb8
fix: normalize ALL CAPS exercise names from Claude
claude May 14, 2026
b020779
fix: cap canvas long edge at 4500px to prevent iOS memory failure
claude May 14, 2026
8cff4dd
fix: use blob URL instead of data URL for canvas image source
claude May 14, 2026
781cb24
fix: remove ambiguous created_at sort from fetchLastSession; deduplic…
claude May 14, 2026
0915690
fix: dimension-reduction fallback for iOS quality-param bug; fix fetc…
claude May 14, 2026
cf81fe8
fix: correct mediaType in compressImage happy path; fix muscle chip l…
ChristopherRotnes May 14, 2026
2e04a03
fix: rewrite compressImage to use fixed dimension targets instead of …
ChristopherRotnes May 14, 2026
9597b42
fix: deduplicate ensureGymMembership/ensureDisplayName with a ref flag
ChristopherRotnes May 14, 2026
be93a7b
fix: use data URL for canvas load in compressImage; target 4.5 MB; sk…
ChristopherRotnes May 14, 2026
7dcc07d
fix: use img.decode() for reliable canvas load on iOS; add debug size…
ChristopherRotnes May 14, 2026
e488b22
diag: log pre-fetch image sizes and compressImage naturalWidth/Height…
ChristopherRotnes May 14, 2026
978a49b
diag: switch diagnostic logs to alert() for iOS visibility
ChristopherRotnes May 14, 2026
bd7c0fe
diag: switch compressImage canvas source to URL.createObjectURL
ChristopherRotnes May 14, 2026
7fc2dc8
diag: show base64 prefix in pre-fetch alert
ChristopherRotnes May 14, 2026
78abbd3
diag: add callClaude entry alert to verify data before fetch
ChristopherRotnes May 14, 2026
43122df
diag: log client-reported and server-received image sizes in Azure Fu…
ChristopherRotnes May 14, 2026
590cf45
diag: surface server-measured image MB in error response and on screen
ChristopherRotnes May 14, 2026
0930e7a
diag: log requestBody length before sending to Anthropic
ChristopherRotnes May 14, 2026
06f218e
diag: use context.warn for diag logs so App Insights captures them
ChristopherRotnes May 14, 2026
1de5e8b
diag: add requestBodyMB to error response for on-screen visibility
ChristopherRotnes May 14, 2026
138a335
diag: use Buffer.from exact decode for server image byte count
ChristopherRotnes May 14, 2026
d84164b
fix: disable caching for index.html to prevent stale JS bundles on iOS
ChristopherRotnes May 14, 2026
744ce33
fix: compare base64 string length directly against Anthropic's 5 MB l…
ChristopherRotnes May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

All notable changes to Workout Lens are documented here.

## [1.2.9] — 2026-05-14

### Fixed
- **HEIF/iPhone photo exceeds 5 MB limit** — root cause: Anthropic enforces the 5 MB limit on the **base64 string character count**, not the decoded byte size. A 3.75 MB decoded image produces ~5.25 M base64 chars and is rejected. `compressImage` was checking `b64.length * 0.75 <= 5 MB` (decoded bytes), which passes an image up to ~6.67 M base64 chars — well over the limit. Fixed by changing all checks to compare the base64 string length directly: `b64.length <= MAX_B64_CHARS`. Additionally fixed iOS-specific canvas source issue: `img.src = dataUrl` (large base64 data URL) causes iOS Safari to silently zero `naturalWidth`/`naturalHeight`, producing a blank canvas — fixed by using `URL.createObjectURL(file)` instead.
- **ALL CAPS exercise names** — when canvas quality reduction degrades the image enough for Claude to return exercise names in ALL CAPS, `normalizeExName` in `MuscleMap.jsx` converts fully-uppercase strings to title case before they reach the exercise list. Acts as a permanent safety net.
- **Anthropic error detail not shown** — the error message surfaced to the user read `data?.error?.message`, but the Anthropic API returns the detail in `data.detail` (string). Fixed to read `data.detail || data?.error?.message`.
- **"Siste økt" showing empty despite a session existing** — `fetchLastSession` in `db.js` used `.maybeSingle()` which sends PostgREST `Accept: application/vnd.pgrst.object+json`. PostgREST returns 406 when multiple rows exist even with `limit=1` (the 406 check precedes limit application). `.maybeSingle()` silently converts 406 to `{ data: null, error: null }`. Fixed by removing `.maybeSingle()` and using `data?.[0] ?? null` on a plain array query.
- **Slow app load after gym-wide templates deploy** — `onAuthStateChange` in `App.jsx` called `ensureGymMembership()` and `ensureDisplayName()` on every Supabase auth event (INITIAL_SESSION, TOKEN_REFRESHED, etc.), causing 3–4 redundant DB upserts per page load. These calls now only fire on `SIGNED_IN` events.

## [1.2.8] — 2026-05-14

### Added
Expand Down
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,23 @@ Carbon's compiled CSS from `@carbon/styles` emits dark skeleton token overrides

**Pattern to watch:** Any new Carbon token that Carbon's SCSS emits only under `.cds--g100` (not `:root`) must be explicitly added to the `[data-theme="g100"]` block in `carbon-tokens.css`. Run a visual check in dark mode whenever a new Carbon component is introduced.

### Issue #173 — HEIF photo exceeds Anthropic 5 MB limit (resolved 2026-05-14)
Symptom: uploading an iPhone 17 Pro photo failed with `Serverfeil (400): image exceeds 5 MB maximum: 5246896 bytes > 5242880 bytes`.

**Root cause 1 — Wrong comparison unit in `compressImage`:** Anthropic enforces the 5 MB limit on the **base64 string character count**, not the decoded byte size. `compressImage` was checking `b64.length * 0.75 <= 5 MB` (decoded bytes ≤ 5 MB), which allows base64 strings up to ~6.67 M chars. A 3.75 MB decoded image produces ~5.25 M base64 chars — passes our check, fails Anthropic's. Fixed by changing all checks to `b64.length <= MAX_B64_CHARS` (5,242,880) and setting the canvas compression target to 90% of that limit.

**Root cause 2 — iOS silently ignores large data URLs as `img.src`:** The original canvas fallback path set `img.src = dataUrl` (the ~9 MB base64 string from FileReader). iOS Safari silently fails to decode a data URL this large: `img.naturalWidth` and `img.naturalHeight` both become 0. The canvas is created as 0×0, `toDataURL` returns a near-empty result that passes the size check, and the original un-compressed data stays in state. No error is thrown. Fixed by using `URL.createObjectURL(file)` as the image source instead.

**Root cause 3 — iOS Safari ignores `canvas.toDataURL` quality parameter:** iOS Safari on some versions silently ignores the `quality` argument and always outputs at default quality (~0.92). Dimension reduction (canvas pixel dimensions) is the only reliable lever on iOS — not quality stepping.

**Never revert to `img.src = dataUrl` for large images** — iOS will silently zero out naturalWidth/Height. Do not use `b64.length * 0.75` to compare against Anthropic's limit — compare `b64.length` directly.

### Issue #173 — fetchLastSession returning null (resolved 2026-05-14)
Symptom: Home → "Siste økt" showed "Ingen økter logget ennå" even though sessions existed in the DB and the weekly strip showed the correct session count.

**Root cause — `.maybeSingle()` with multiple rows in the sessions table:** `fetchLastSession` used `.limit(1).maybeSingle()`. `.maybeSingle()` sends PostgREST `Accept: application/vnd.pgrst.object+json`. PostgREST evaluates the "single row" constraint and returns 406 when the base query (before LIMIT) would produce multiple rows — the LIMIT is not applied before this check. `.maybeSingle()` in Supabase JS v2 silently converts a 406 (PGRST116) to `{ data: null, error: null }`, so `fetchLastSession` returned null without any error. Works fine with 1 session in the DB; silently breaks once there are 2+ sessions.

**Fix:** Removed `.maybeSingle()`. Changed to a plain array query (`.limit(1)` without `.maybeSingle()`) and returned `data?.[0] ?? null`. The simpler approach avoids the `Accept: application/vnd.pgrst.object+json` header entirely.

**Pattern to watch:** Do not combine `.limit(1)` with `.maybeSingle()` in Supabase JS v2 when the table can have multiple rows. Use `.limit(1)` with an array query and take `data?.[0]` instead. `.maybeSingle()` is only safe on queries where the base set is already guaranteed to be 0 or 1 rows (e.g. `.eq("id", id)` on a primary key).

2 changes: 2 additions & 0 deletions app/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
"seeAll": "SEE ALL →",
"loading": "Loading last session…",
"noSessions": "No sessions logged yet. Log your first session!",
"muscleCount_one": "{{count}} muscle group",
"muscleCount_other": "{{count}} muscle groups",
"ownTraining": "Personal training",
"train": "Train.",
"today": "Today.",
Expand Down
2 changes: 2 additions & 0 deletions app/public/locales/fa/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
"seeAll": "مشاهده همه →",
"loading": "در حال بارگذاری آخرین جلسه…",
"noSessions": "هنوز جلسه‌ای ثبت نشده. اولین جلسه خود را ثبت کنید!",
"muscleCount_one": "{{count}} گروه عضلانی",
"muscleCount_other": "{{count}} گروه عضلانی",
"ownTraining": "تمرین شخصی",
"train": "تمرین کن.",
"today": "امروز.",
Expand Down
2 changes: 2 additions & 0 deletions app/public/locales/nb/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
"seeAll": "SE ALLE →",
"loading": "Laster siste økt…",
"noSessions": "Ingen økter logget ennå. Logg din første økt!",
"muscleCount_one": "{{count}} muskelgruppe",
"muscleCount_other": "{{count}} muskelgrupper",
"ownTraining": "Egentrening",
"train": "Tren.",
"today": "I dag.",
Expand Down
12 changes: 12 additions & 0 deletions app/public/staticwebapp.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@
{
"route": "/api/*",
"allowedRoles": ["anonymous"]
},
{
"route": "/",
"headers": {
"Cache-Control": "no-store"
}
},
{
"route": "/index.html",
"headers": {
"Cache-Control": "no-store"
}
}
],
"globalHeaders": {
Expand Down
15 changes: 11 additions & 4 deletions app/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { supabase } from "./lib/supabase";
import { ensureGymMembership, ensureDisplayName } from "./lib/db";
import { NavContext } from "./lib/NavContext";
Expand All @@ -24,14 +24,21 @@ function App() {
const [reportPrefill, setReportPrefill] = useState(null);
const [introOpen, setIntroOpen] = useState(false);

const ensuredRef = useRef(false);
useEffect(() => {
const runEnsures = () => {
if (ensuredRef.current) return;
ensuredRef.current = true;
ensureGymMembership().catch(() => {});
ensureDisplayName().catch(() => {});
};
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); }
if (session) runEnsures();
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
setSession(session);
if (session) { ensureGymMembership().catch(() => {}); ensureDisplayName().catch(() => {}); }
if ((event === "SIGNED_IN" || event === "INITIAL_SESSION") && session) runEnsures();
});
return () => subscription.unsubscribe();
}, []);
Expand Down
7 changes: 3 additions & 4 deletions app/src/components/History.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { fetchSessions, fetchSessionsByDate, fetchGymSessionsByDate, updateSession, checkGymCalendarConflict, fetchLibraryExercises, fetchClassHistory } from "../lib/db";
import { MUSCLES, calcMuscles } from "../lib/bodymap.jsx";
import { toBase64, detectMediaType, buildMuscleMapFromSession, buildMuscleMapFromExercises, isInvalidNum, callClaude, extractMuscles, logDevError, getIntlLocale, toIsoDate } from "../lib/utils";
import { compressImage, buildMuscleMapFromSession, buildMuscleMapFromExercises, isInvalidNum, callClaude, extractMuscles, logDevError, getIntlLocale, toIsoDate } from "../lib/utils";
import { CLAUDE_MODEL_VISION, ANALYZE_PROMPT } from "../lib/prompts";
import {
Button, Tag, InlineNotification, InlineLoading,
Expand Down Expand Up @@ -374,8 +374,7 @@ export default function History({ initialDate }) {
if (!file) return;
patchSessionEdit(session.id, { analyzing: true, analyzeError: null });
try {
const mt = await detectMediaType(file);
const b64 = await toBase64(file);
const { base64: b64, mediaType: mt } = await compressImage(file);
const res = await callClaude({
model: CLAUDE_MODEL_VISION,
max_tokens: 1500,
Expand All @@ -390,7 +389,7 @@ export default function History({ initialDate }) {
let data;
try { data = await res.json(); } catch { throw new Error(t("history.reanalyzeServerError", { status: res.status })); }
if (!res.ok) {
const detail = data?.error?.message;
const detail = data?.detail || data?.error?.message;
throw new Error(detail
? t("history.reanalyzeServerErrorDetail", { status: res.status, detail })
: t("history.reanalyzeServerErrorCode", { status: res.status }));
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/Home.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export default function Home({ onShowHistoryWithDate }) {
)}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<AccentChip>{t("history.exerciseCount", { count: exCount })}</AccentChip>
<AccentChip>{t("history.exerciseCount", { count: muscleCount })}</AccentChip>
<AccentChip>{t("home.muscleCount", { count: muscleCount })}</AccentChip>
</div>
</div>
</div>
Expand Down
31 changes: 20 additions & 11 deletions app/src/components/MuscleMap.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useReducer, useRef, useCallback, useEffect, useMemo, useState } from "react";
import { saveSession, fetchGymSessionsByDate, checkGymCalendarConflict, fetchLibraryExercises } from "../lib/db";
import { EX_DB, MUSCLES, calcMuscles } from "../lib/bodymap.jsx";
import { toBase64, detectMediaType, buildMuscleMapFromExercises, buildRecMuscleMap, callClaude, logDevError, getIntlLocale } from "../lib/utils";
import { compressImage, buildMuscleMapFromExercises, buildRecMuscleMap, callClaude, logDevError, getIntlLocale } from "../lib/utils";
import { CLAUDE_MODEL_VISION, CLAUDE_MODEL_TEXT, ANALYZE_PROMPT, buildRecommendPrompt } from "../lib/prompts";
import {
Button, Select, SelectItem,
Expand All @@ -22,8 +22,17 @@
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
};

const MAX_FILE_SIZE_MB = 5;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;


// Gym whiteboards are written in ALL CAPS by convention. Normalize to title
// case when the entire string is uppercase so names display consistently.
const toTitleCase = (str) =>
str.toLowerCase().split(' ').map(w => w ? w[0].toUpperCase() + w.slice(1) : w).join(' ');
const normalizeExName = (str) => {
if (!str) return str;
const t = str.trim();
return t === t.toUpperCase() && /[A-ZÆØÅ]{2,}/.test(t) ? toTitleCase(t) : t;
};

function getConfidenceColor(ex) {
if (ex.primary?.length || ex.secondary?.length) return "var(--heat-4)";
Expand All @@ -34,7 +43,7 @@
return "var(--cds-support-error)";
}

export const initialState = {

Check warning on line 46 in app/src/components/MuscleMap.jsx

View workflow job for this annotation

GitHub Actions / Test, Build and Deploy

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
step: "upload",
images: [],
exercises: [],
Expand All @@ -54,7 +63,7 @@
sessionDate: localDateStr(),
};

export function reducer(state, action) {

Check warning on line 66 in app/src/components/MuscleMap.jsx

View workflow job for this annotation

GitHub Actions / Test, Build and Deploy

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
switch (action.type) {
case "RESET":
return { ...initialState, sessionDate: localDateStr() };
Expand Down Expand Up @@ -170,13 +179,12 @@

const addImage = useCallback(async (file) => {
if (!file || !file.type.startsWith("image/")) return;
if (file.size > MAX_FILE_SIZE_BYTES) {
setSizeError(`Bildet er for stort (maks ${MAX_FILE_SIZE_MB} MB). Komprimer eller velg et annet bilde.`);
return;
try {
const { base64: b64, mediaType: mt } = await compressImage(file);
dispatch({ type: "ADD_IMAGE", image: { id: Date.now() + Math.random(), base64: b64, mediaType: mt, preview: `data:${mt};base64,${b64}` } });
} catch (e) {
setSizeError(e.message || "Kunne ikke laste bildet.");
}
const mt = await detectMediaType(file);
const b64 = await toBase64(file);
dispatch({ type: "ADD_IMAGE", image: { id: Date.now() + Math.random(), base64: b64, mediaType: mt, preview: `data:${mt};base64,${b64}` } });
}, [setSizeError]);

const handleFiles = useCallback(async (files) => {
Expand All @@ -199,7 +207,7 @@
let data;
try { data = await res.json(); } catch { throw new Error(`Serverfeil (${res.status}): Ugyldig svar fra server`); }
if (!res.ok) {
const detail = data?.error?.message;
const detail = data?.detail || data?.error?.message;
throw new Error(res.status === 401 ? "Ikke innlogget. Logg inn på nytt." : detail ? `Serverfeil (${res.status}): ${detail}` : `Serverfeil (${res.status})`);
}
const text = (data.content || []).map(b => b.text || "").join("").replace(/```json|```/g, "").trim();
Expand All @@ -208,7 +216,7 @@
throw new Error("Svaret fra Claude var ikke gyldig JSON. Prøv igjen.");
}
if (!Array.isArray(parsed)) throw new Error("Uventet svarformat fra Claude.");
dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1" })) });
dispatch({ type: "ANALYZE_SUCCESS", exercises: parsed.map((ex, i) => ({ ...ex, id: i, enabled: true, sets: ex.sets ?? "1", name: normalizeExName(ex.name), standardName: normalizeExName(ex.standardName) })) });
setUseTodayDate(true);
} catch (err) {
logDevError("MuscleMap/analyse", err);
Expand Down Expand Up @@ -359,6 +367,7 @@
{images.map((img, idx) => (
<div key={img.id} style={{ position: "relative", overflow: "hidden", aspectRatio: "1", background: "var(--cds-layer-01)" }}>
<img src={img.preview} alt={t("muscleMap.imageAlt", { n: idx + 1 })} style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />

<button
aria-label={t("muscleMap.removeImage", { n: idx + 1 })}
onClick={() => dispatch({ type: "REMOVE_IMAGE", id: img.id })}
Expand Down
6 changes: 2 additions & 4 deletions app/src/lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,9 @@ export async function fetchLastSession() {
)
`)
.order("session_date", { ascending: false })
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
.limit(1);
if (error) throw error;
return data;
return data?.[0] ?? null;
}

export async function fetchSessionsForWeek(weekIso) {
Expand Down
76 changes: 76 additions & 0 deletions app/src/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,82 @@ export async function detectMediaType(file) {
return file.type || "image/jpeg";
}

// Compress an image to JPEG and ensure decoded size is under Anthropic's 5 MB limit.
// Strategy:
// 1. Read via FileReader — iOS auto-converts HEIF/HEIC to JPEG at full native
// resolution. If the result is already under 5 MB, use it directly.
// 2. Only if over 5 MB: load the data URL into a canvas img (the data URL is already
// in memory; blob URLs can have quirks with HEIC files on some iOS versions).
// iOS Safari ignores the quality param in canvas.toDataURL, so dimension reduction
// is the only reliable lever. We target 4.5 MB (not 5 MB) for a safety margin.
// We start at 1200 px — the 1600 px step is a no-op for typical 1440 px-wide photos
// and just wastes a round-trip through the iOS JPEG encoder at the same size.
export function compressImage(file) {
// Anthropic enforces a 5 MB limit on the base64 string character count, not
// the decoded byte size. A 3.75 MB decoded image produces ~5.25 M base64 chars
// and is rejected. All checks must compare b64.length directly, not b64.length * 0.75.
const MAX_B64_CHARS = 5 * 1024 * 1024;
// Target 90 % of the limit for a safety margin; iOS ignores the quality param
// on canvas.toDataURL so dimension reduction is the only reliable lever.
const TARGET_B64_CHARS = Math.round(MAX_B64_CHARS * 0.9);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Kunne ikke laste bildet'));
reader.onload = () => {
const dataUrl = reader.result;
const mediaType = dataUrl.split(';')[0].split(':')[1] || 'image/jpeg';
const b64 = dataUrl.split(',')[1];
if (b64.length <= MAX_B64_CHARS) {
resolve({ base64: b64, mediaType });
return;
}
// Over limit — compress via canvas.
// Use a blob URL so iOS Safari correctly decodes the image dimensions.
// img.decode() is more reliable than onload on mobile WebKit — it waits
// until the bitmap is fully available to the rendering pipeline, preventing
// drawImage from pulling stale/zero pixel data after a premature onload.
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.src = objectUrl;
const ready = typeof img.decode === 'function'
? img.decode()
: new Promise((res, rej) => { img.onload = res; img.onerror = rej; });
ready.then(() => {
URL.revokeObjectURL(objectUrl);
const nw = img.naturalWidth || 1600;
const nh = img.naturalHeight || 1200;
let resolved = false;
for (const maxEdge of [1200, 960, 768, 600]) {
const scale = Math.min(1, maxEdge / Math.max(nw, nh));
const w = Math.max(1, Math.round(nw * scale));
const h = Math.max(1, Math.round(nh * scale));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
const d = canvas.toDataURL('image/jpeg', 0.85);
const b = d.split(',')[1];
if (b.length <= TARGET_B64_CHARS) {
resolve({ base64: b, mediaType: 'image/jpeg' });
resolved = true;
break;
}
}
if (!resolved) {
// Fallback: 600px at 0.7 quality — accept regardless of size.
const canvas = document.createElement('canvas');
const scale = Math.min(1, 600 / Math.max(nw, nh));
canvas.width = Math.max(1, Math.round(nw * scale));
canvas.height = Math.max(1, Math.round(nh * scale));
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
resolve({ base64: canvas.toDataURL('image/jpeg', 0.7).split(',')[1], mediaType: 'image/jpeg' });
}
}).catch(() => { URL.revokeObjectURL(objectUrl); reject(new Error('Kunne ikke laste bildet')); });
};
reader.readAsDataURL(file);
});
}

// Builds muscle-ID → exercise-name map from a live exercises array (confirm/edit step).
// Falls back to EX_DB keyword matching for exercises without Claude-assigned muscle data.
export function buildMuscleMapFromExercises(exercises) {
Expand Down
Loading