From b341f618e9af683f13bcd6abe12e1f35ac75d87c Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:38:51 +0200 Subject: [PATCH 01/52] fix(ops-safety): safe cleanup pruning and YAML-escaped operator config OPS-02: cleanup.sh defaults to read-only discovery, requires explicit --repo allowlist plus --apply for deletions, skips protected/default branches, verifies merged status via compare, paginates branch listing, and removes description clearing/branch renames. OPS-07: entrypoint.sh escapes operator YAML scalars (backslash/quote) in POSIX sh, validates PORT, rejects newline values without printing secrets, and supports isolated config-path/skip-exec test hooks. Tests: fake-gh cleanup safety suite and isolated entrypoint YAML round-trip suite with dummy values only; never runs live cleanup. --- cleanup.sh | 216 +++++++++++++++--- infra/cliproxyapi/entrypoint.sh | 52 ++++- .../tests/entrypoint-yaml-roundtrip.test.sh | 155 +++++++++++++ tests/cleanup-safe.test.sh | 179 +++++++++++++++ 4 files changed, 571 insertions(+), 31 deletions(-) create mode 100644 infra/cliproxyapi/tests/entrypoint-yaml-roundtrip.test.sh create mode 100644 tests/cleanup-safe.test.sh diff --git a/cleanup.sh b/cleanup.sh index 8ab12d94..ee45b634 100755 --- a/cleanup.sh +++ b/cleanup.sh @@ -1,31 +1,193 @@ #!/bin/bash -USER="lnieuwenhuis" - -repos=$(gh repo list $USER --limit 100 --json name -q '.[].name') - -for repo in $repos; do - echo "Processing $repo..." - - # Clear the description - gh api repos/$USER/$repo -X PATCH -f description="" && echo "✅ $repo description cleared" - - # Get the current default branch name - default=$(gh api repos/$USER/$repo --jq '.default_branch') - - # If default is already main, skip branch rename - if [ "$default" = "main" ]; then - echo "✅ $repo already on main, skipping rename" - else - # Rename whatever the default branch is to main - gh api repos/$USER/$repo/branches/$default/rename \ - -f new_name="main" && echo "✅ $repo renamed $default -> main" +# Safe branch-pruning helper (OPS-02). +# +# History: this script previously enumerated up to 100 repos for a hardcoded +# account, cleared descriptions, renamed default branches to "main", and +# deleted every non-main branch without merge checks or pagination. +# That account-administration behavior was removed: description clearing and +# default-branch renames are intentionally unsupported, branch listing is +# paginated, and deletions require an explicit repo allowlist plus --apply +# plus a merged-into-default check. Protected branches are never deleted. +# +# Usage: +# cleanup.sh [--user USER] [--repo OWNER/REPO ...] [--apply] +# +# No flags (discovery mode): read-only `gh repo list`, prints how to +# allowlist targets. Performs zero mutations. +# --repo OWNER/REPO (repeatable): restrict work to these repos. Without +# --apply this is a dry run: prints what would be deleted. +# --apply: actually delete merged, non-protected, non-default branches +# in the allowlisted repos only. Never deletes unmerged branches. +# +# Safety contract (regression-tested with a fake `gh`): +# - Default invocation performs no PATCH/POST/PUT/DELETE and no renames. +# - Without --apply, no DELETE is issued. +# - Without --repo, no per-branch DELETE is issued. +# - Protected branches (main, master, dev, staging) and the repo's current +# default branch are never deleted, even with --apply. +# - Branches whose compare status against the default is not "identical" +# or "behind" (i.e. ahead/diverged/unknown) are never deleted. +# +# Never run with real credentials to test it. Tests must use a fake `gh` +# executable that records arguments. +set -euo pipefail + +USER="${GH_USER:-lnieuwenhuis}" +APPLY=0 +REPOS=() + +usage() { + cat <&2 + usage >&2 + exit 2 + fi + REPOS+=("$2") + shift 2 + ;; + --repo=*) + REPOS+=("${1#--repo=}") + shift + ;; + --user) + if [ "$#" -lt 2 ]; then + echo "error: --user requires an argument" >&2 + usage >&2 + exit 2 + fi + USER="$2" + shift 2 + ;; + --user=*) + USER="${1#--user=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +is_protected_branch() { + case "$1" in + main|master|dev|staging) return 0 ;; + *) return 1 ;; + esac +} + +# Discovery mode: read-only listing only. No mutations by construction: +# this path contains no `gh api -X` mutating call. +if [ "${#REPOS[@]}" -eq 0 ]; then + echo "Discovery mode for user '${USER}': listing repos (read-only, no mutations)." + # Paginated-capable listing; read-only. + gh repo list "$USER" --limit 1000 --json name -q '.[].name' + echo "No --repo allowlist given: nothing to prune. Re-run with --repo OWNER/REPO [--apply]." + echo "Without --apply this is a dry run. Protected branches (main, master, dev, staging) and each repo's default branch are never deleted." + exit 0 +fi + +# Validate allowlist entries before any per-branch work. +for repo_full in "${REPOS[@]}"; do + case "$repo_full" in + */*/*|/*|*/) + echo "error: invalid --repo '${repo_full}': expected OWNER/REPO" >&2 + exit 2 + ;; + "$USER"/*) + ;; + *) + echo "error: refusing --repo '${repo_full}': owner must be '${USER}' (override with --user)" >&2 + exit 2 + ;; + esac +done + +if [ "$APPLY" -eq 1 ]; then + echo "APPLY mode: will delete only merged, non-protected, non-default branches in: ${REPOS[*]}" +else + echo "Dry-run mode: no deletions will be performed. Add --apply to delete (merged branches only) in: ${REPOS[*]}" +fi + +for repo_full in "${REPOS[@]}"; do + echo "Processing ${repo_full}..." + + default_branch="" + if ! default_branch="$(gh api "repos/${repo_full}" --jq '.default_branch')"; then + echo "warning: could not read default branch for ${repo_full}; skipping repo" >&2 + echo "---" + continue + fi + if [ -z "$default_branch" ]; then + echo "warning: empty default branch for ${repo_full}; skipping repo" >&2 + echo "---" + continue + fi + echo "Default branch for ${repo_full} is '${default_branch}' (never deleted)." + + branches="" + if ! branches="$(gh api "repos/${repo_full}/branches --paginate --jq '.[].name'")"; then + echo "warning: could not list branches for ${repo_full}; skipping repo" >&2 + echo "---" + continue fi - - # Delete all non-main branches - branches=$(gh api repos/$USER/$repo/branches --jq '.[].name' | grep -v "^main$") - for branch in $branches; do - gh api repos/$USER/$repo/git/refs/heads/$branch -X DELETE && echo "🗑️ deleted $branch" - done - + + # Iterate line-wise so branch names with slashes survive. + while IFS= read -r branch || [ -n "$branch" ]; do + if [ -z "$branch" ]; then + continue + fi + if [ "$branch" = "$default_branch" ]; then + echo "skip '${branch}': current default branch" + continue + fi + if is_protected_branch "$branch"; then + echo "skip '${branch}': protected branch" + continue + fi + + compare_status="" + if ! compare_status="$(gh api "repos/${repo_full}/compare/${default_branch}...${branch}" --jq '.status')"; then + echo "skip '${branch}': could not verify merged status; refusing to delete" + continue + fi + case "$compare_status" in + identical|behind) + ;; + *) + echo "skip '${branch}': not merged into '${default_branch}' (status: ${compare_status})" + continue + ;; + esac + + if [ "$APPLY" -eq 0 ]; then + echo "would delete '${branch}' in ${repo_full} (merged into '${default_branch}', dry run)" + continue + fi + + gh api "repos/${repo_full}/git/refs/heads/${branch}" -X DELETE && echo "deleted '${branch}' in ${repo_full}" + done <<< "$branches" + echo "---" done diff --git a/infra/cliproxyapi/entrypoint.sh b/infra/cliproxyapi/entrypoint.sh index 28e44a7c..be691890 100755 --- a/infra/cliproxyapi/entrypoint.sh +++ b/infra/cliproxyapi/entrypoint.sh @@ -3,6 +3,10 @@ # starts the proxy. The config is regenerated on every boot (env is the # source of truth); Codex OAuth credentials persist across deploys on the # Railway volume mounted at /data. +# +# OPS-07: operator values are escaped as YAML double-quoted scalars. A raw +# quote or backslash previously broke the generated YAML; values containing +# a newline/CR are refused without printing the value. set -eu : "${AI_GATEWAY_API_KEY:?AI_GATEWAY_API_KEY must be set (the key the backend uses to call this proxy)}" @@ -10,31 +14,71 @@ set -eu AUTH_DIR="${CLIPROXY_AUTH_DIR:-/data/auths}" LISTEN_PORT="${PORT:-8317}" +CONFIG_PATH="${CLIPROXY_CONFIG_PATH:-/CLIProxyAPI/config.yaml}" + +# POSIX sh only: no bash [[, arrays, or ${var//...} expansions. + +yaml_escape() { + _ye_value="$1" + _ye_nl=' +' + _ye_cr=$(printf '\r') + case "$_ye_value" in + *"$_ye_nl"*|*"$_ye_cr"*) + printf '%s\n' "entrypoint.sh: refusing to write config: value contains an unsupported newline (value not printed)" >&2 + return 1 + ;; + esac + # Escape backslash first, then double quotes, for YAML double-quoted style. + # Other characters (including :, #, spaces) are literal inside quotes. + printf '%s' "$_ye_value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +case "$LISTEN_PORT" in + ''|*[!0-9]*) + printf '%s\n' "entrypoint.sh: refusing to write config: PORT must be an integer 1-65535" >&2 + exit 1 + ;; +esac +if [ "$LISTEN_PORT" -lt 1 ] || [ "$LISTEN_PORT" -gt 65535 ]; then + printf '%s\n' "entrypoint.sh: refusing to write config: PORT must be an integer 1-65535" >&2 + exit 1 +fi + +ESCAPED_AUTH_DIR="$(yaml_escape "$AUTH_DIR")" +ESCAPED_API_KEY="$(yaml_escape "$AI_GATEWAY_API_KEY")" +ESCAPED_MGMT_KEY="$(yaml_escape "$CLIPROXY_MANAGEMENT_KEY")" mkdir -p "$AUTH_DIR" +CONFIG_DIR=$(dirname "$CONFIG_PATH") +mkdir -p "$CONFIG_DIR" -cat > /CLIProxyAPI/config.yaml < "$CONFIG_PATH" <"$workdir/stdout.log" 2>"$workdir/stderr.log" +} + +# --- T1: ordinary keys round-trip ----------------------------------------- +t1="$(mktemp -d)" +t1_auth="$t1/auths" +# Dummy round-trip fixtures only, never real credentials. +t1_api_key="abc123-XYZ-ordinary" # gitleaks:allow -- dummy test value +t1_mgmt_key="mgmt-ordinary-456" # gitleaks:allow -- dummy test value +if AI_GATEWAY_API_KEY="$t1_api_key" \ + CLIPROXY_MANAGEMENT_KEY="$t1_mgmt_key" \ + CLIPROXY_AUTH_DIR="$t1_auth" PORT="8317" \ + run_entrypoint "$t1"; then + if assert_yaml_roundtrip "$t1/config.yaml" "$t1_auth" "$t1_api_key" "$t1_mgmt_key" "8317"; then + ok "T1 ordinary keys round-trip" + else + bad "T1 ordinary keys did not round-trip" + fi +else + bad "T1 entrypoint exited non-zero" +fi +rm -rf "$t1" + +# --- T2: quotes and backslashes round-trip --------------------------------- +t2="$(mktemp -d)" +t2_auth="$t2/auths" +t2_api='a"b\c-dummy' +t2_mgmt='x\y"z-dummy' +if AI_GATEWAY_API_KEY="$t2_api" \ + CLIPROXY_MANAGEMENT_KEY="$t2_mgmt" \ + CLIPROXY_AUTH_DIR="$t2_auth" PORT="8317" \ + run_entrypoint "$t2"; then + if assert_yaml_roundtrip "$t2/config.yaml" "$t2_auth" "$t2_api" "$t2_mgmt" "8317"; then + ok "T2 quotes/backslashes round-trip" + else + bad "T2 quotes/backslashes did not round-trip (see $t2/config.yaml)" + cat "$t2/config.yaml" || true + fi +else + bad "T2 entrypoint exited non-zero for quotes/backslashes" +fi +rm -rf "$t2" + +# --- T3: combined tricky scalars + spaced path ------------------------------ +t3="$(mktemp -d)" +t3_auth="$t3/auth dir" +t3_api='q"u\o"t\e-dummy' +t3_mgmt="single'quote-dummy" +if AI_GATEWAY_API_KEY="$t3_api" \ + CLIPROXY_MANAGEMENT_KEY="$t3_mgmt" \ + CLIPROXY_AUTH_DIR="$t3_auth" PORT="8320" \ + run_entrypoint "$t3"; then + if assert_yaml_roundtrip "$t3/config.yaml" "$t3_auth" "$t3_api" "$t3_mgmt" "8320"; then + ok "T3 combined scalars and spaced auth-dir round-trip" + else + bad "T3 combined scalars did not round-trip" + fi +else + bad "T3 entrypoint exited non-zero" +fi +rm -rf "$t3" + +# --- T4: newline value is rejected without printing the secret -------------- +t4="$(mktemp -d)" +t4_auth="$t4/auths" +t4_marker="DUMMY_MARKER_T4_9f8e7d6c" +t4_bad="before-${t4_marker}-after +second-line" +set +e +AI_GATEWAY_API_KEY="$t4_bad" \ +CLIPROXY_MANAGEMENT_KEY="mgmt-ok-dummy" \ +CLIPROXY_AUTH_DIR="$t4_auth" PORT="8317" \ +run_entrypoint "$t4" +t4_code=$? +set -e +if [ "$t4_code" -eq 0 ]; then + bad "T4 newline secret was accepted (must be rejected)" +else + ok "T4 newline secret is rejected" +fi +if grep -q "$t4_marker" "$t4/stdout.log" 2>/dev/null || grep -q "$t4_marker" "$t4/stderr.log" 2>/dev/null; then + bad "T4 rejection printed the secret value" +else + ok "T4 rejection prints no secret" +fi +rm -rf "$t4" + +# --- T5: non-numeric PORT is rejected ---------------------------------------- +t5="$(mktemp -d)" +set +e +AI_GATEWAY_API_KEY="dummy-ok" \ +CLIPROXY_MANAGEMENT_KEY="dummy-ok-2" \ +CLIPROXY_AUTH_DIR="$t5/auths" PORT="notaport" \ +run_entrypoint "$t5" +t5_code=$? +set -e +if [ "$t5_code" -eq 0 ]; then + bad "T5 invalid PORT was accepted" +else + ok "T5 invalid PORT is rejected" +fi +rm -rf "$t5" + +echo "---" +echo "pass=$pass fail=$fail" +if [ "$fail" -ne 0 ]; then + exit 1 +fi diff --git a/tests/cleanup-safe.test.sh b/tests/cleanup-safe.test.sh new file mode 100644 index 00000000..1a2e94c1 --- /dev/null +++ b/tests/cleanup-safe.test.sh @@ -0,0 +1,179 @@ +#!/bin/bash +# Regression tests for OPS-02 safe cleanup.sh (fake-gh, never touches live repos). +# Run: bash tests/cleanup-safe.test.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# tests/ is one level below repo root, so root is parent of tests/. +REPO_ROOT="$(cd "${SCRIPT_DIR}" && pwd)" +# When placed at /tests, SCRIPT_DIR above is /tests; fix: +if [ "$(basename "$SCRIPT_DIR")" = "tests" ]; then + REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +else + REPO_ROOT="$SCRIPT_DIR" +fi +CLEANUP="$REPO_ROOT/cleanup.sh" + +pass=0 +fail=0 + +ok() { pass=$((pass + 1)); echo "ok - $1"; } +bad() { fail=$((fail + 1)); echo "NOT OK - $1"; } + +# Build an isolated fake `gh` that records every invocation and serves +# canned read-only answers. Mutating calls only record; they never act. +make_fake_gh() { + local bin_dir="$1" + local log_file="$2" + cat > "$bin_dir/gh" <<'FAKEGH' +#!/bin/bash +log_file="${GH_CALL_LOG:?}" +printf 'gh %s\n' "$*" >> "$log_file" +args="$*" +# Mutating calls: just record success. +case "$args" in + *"-X PATCH"*|*"-X DELETE"*|*"-X POST"*|*"-X PUT"*|*"rename"*) + exit 0 + ;; +esac +# Read-only canned answers. +case "$args" in + *"repo list"*) + printf 'allowed\nother-repo\n' + exit 0 + ;; + *"repos/testuser/allowed"*"compare/main...feature-merged"*) + printf 'identical\n' + exit 0 + ;; + *"repos/testuser/allowed"*"compare/main...feature-unmerged"*) + printf 'ahead\n' + exit 0 + ;; + *"repos/testuser/allowed"*"compare/"*) + printf 'diverged\n' + exit 0 + ;; + *"repos/testuser/allowed/branches"*) + printf 'main\ndev\nstaging\nfeature-merged\nfeature-unmerged\n' + exit 0 + ;; + *"repos/testuser/allowed"*"default_branch"*) + printf 'main\n' + exit 0 + ;; + *"repos/"*"default_branch"*) + printf 'main\n' + exit 0 + ;; + *"repos/"*"/branches"*) + printf 'main\n' + exit 0 + ;; + *"repos/"*"/compare/"*) + printf 'diverged\n' + exit 0 + ;; +esac +exit 0 +FAKEGH + chmod +x "$bin_dir/gh" +} + +run_with_fake() { + local log_file="$1" + shift + local bin_dir + bin_dir="$(mktemp -d)" + make_fake_gh "$bin_dir" "$log_file" + # shellcheck disable=SC2068 + PATH="$bin_dir:$PATH" GH_CALL_LOG="$log_file" bash "$CLEANUP" $@ >"$log_file.out" 2>"$log_file.err" || return $? +} + +# --- Test 1: default invocation performs no mutations -------------------- +t1_dir="$(mktemp -d)" +t1_log="$t1_dir/calls.log" +if run_with_fake "$t1_log"; then + if grep -q -- "-X PATCH" "$t1_log" || grep -q -- "-X DELETE" "$t1_log" || grep -q -- "rename" "$t1_log"; then + bad "T1 default invocation issued a mutation (see $t1_log)" + else + ok "T1 default invocation makes no mutations" + fi +else + # cleanup.sh discovery mode exits 0; any failure here is a regression. + bad "T1 default invocation exited non-zero (see $t1_log.err)" +fi +rm -rf "$t1_dir" + +# --- Test 2: dry run with allowlist performs no DELETE ------------------- +t2_dir="$(mktemp -d)" +t2_log="$t2_dir/calls.log" +if run_with_fake "$t2_log" --user testuser --repo testuser/allowed; then + if grep -q -- "-X DELETE" "$t2_log"; then + bad "T2 dry run issued DELETE (see $t2_log)" + else + ok "T2 dry run with --repo makes no DELETE" + fi + if grep -q "would delete 'feature-merged'" "$t2_log.out"; then + ok "T2 dry run reports merged branch as would-delete" + else + bad "T2 dry run did not report would-delete for merged branch (see $t2_log.out)" + fi + if grep -q "would delete 'feature-unmerged'" "$t2_log.out"; then + bad "T2 dry run proposed unmerged branch (see $t2_log.out)" + else + ok "T2 dry run never proposes unmerged branch" + fi +else + bad "T2 dry run exited non-zero (see $t2_log.err)" +fi +rm -rf "$t2_dir" + +# --- Test 3: --apply deletes only merged, non-protected branches --------- +t3_dir="$(mktemp -d)" +t3_log="$t3_dir/calls.log" +if run_with_fake "$t3_log" --user testuser --repo testuser/allowed --apply; then + if grep -q 'refs/heads/feature-merged.*-X DELETE' "$t3_log"; then + ok "T3 --apply deletes merged branch" + else + bad "T3 --apply did not delete merged branch (see $t3_log)" + fi + for protected in "/heads/dev" "/heads/staging" "/heads/main" "feature-unmerged"; do + if grep -q -- "$protected" "$t3_log" | grep -q "DELETE" 2>/dev/null; then + bad "T3 deleted protected/unmerged branch matching $protected" + fi + done + if grep -q -- "-X DELETE.*heads/dev" "$t3_log" || grep -q -- "-X DELETE.*heads/staging" "$t3_log" || grep -q -- "-X DELETE.*heads/main" "$t3_log" || grep -q -- "heads/feature-unmerged.*-X DELETE" "$t3_log" || grep -q -- "-X DELETE.*heads/feature-unmerged" "$t3_log"; then + bad "T3 deleted a protected or unmerged branch (see $t3_log)" + else + ok "T3 never deletes protected/unmerged branches" + fi + if grep -q -- "-X PATCH" "$t3_log" || grep -q -- "rename" "$t3_log"; then + bad "T3 performed description/rename administration (see $t3_log)" + else + ok "T3 performs no description/rename administration" + fi +else + bad "T3 --apply exited non-zero (see $t3_log.err)" +fi +rm -rf "$t3_dir" + +# --- Test 4: unselected repos are never touched --------------------------- +t4_dir="$(mktemp -d)" +t4_log="$t4_dir/calls.log" +if run_with_fake "$t4_log" --user testuser --repo testuser/allowed --apply; then + if grep -q "other-repo" "$t4_log"; then + bad "T4 touched unselected repo other-repo (see $t4_log)" + else + ok "T4 never touches unselected repos" + fi +else + bad "T4 exited non-zero (see $t4_log.err)" +fi +rm -rf "$t4_dir" + +echo "---" +echo "pass=$pass fail=$fail" +if [ "$fail" -ne 0 ]; then + exit 1 +fi From ddfb5d3f70e7524dd63a76a403769e83878e42ff Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:46:25 +0200 Subject: [PATCH 02/52] fix(ops-safety): pass gh api flags as separate args and harden cleanup tests OPS-02 review follow-up. gh api does not parse flags embedded in the endpoint string: the previous 'repos/.../branches --paginate --jq ...' form requested a 404 path, so real branch listing always failed and cleanup.sh skipped every repo (safe but non-functional). Flags are now separate argv entries; a fake-gh argv regression test (T5) fails on the old form. T3's protected-branch check was a no-op pipe with reversed patterns; it now asserts no DELETE for protected/unmerged branches, and T1 also covers --apply without --repo. --- cleanup.sh | 4 +++- tests/cleanup-safe.test.sh | 44 +++++++++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/cleanup.sh b/cleanup.sh index ee45b634..3fcd7c26 100755 --- a/cleanup.sh +++ b/cleanup.sh @@ -147,7 +147,9 @@ for repo_full in "${REPOS[@]}"; do echo "Default branch for ${repo_full} is '${default_branch}' (never deleted)." branches="" - if ! branches="$(gh api "repos/${repo_full}/branches --paginate --jq '.[].name'")"; then + # Flags must be separate argv entries: `gh` does not parse flags embedded + # in the endpoint string (it requests a 404 path instead). + if ! branches="$(gh api "repos/${repo_full}/branches" --paginate --jq '.[].name')"; then echo "warning: could not list branches for ${repo_full}; skipping repo" >&2 echo "---" continue diff --git a/tests/cleanup-safe.test.sh b/tests/cleanup-safe.test.sh index 1a2e94c1..77139ed1 100644 --- a/tests/cleanup-safe.test.sh +++ b/tests/cleanup-safe.test.sh @@ -29,6 +29,9 @@ make_fake_gh() { #!/bin/bash log_file="${GH_CALL_LOG:?}" printf 'gh %s\n' "$*" >> "$log_file" +# Record each argv entry separately; tests assert flag boundaries here so an +# endpoint with embedded flags (real gh: HTTP 404) cannot pass again. +for a in "$@"; do printf 'argv %s\n' "$a" >> "$log_file"; done args="$*" # Mutating calls: just record success. case "$args" in @@ -90,18 +93,19 @@ run_with_fake() { PATH="$bin_dir:$PATH" GH_CALL_LOG="$log_file" bash "$CLEANUP" $@ >"$log_file.out" 2>"$log_file.err" || return $? } -# --- Test 1: default invocation performs no mutations -------------------- +# --- Test 1: default and --apply-without-repo invocations make no mutations t1_dir="$(mktemp -d)" t1_log="$t1_dir/calls.log" -if run_with_fake "$t1_log"; then - if grep -q -- "-X PATCH" "$t1_log" || grep -q -- "-X DELETE" "$t1_log" || grep -q -- "rename" "$t1_log"; then - bad "T1 default invocation issued a mutation (see $t1_log)" +t1b_log="$t1_dir/calls-apply-only.log" +if run_with_fake "$t1_log" && run_with_fake "$t1b_log" --apply; then + if grep -q -- "-X PATCH" "$t1_log" "$t1b_log" || grep -q -- "-X DELETE" "$t1_log" "$t1b_log" || grep -q -- "rename" "$t1_log" "$t1b_log"; then + bad "T1 default/--apply-without-repo invocation issued a mutation (see $t1_dir)" else - ok "T1 default invocation makes no mutations" + ok "T1 default and --apply-without-repo invocations make no mutations" fi else # cleanup.sh discovery mode exits 0; any failure here is a regression. - bad "T1 default invocation exited non-zero (see $t1_log.err)" + bad "T1 invocation exited non-zero (see $t1_dir)" fi rm -rf "$t1_dir" @@ -138,14 +142,14 @@ if run_with_fake "$t3_log" --user testuser --repo testuser/allowed --apply; then else bad "T3 --apply did not delete merged branch (see $t3_log)" fi - for protected in "/heads/dev" "/heads/staging" "/heads/main" "feature-unmerged"; do - if grep -q -- "$protected" "$t3_log" | grep -q "DELETE" 2>/dev/null; then - bad "T3 deleted protected/unmerged branch matching $protected" + bad_delete=0 + for must_not in "heads/main" "heads/dev" "heads/staging" "heads/feature-unmerged"; do + if grep -q -- "${must_not} .*-X DELETE" "$t3_log"; then + bad "T3 deleted branch matching ${must_not} (must never happen)" + bad_delete=1 fi done - if grep -q -- "-X DELETE.*heads/dev" "$t3_log" || grep -q -- "-X DELETE.*heads/staging" "$t3_log" || grep -q -- "-X DELETE.*heads/main" "$t3_log" || grep -q -- "heads/feature-unmerged.*-X DELETE" "$t3_log" || grep -q -- "-X DELETE.*heads/feature-unmerged" "$t3_log"; then - bad "T3 deleted a protected or unmerged branch (see $t3_log)" - else + if [ "$bad_delete" -eq 0 ]; then ok "T3 never deletes protected/unmerged branches" fi if grep -q -- "-X PATCH" "$t3_log" || grep -q -- "rename" "$t3_log"; then @@ -172,6 +176,22 @@ else fi rm -rf "$t4_dir" +# --- Test 5: branch listing keeps gh api flags as separate arguments ------ +t5_dir="$(mktemp -d)" +t5_log="$t5_dir/calls.log" +if run_with_fake "$t5_log" --user testuser --repo testuser/allowed; then + if grep -q -- '^argv repos/testuser/allowed/branches$' "$t5_log" \ + && grep -q -- '^argv --paginate$' "$t5_log" \ + && grep -q -- '^argv --jq$' "$t5_log"; then + ok "T5 branch listing passes --paginate/--jq as separate gh api arguments" + else + bad "T5 branch listing embedded flags in the endpoint (real gh 404s) (see $t5_log)" + fi +else + bad "T5 exited non-zero (see $t5_log.err)" +fi +rm -rf "$t5_dir" + echo "---" echo "pass=$pass fail=$fail" if [ "$fail" -ne 0 ]; then From 9c5a842e433b0a2165153e0ad2e8fd58b2db9c9d Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:53:53 +0200 Subject: [PATCH 03/52] fix(benchmark): reuse validated baselines and pin fixture image inputs AI-01: validate baseline model, fixture version/IDs, result shape and age server-side, then reuse actual compatible current-model cases without provider calls. Invalid/stale baselines fall back truthfully with accurate call budgets. Same-model runs deduplicate; valid baselines make 0 calls (same-model) or candidate-only calls. AI-02: replace 10 random loremflickr inputs with pinned stable Commons files so every compared model receives identical input. Record lowercase SHA-256 of checked-in thumbnails plus attribution, expose imageUrl/hash in fixture JSON, and version the set as 2026-09-17-pinned-v1 to invalidate incompatible caches. Client cache moves v2->v3 and checks version/length. Tests use stub providers with call counters; no paid gateway calls. (cherry picked from commit 135ce6d714611e3f0f92ad52fdf0f727fb741482) --- apps/backend/src/legacy_api.rs | 158 ++++++- .../src/legacy_api/benchmark_fixtures.rs | 69 ++- apps/backend/src/legacy_api/tests/mod.rs | 445 +++++++++++++++++- .../components/admin-ai-benchmark-client.tsx | 19 +- apps/web/lib/ai-model-benchmark.ts | 11 + .../unit/admin-ai-benchmark-client.test.ts | 109 ++++- 6 files changed, 736 insertions(+), 75 deletions(-) diff --git a/apps/backend/src/legacy_api.rs b/apps/backend/src/legacy_api.rs index 437e523a..bfc49529 100644 --- a/apps/backend/src/legacy_api.rs +++ b/apps/backend/src/legacy_api.rs @@ -42,6 +42,16 @@ const FOOD_PHOTO_REQUEST_TIMEOUT: Duration = Duration::from_secs(25); const BENCHMARK_ROUTE_RUNTIME_BUDGET_MS: u64 = 270_000; const BENCHMARK_RUN_LOCK_TTL: Duration = Duration::from_secs(300); +/// Pinned fixture-set identity (AI-02). Bump whenever any fixture id, provider +/// image URL, expected macros, or checked-in thumbnail bytes change. Baselines +/// carrying any other version are rejected so incompatible caches cannot be +/// reused (AI-01). +pub(super) const BENCHMARK_FIXTURE_SET_VERSION: &str = "2026-09-17-pinned-v1"; +/// Baseline reuse window, matching the admin client's 24h cache. +const BENCHMARK_BASELINE_TTL: Duration = Duration::from_secs(24 * 60 * 60); +/// Clock-skew tolerance for baseline `createdAt` values minted by client clocks. +const BENCHMARK_BASELINE_FUTURE_TOLERANCE: Duration = Duration::from_secs(5 * 60); + /// `generation` keeps an overrunning run's release from freeing its successor (API-08). #[derive(Clone, Copy)] struct BenchmarkRun { @@ -1480,6 +1490,75 @@ fn configured_food_photo_models(config: &crate::config::Config) -> Vec { seen } +struct ValidatedBaseline { + created_at: String, + results: Vec, +} + +/// Server-side baseline validation (AI-01). A baseline is reusable only when +/// every identity matches the current run: configured current model, pinned +/// fixture-set version (AI-02), ordered fixture ids, result shape, and age. +/// Anything else is rejected so `usedBaseline` stays truthful and the +/// user-visible call budget remains accurate. +fn validate_benchmark_baseline( + baseline: &Value, + current_model: &str, + fixtures: &[&BenchmarkFixture], +) -> Option { + let record = baseline.as_object()?; + if record.get("currentModel").and_then(Value::as_str) != Some(current_model) { + return None; + } + if record.get("fixtureVersion").and_then(Value::as_str) != Some(BENCHMARK_FIXTURE_SET_VERSION) { + return None; + } + let expected_ids: Vec<&str> = fixtures.iter().map(|fixture| fixture.id).collect(); + let baseline_ids: Vec<&str> = record + .get("fixtureIds") + .and_then(Value::as_array) + .map(|ids| ids.iter().filter_map(Value::as_str).collect())?; + if baseline_ids != expected_ids { + return None; + } + let results = record.get("results").and_then(Value::as_array)?.clone(); + if results.len() != fixtures.len() { + return None; + } + for result in &results { + let result = result.as_object()?; + if result.get("ok").and_then(Value::as_bool) != Some(true) { + return None; + } + if result.get("wasSkipped").and_then(Value::as_bool) == Some(true) { + return None; + } + if result.get("model").and_then(Value::as_str) != Some(current_model) { + return None; + } + if !result + .get("estimate") + .is_some_and(|estimate| estimate.is_object()) + { + return None; + } + } + let created_at = record.get("createdAt").and_then(Value::as_str)?.to_string(); + let created = chrono::DateTime::parse_from_rfc3339(&created_at) + .ok()? + .with_timezone(&chrono::Utc); + let now = chrono::Utc::now(); + if created > now + BENCHMARK_BASELINE_FUTURE_TOLERANCE { + return None; + } + if now.signed_duration_since(created).to_std().ok()? > BENCHMARK_BASELINE_TTL { + return None; + } + Some(ValidatedBaseline { + created_at, + results, + }) +} + async fn run_macro_benchmark( state: &AppState, user_id: &str, @@ -1492,26 +1571,67 @@ async fn run_macro_benchmark( .first() .cloned() .unwrap_or_else(|| DEFAULT_FOOD_PHOTO_MODELS[0].to_string()); + let state_clone = state.clone(); + let user_id_owned = user_id.to_string(); + let runner = move |fixture: BenchmarkFixture, model: String| { + let state = state_clone.clone(); + let user_id = user_id_owned.clone(); + async move { run_fixture_for_model(&state, &fixture, &model, &user_id).await } + }; + run_macro_benchmark_with_runner( + current_model, + candidate_model, + fixture_limit, + mode, + baseline, + runner, + ) + .await +} + +async fn run_macro_benchmark_with_runner( + current_model: String, + candidate_model: &str, + fixture_limit: usize, + mode: &str, + baseline: Option, + runner: F, +) -> Result +where + F: Fn(BenchmarkFixture, String) -> Fut, + Fut: Future, +{ let fixtures = BENCHMARK_FIXTURES .iter() .take(fixture_limit) .collect::>(); let compared_same_model = mode == "compare" && candidate_model == current_model; - let baseline_created_at = baseline - .as_ref() - .and_then(|value| value.get("createdAt")) - .and_then(Value::as_str) - .filter(|_| mode == "compare") - .map(str::to_string); - let used_baseline = baseline_created_at.is_some(); + let validated = if mode == "compare" { + baseline + .as_ref() + .and_then(|value| validate_benchmark_baseline(value, ¤t_model, &fixtures)) + } else { + None + }; + let (used_baseline, baseline_created_at, reused_current) = match validated { + Some(valid) => (true, Some(valid.created_at), Some(valid.results)), + None => (false, None, None), + }; let deadline = Instant::now() + Duration::from_millis(BENCHMARK_ROUTE_RUNTIME_BUDGET_MS); let mut cases = Vec::new(); let mut current_results = Vec::new(); let mut candidate_results = Vec::new(); - for fixture in &fixtures { + for (index, fixture) in fixtures.iter().enumerate() { + // A validated baseline reuses the actual compatible current-model cases + // without any provider call (AI-01). The same `fixture.image_url` + // string reaches every compared model, so inputs are identical (AI-02). let current = if mode == "candidate_only" { skipped_result(¤t_model, "Not run in candidate-only mode.", "unknown") + } else if let Some(ref reused) = reused_current { + reused.get(index).cloned().unwrap_or_else(|| { + skipped_result(¤t_model, "Baseline case missing.", "unknown") + }) } else if Instant::now() >= deadline { skipped_result( ¤t_model, @@ -1519,7 +1639,7 @@ async fn run_macro_benchmark( "skipped", ) } else { - run_fixture_for_model(state, fixture, ¤t_model, user_id).await + runner(**fixture, current_model.clone()).await }; let candidate = if compared_same_model { current.clone() @@ -1530,7 +1650,7 @@ async fn run_macro_benchmark( "skipped", ) } else { - run_fixture_for_model(state, fixture, candidate_model, user_id).await + runner(**fixture, candidate_model.to_string()).await }; current_results.push(current.clone()); candidate_results.push(candidate.clone()); @@ -1539,6 +1659,8 @@ async fn run_macro_benchmark( "fixtureName": fixture.name, "servingDescription": fixture.serving_description, "thumbnailUrl": format!("/benchmark-foods/{}", fixture.asset_file_name), + "imageUrl": fixture.image_url, + "imageSha256": fixture.image_sha256, "imageSourceUrl": fixture.image_source_url, "expected": fixture.expected_json(), "expectedSource": fixture.expected_source, @@ -1553,6 +1675,7 @@ async fn run_macro_benchmark( "candidateModel": candidate_model, "fixtureCount": fixtures.len(), "totalFixtureCount": BENCHMARK_FIXTURES.len(), + "fixtureVersion": BENCHMARK_FIXTURE_SET_VERSION, "comparedSameModel": compared_same_model, "mode": mode, "usedBaseline": used_baseline, @@ -2056,10 +2179,19 @@ struct BenchmarkFixture { name: &'static str, serving_description: &'static str, asset_file_name: &'static str, - /// Direct file URL fetched by the model; article URLs serve `text/html`, hence the pasta fixture's encoded parens. + /// Pinned stable file URL sent to the provider. Random per-request hosts + /// (for example loremflickr) must never be used: every compared model + /// receives this exact URL string for the fixture (AI-02). image_url: &'static str, - /// Commons article page shown in the admin UI for attribution. Never fetched. + /// Attribution page shown in the admin UI. Never fetched. image_source_url: &'static str, + /// SHA-256 (lowercase hex) of the checked-in thumbnail + /// `apps/web/public/benchmark-foods/`. The thumbnail is + /// the admin-visible frozen reference; provider URLs above are pinned + /// stable Commons files (not random). Thumbnail and provider photos may be + /// different shots of the same food type; ground truth remains + /// serving-based. Recorded so fixture changes invalidate baselines. + image_sha256: &'static str, expected_source: &'static str, category: &'static str, calories: f64, @@ -2085,6 +2217,8 @@ impl BenchmarkFixture { "servingDescription": self.serving_description, "assetFileName": self.asset_file_name, "thumbnailUrl": format!("/benchmark-foods/{}", self.asset_file_name), + "imageUrl": self.image_url, + "imageSha256": self.image_sha256, "imageSourceUrl": self.image_source_url, "expected": self.expected_json(), "expectedSource": self.expected_source, diff --git a/apps/backend/src/legacy_api/benchmark_fixtures.rs b/apps/backend/src/legacy_api/benchmark_fixtures.rs index aa1cb5c6..63af9dc5 100644 --- a/apps/backend/src/legacy_api/benchmark_fixtures.rs +++ b/apps/backend/src/legacy_api/benchmark_fixtures.rs @@ -10,6 +10,17 @@ pub(super) const CATEGORIES: [&str; 7] = [ "legume", ]; +/// Pinned benchmark inputs (AI-02). The first eight fixtures were already +/// stable Commons files. The remaining ten previously used random +/// `loremflickr.com` URLs (a different photo per request) and are now pinned +/// to stable Commons files so every compared model receives identical input. +/// `image_sha256` is the SHA-256 (lowercase hex) of the checked-in thumbnail +/// `apps/web/public/benchmark-foods/`, the admin-visible +/// frozen reference. Provider URLs are pinned stable (not random); thumbnail +/// and provider photos may be different shots of the same food type. Ground +/// truth remains serving-based, so it stays valid across shots. Any fixture +/// change must bump `BENCHMARK_FIXTURE_SET_VERSION` so old baselines are +/// rejected (AI-01). pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ BenchmarkFixture { id: "medium-banana", @@ -18,6 +29,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "banana.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/8/8a/Banana-Single.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:Banana-Single.jpg", + image_sha256: "a0fff94e4521a686bd3acfd3fe4582dee37da998445231115ac519bffa6e3a6c", expected_source: "USDA FoodData Central, one medium banana, rounded.", category: "fruit", calories: 105.0, @@ -32,6 +44,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "apple.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/1/15/Red_Apple.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:Red_Apple.jpg", + image_sha256: "7d3406d0449f226f2db014ac7d99a5dab7757afaeccaad52bd0091b7ee1363cf", expected_source: "USDA FoodData Central, one medium apple with skin, rounded.", category: "fruit", calories: 95.0, @@ -46,6 +59,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "hard-boiled-egg.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/c/c3/Hard_boiled_egg.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:Hard_boiled_egg.jpg", + image_sha256: "154e509aecdcb3f2f0c7b2ebb32cba7818b2c6151b5d9a49a63b360736a87d84", expected_source: "USDA FoodData Central, one large hard-boiled egg, rounded.", category: "protein", calories: 78.0, @@ -60,6 +74,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "orange.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/4/43/Ambersweet_oranges.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:Ambersweet_oranges.jpg", + image_sha256: "1b3b8ecf2dbb10fdd6c1d35673d10a2288eb6276b46414025c5c695bb9cd36db", expected_source: "USDA FoodData Central, one medium orange, rounded.", category: "fruit", calories: 62.0, @@ -74,6 +89,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "white-rice.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/1/16/Cooked_white_rice.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:Cooked_white_rice.jpg", + image_sha256: "48b18a855c94f249e98419ad93ea54a90c8cf3331db66bea7f32ea56510c733d", expected_source: "USDA FoodData Central, one cup cooked white rice, rounded.", category: "grain", calories: 205.0, @@ -88,6 +104,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "pasta.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/3/3f/%28Pasta%29_by_David_Adam_Kess_%28pic.2%29.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:(Pasta)_by_David_Adam_Kess_(pic.2).jpg", + image_sha256: "44dd209f13fdbb4dae4ea7f39dc2fcb75609954be3b0aeb48f2207f34a7a711a", expected_source: "USDA FoodData Central, one cup cooked spaghetti, rounded.", category: "grain", calories: 221.0, @@ -102,6 +119,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "avocado.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/f/f0/Liat_Portal_for_Foodie_Disorder_-_Avocado_halves.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:Liat_Portal_for_Foodie_Disorder_-_Avocado_halves.jpg", + image_sha256: "1133824cda9e9cb4bbf74cb4f474c5532ca35a6cc40e8a6de3fa154d22fb1a32", expected_source: "USDA FoodData Central, half medium avocado, rounded.", category: "fat", calories: 120.0, @@ -116,6 +134,7 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ asset_file_name: "broccoli.jpg", image_url: "https://upload.wikimedia.org/wikipedia/commons/4/48/Broccoli_florets_on_ice.jpg", image_source_url: "https://commons.wikimedia.org/wiki/File:Broccoli_florets_on_ice.jpg", + image_sha256: "4683949e5d303455720ed36d98bb29ec200f4b8cc676d7fabbf242e461cf3f4e", expected_source: "USDA FoodData Central, one cup cooked broccoli, rounded.", category: "vegetable", calories: 55.0, @@ -128,8 +147,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Medium carrot", serving_description: "One medium raw carrot.", asset_file_name: "carrot.jpg", - image_url: "https://loremflickr.com/512/512/carrot,food", - image_source_url: "https://loremflickr.com/512/512/carrot,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/e/e6/Carrots.JPG", + image_source_url: "https://commons.wikimedia.org/wiki/File:Carrots.JPG", + image_sha256: "271485219ab11f2dff03d0c6b29faf338558435a6a4fca63003829a6e85d058b", expected_source: "USDA FoodData Central, one medium raw carrot, rounded.", category: "vegetable", calories: 25.0, @@ -142,8 +162,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "White bread slice", serving_description: "One regular slice white bread.", asset_file_name: "white-bread.jpg", - image_url: "https://loremflickr.com/512/512/toast,food", - image_source_url: "https://loremflickr.com/512/512/toast,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/1/1f/White_bread.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:White_bread.jpg", + image_sha256: "a6c3e042a20cf86d81ed5ec614b8346d7d531e50b6ba782d7f9a80771eb137ca", expected_source: "USDA FoodData Central, one slice white bread, rounded.", category: "grain", calories: 75.0, @@ -156,8 +177,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Cheddar cheese", serving_description: "One ounce cheddar cheese.", asset_file_name: "cheddar.jpg", - image_url: "https://loremflickr.com/512/512/cheddar,food", - image_source_url: "https://loremflickr.com/512/512/cheddar,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/4/45/Cheese.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Cheese.jpg", + image_sha256: "b584ca499be7c1b6486accc652f930bbadf474b00fe45113fa1d0b5c9d621dda", expected_source: "USDA FoodData Central, one ounce cheddar cheese, rounded.", category: "dairy", calories: 113.0, @@ -170,8 +192,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Raw almonds", serving_description: "One ounce raw almonds.", asset_file_name: "almonds.jpg", - image_url: "https://loremflickr.com/512/512/almonds,food", - image_source_url: "https://loremflickr.com/512/512/almonds,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/c/c0/Almonds.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Almonds.jpg", + image_sha256: "67951f738dd255a2af250446f9200843c6a4e1ca1cac53b51284c6109724b0d5", expected_source: "USDA FoodData Central, one ounce raw almonds, rounded.", category: "fat", calories: 164.0, @@ -184,8 +207,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Rolled oats", serving_description: "Forty grams dry rolled oats.", asset_file_name: "oats.jpg", - image_url: "https://loremflickr.com/512/512/oats,food", - image_source_url: "https://loremflickr.com/512/512/oats,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/3/39/Oatmeal.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Oatmeal.jpg", + image_sha256: "12bece8a06517d65109da91d9f32688cb3e786996d022f4e18f3297d8a8b41ea", expected_source: "Common nutrition label serving, 40g dry rolled oats.", category: "grain", calories: 150.0, @@ -198,8 +222,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Cooked shrimp", serving_description: "One hundred grams cooked shrimp.", asset_file_name: "shrimp.jpg", - image_url: "https://loremflickr.com/512/512/prawn,food", - image_source_url: "https://loremflickr.com/512/512/prawn,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/a/a6/Shrimp.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Shrimp.jpg", + image_sha256: "96a46c093ed74aec1037d83b2ba3e8d272ea97f38dbdcbdf30477bcb22f80b51", expected_source: "USDA FoodData Central, 100g cooked shrimp, rounded.", category: "protein", calories: 99.0, @@ -212,8 +237,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Cooked salmon", serving_description: "One hundred grams cooked Atlantic salmon.", asset_file_name: "salmon.jpg", - image_url: "https://loremflickr.com/512/512/salmon,food", - image_source_url: "https://loremflickr.com/512/512/salmon,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/7/72/Salmon.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Salmon.jpg", + image_sha256: "78b118d367289786c1a68ecd3f72605af09fc849a56935bc71e9f63a60f05393", expected_source: "USDA FoodData Central, 100g cooked Atlantic salmon, rounded.", category: "protein", calories: 206.0, @@ -226,8 +252,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Cooked lentils", serving_description: "One cup cooked lentils.", asset_file_name: "lentils.jpg", - image_url: "https://loremflickr.com/512/512/lentils,food", - image_source_url: "https://loremflickr.com/512/512/lentils,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/3/37/Lentil_soup.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Lentil_soup.jpg", + image_sha256: "8e908b251bc84f88ead174849a3115815a7e6e1ace9e91f8ca882e201b52f997", expected_source: "USDA FoodData Central, one cup cooked lentils, rounded.", category: "legume", calories: 230.0, @@ -240,8 +267,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Whole milk", serving_description: "One cup whole milk.", asset_file_name: "whole-milk.jpg", - image_url: "https://loremflickr.com/512/512/milk,food", - image_source_url: "https://loremflickr.com/512/512/milk,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/a/ad/Glass_of_milk_on_tablecloth.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Glass_of_milk_on_tablecloth.jpg", + image_sha256: "973c07f5db5680a51e3c0c93781e3f1ddd4fa868efdc9305cb4763439df84cfd", expected_source: "USDA FoodData Central, one cup whole milk, rounded.", category: "dairy", calories: 149.0, @@ -254,8 +282,9 @@ pub(super) const BENCHMARK_FIXTURES: &[BenchmarkFixture] = &[ name: "Greek yogurt", serving_description: "One 170g serving plain nonfat Greek yogurt.", asset_file_name: "greek-yogurt.jpg", - image_url: "https://loremflickr.com/512/512/yogurt,food", - image_source_url: "https://loremflickr.com/512/512/yogurt,food", + image_url: "https://upload.wikimedia.org/wikipedia/commons/0/05/Yogurt.jpg", + image_source_url: "https://commons.wikimedia.org/wiki/File:Yogurt.jpg", + image_sha256: "f0028fa7c9070149dc3f39163b3484a5a43a0bf56c97e5427daa3c778ae68680", expected_source: "USDA FoodData Central, 170g plain nonfat Greek yogurt, rounded.", category: "dairy", calories: 100.0, diff --git a/apps/backend/src/legacy_api/tests/mod.rs b/apps/backend/src/legacy_api/tests/mod.rs index 70bd2319..fc2ac3d5 100644 --- a/apps/backend/src/legacy_api/tests/mod.rs +++ b/apps/backend/src/legacy_api/tests/mod.rs @@ -631,6 +631,7 @@ fn truncating_a_clarification_never_splits_a_character() { #[test] fn every_benchmark_fixture_points_at_a_direct_image_file() { // CONCERN-C3: an article page URL serves `text/html`, not the image, silently scoring models on a web page. + // AI-02: random per-request hosts are banned; every fixture must be a pinned stable file. for fixture in BENCHMARK_FIXTURES { assert!( !fixture.image_url.contains("/wiki/"), @@ -643,6 +644,12 @@ fn every_benchmark_fixture_points_at_a_direct_image_file() { "{}: image_url must be https", fixture.id ); + assert!( + !fixture.image_url.contains("loremflickr.com"), + "{}: random loremflickr inputs are not reproducible: {}", + fixture.id, + fixture.image_url + ); if fixture .image_source_url @@ -652,7 +659,7 @@ fn every_benchmark_fixture_points_at_a_direct_image_file() { fixture .image_url .starts_with("https://upload.wikimedia.org/wikipedia/commons/") - && fixture.image_url.ends_with(".jpg"), + && fixture.image_url.to_ascii_lowercase().ends_with(".jpg"), "{}: a Commons fixture must fetch the direct file URL, got {}", fixture.id, fixture.image_url @@ -662,31 +669,431 @@ fn every_benchmark_fixture_points_at_a_direct_image_file() { } #[test] -fn the_unreproducible_benchmark_fixtures_are_the_known_ten() { - // `loremflickr.com` redirects to a random photo per request; pinned so this set can't grow unnoticed. - let unreproducible = BENCHMARK_FIXTURES +fn benchmark_fixtures_are_pinned_with_content_hashes() { + // AI-02: freeze verified bytes with hashes and attribution; any change must + // bump `BENCHMARK_FIXTURE_SET_VERSION` so old baselines are rejected. + use std::collections::HashSet; + + assert_eq!(BENCHMARK_FIXTURES.len(), 18); + assert!(!BENCHMARK_FIXTURE_SET_VERSION.is_empty()); + + let mut ids = HashSet::new(); + for fixture in BENCHMARK_FIXTURES { + assert!( + ids.insert(fixture.id), + "duplicate fixture id {}", + fixture.id + ); + assert!( + CATEGORIES.contains(&fixture.category), + "{}: unknown category {}", + fixture.id, + fixture.category + ); + assert!( + fixture.image_sha256.len() == 64 + && fixture + .image_sha256 + .chars() + .all(|ch| ch.is_ascii_hexdigit()) + && fixture.image_sha256 == fixture.image_sha256.to_ascii_lowercase(), + "{}: image_sha256 must be lowercase hex (64 chars), got {}", + fixture.id, + fixture.image_sha256 + ); + assert!( + !fixture.image_source_url.is_empty(), + "{}: missing image attribution", + fixture.id + ); + let rendered = fixture.as_json(); + assert_eq!( + rendered.get("imageUrl").and_then(Value::as_str), + Some(fixture.image_url), + "{}: as_json must expose the exact provider input", + fixture.id + ); + assert_eq!( + rendered.get("imageSha256").and_then(Value::as_str), + Some(fixture.image_sha256), + "{}: as_json must expose the content hash", + fixture.id + ); + } +} + +fn stub_success_case(model: &str) -> Value { + json!({ + "model": model, + "ok": true, + "latencyMs": 42, + "estimate": { + "label": "stub food", + "caloriesKcal": 100, + "proteinG": 5.0, + "carbsG": 10.0, + "fatG": 3.0, + "confidence": 0.9, + "notes": [] + }, + "absoluteError": { + "caloriesKcal": 0, + "proteinG": 0.0, + "carbsG": 0.0, + "fatG": 0.0 + }, + "normalizedErrorPct": 0.0, + "error": Value::Null + }) +} + +fn baseline_for_current( + current_model: &str, + fixture_limit: usize, + created_at: &str, + mutate: impl FnOnce(&mut serde_json::Map), +) -> Value { + let fixtures = BENCHMARK_FIXTURES .iter() - .filter(|fixture| fixture.image_url.contains("loremflickr.com")) - .map(|fixture| fixture.id) + .take(fixture_limit) .collect::>(); + let mut record = serde_json::Map::new(); + record.insert( + "currentModel".to_string(), + Value::String(current_model.to_string()), + ); + record.insert( + "fixtureVersion".to_string(), + Value::String(BENCHMARK_FIXTURE_SET_VERSION.to_string()), + ); + record.insert( + "fixtureIds".to_string(), + json!( + fixtures + .iter() + .map(|fixture| fixture.id) + .collect::>() + ), + ); + record.insert( + "results".to_string(), + json!( + fixtures + .iter() + .map(|_| stub_success_case(current_model)) + .collect::>() + ), + ); + record.insert( + "createdAt".to_string(), + Value::String(created_at.to_string()), + ); + mutate(&mut record); + Value::Object(record) +} + +fn fresh_created_at() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +#[tokio::test] +async fn valid_same_model_baseline_reuses_without_provider_calls() { + // AI-01 acceptance: valid same-model baseline makes 0 calls. + let current_model = "current/test-model"; + let baseline = baseline_for_current(current_model, 4, &fresh_created_at(), |_| {}); + let calls = Arc::new(AtomicUsize::new(0)); + let seen_urls = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + let calls_clone = calls.clone(); + let seen_clone = seen_urls.clone(); + + let result = run_macro_benchmark_with_runner( + current_model.to_string(), + current_model, + 4, + "compare", + Some(baseline.clone()), + move |fixture: BenchmarkFixture, model: String| { + let calls = calls_clone.clone(); + let seen = seen_clone.clone(); + let fixture_id = fixture.id.to_string(); + let image_url = fixture.image_url.to_string(); + let model = model.to_string(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + seen.lock() + .unwrap_or_else(|error| error.into_inner()) + .push((model.clone(), image_url)); + let _ = fixture_id; + stub_success_case(&model) + } + }, + ) + .await + .expect("benchmark should succeed"); assert_eq!( - unreproducible, - vec![ - "medium-carrot", - "white-bread-slice", - "cheddar-ounce", - "almonds-ounce", - "rolled-oats-40g", - "cooked-shrimp-100g", - "cooked-salmon-100g", - "cooked-lentils-cup", - "whole-milk-cup", - "nonfat-greek-yogurt-170g", - ] + calls.load(Ordering::SeqCst), + 0, + "valid same-model baseline must make 0 provider calls" + ); + assert_eq!( + result.get("usedBaseline").and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + result.get("baselineCreatedAt").and_then(Value::as_str), + baseline.get("createdAt").and_then(Value::as_str) + ); + assert_eq!( + result.get("fixtureVersion").and_then(Value::as_str), + Some(BENCHMARK_FIXTURE_SET_VERSION) ); } +#[tokio::test] +async fn valid_different_model_baseline_runs_only_candidate_calls() { + // AI-01 acceptance: valid baseline with a different candidate makes only candidate calls. + let current_model = "current/test-model"; + let candidate_model = "candidate/test-model"; + let baseline = baseline_for_current(current_model, 4, &fresh_created_at(), |_| {}); + let calls = Arc::new(AtomicUsize::new(0)); + let seen_models = Arc::new(Mutex::new(Vec::::new())); + let calls_clone = calls.clone(); + let seen_clone = seen_models.clone(); + + let result = run_macro_benchmark_with_runner( + current_model.to_string(), + candidate_model, + 4, + "compare", + Some(baseline), + move |_fixture: BenchmarkFixture, model: String| { + let calls = calls_clone.clone(); + let seen = seen_clone.clone(); + let model = model.to_string(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + seen.lock() + .unwrap_or_else(|error| error.into_inner()) + .push(model.clone()); + stub_success_case(&model) + } + }, + ) + .await + .expect("benchmark should succeed"); + + assert_eq!( + calls.load(Ordering::SeqCst), + 4, + "valid baseline must run only the 4 candidate calls" + ); + let seen = seen_models + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + assert!( + seen.iter().all(|model| model == candidate_model), + "current-model calls must be skipped, got {seen:?}" + ); + assert_eq!( + result.get("usedBaseline").and_then(Value::as_bool), + Some(true) + ); +} + +#[tokio::test] +async fn invalid_or_stale_baselines_are_rejected_truthfully() { + // AI-01 acceptance: absent/invalid/stale baselines follow an explicit truthful budget. + let current_model = "current/test-model"; + let candidate_model = "candidate/test-model"; + let stale = (chrono::Utc::now() - chrono::Duration::hours(25)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let cases: Vec<(&str, Option)> = vec![ + ("absent", None), + ( + "wrong-model", + Some(baseline_for_current( + "other/model", + 4, + &fresh_created_at(), + |_| {}, + )), + ), + ( + "wrong-version", + Some(baseline_for_current( + current_model, + 4, + &fresh_created_at(), + |record| { + record.insert( + "fixtureVersion".to_string(), + Value::String("stale-version".to_string()), + ); + }, + )), + ), + ( + "wrong-ids", + Some(baseline_for_current( + current_model, + 4, + &fresh_created_at(), + |record| { + record.insert("fixtureIds".to_string(), json!(["wrong-id"])); + }, + )), + ), + ( + "stale", + Some(baseline_for_current(current_model, 4, &stale, |_| {})), + ), + ( + "failed-row", + Some(baseline_for_current( + current_model, + 4, + &fresh_created_at(), + |record| { + let mut results = record + .get("results") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if let Some(first) = results.first_mut() { + if let Some(object) = first.as_object_mut() { + object.insert("ok".to_string(), Value::Bool(false)); + } + } + record.insert("results".to_string(), Value::Array(results)); + }, + )), + ), + ]; + + for (name, baseline) in cases { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = calls.clone(); + let result = run_macro_benchmark_with_runner( + current_model.to_string(), + candidate_model, + 4, + "compare", + baseline, + move |_fixture: BenchmarkFixture, model: String| { + let calls = calls_clone.clone(); + let model = model.to_string(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + stub_success_case(&model) + } + }, + ) + .await + .expect("benchmark should succeed"); + + assert_eq!( + result.get("usedBaseline").and_then(Value::as_bool), + Some(false), + "{name} baseline must not be marked reused" + ); + assert_eq!( + result.get("baselineCreatedAt").and_then(Value::as_str), + None, + "{name} baseline must not report a reused timestamp" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 8, + "{name} baseline must fall back to the full 4+4 call budget" + ); + } +} + +#[tokio::test] +async fn every_compared_model_receives_identical_image_inputs() { + // AI-02 acceptance: fake provider captures actual image inputs for every + // model; inputs are identical and fixture identities invalidate caches. + let current_model = "current/test-model"; + let candidate_model = "candidate/test-model"; + let seen = Arc::new(Mutex::new(Vec::<(String, String, String)>::new())); + let seen_clone = seen.clone(); + + let result = run_macro_benchmark_with_runner( + current_model.to_string(), + candidate_model, + 8, + "compare", + None, + move |fixture: BenchmarkFixture, model: String| { + let seen = seen_clone.clone(); + let fixture_id = fixture.id.to_string(); + let image_url = fixture.image_url.to_string(); + let image_sha = fixture.image_sha256.to_string(); + let model = model.to_string(); + async move { + seen.lock() + .unwrap_or_else(|error| error.into_inner()) + .push((model.clone(), fixture_id, image_url.clone())); + let _ = image_sha; + stub_success_case(&model) + } + }, + ) + .await + .expect("benchmark should succeed"); + + let seen = seen + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + assert_eq!(seen.len(), 16, "8 fixtures x 2 models without a baseline"); + for fixture in BENCHMARK_FIXTURES.iter().take(8) { + let inputs: Vec<&(String, String, String)> = seen + .iter() + .filter(|(_, fixture_id, _)| fixture_id == fixture.id) + .collect(); + assert_eq!( + inputs.len(), + 2, + "{}: both models must be called once", + fixture.id + ); + assert_eq!( + inputs[0].2, inputs[1].2, + "{}: image inputs must be identical across models", + fixture.id + ); + assert_eq!( + inputs[0].2, fixture.image_url, + "{}: captured input must equal the pinned fixture URL", + fixture.id + ); + } + // Fixture identities are part of the result so incompatible caches invalidate. + let fixtures_json = result + .get("fixtures") + .and_then(Value::as_array) + .expect("result must carry fixtures"); + assert_eq!(fixtures_json.len(), 8); + for (index, rendered) in fixtures_json.iter().enumerate() { + let expected = &BENCHMARK_FIXTURES[index]; + assert_eq!( + rendered.get("id").and_then(Value::as_str), + Some(expected.id) + ); + assert_eq!( + rendered.get("imageUrl").and_then(Value::as_str), + Some(expected.image_url) + ); + assert_eq!( + rendered.get("imageSha256").and_then(Value::as_str), + Some(expected.image_sha256) + ); + } +} + /// `BENCHMARK_LOCK` is process-global, so the tests that drive it have to take turns. static BENCHMARK_LOCK_TESTS: Mutex<()> = Mutex::new(()); diff --git a/apps/web/components/admin-ai-benchmark-client.tsx b/apps/web/components/admin-ai-benchmark-client.tsx index ef09aca8..7d5a2c18 100644 --- a/apps/web/components/admin-ai-benchmark-client.tsx +++ b/apps/web/components/admin-ai-benchmark-client.tsx @@ -8,6 +8,7 @@ import type { MacroBenchmarkModelSummary, MacroBenchmarkResult, } from "@/lib/ai-model-benchmark"; +import { BENCHMARK_FIXTURE_VERSION } from "@/lib/ai-model-benchmark"; import type { AnalyzeFoodPhotoFailureKind } from "@/lib/ai-food-photo"; type ApiResponse = @@ -20,7 +21,10 @@ type ApiResponse = error: string; }; -const BASELINE_CACHE_PREFIX = "macro-benchmark-baseline:v2:"; +// v3 invalidates v2 caches that lack `fixtureVersion` (AI-01/AI-02): old +// baselines with random loremflickr inputs or timestamp-only validation must +// never be reused. +const BASELINE_CACHE_PREFIX = "macro-benchmark-baseline:v3:"; const BASELINE_TTL_MS = 24 * 60 * 60 * 1000; const FAILURE_LABELS: Record = { @@ -69,8 +73,11 @@ function isFreshBaseline(value: unknown): value is MacroBenchmarkBaseline & { return ( typeof record.currentModel === "string" && typeof record.fixtureLimit === "number" && + record.fixtureVersion === BENCHMARK_FIXTURE_VERSION && Array.isArray(record.fixtureIds) && Array.isArray(record.results) && + (record.fixtureIds as unknown[]).length === record.fixtureLimit && + (record.results as unknown[]).length === record.fixtureLimit && Number.isFinite(createdAt) && createdAt <= now && now - createdAt <= BASELINE_TTL_MS @@ -118,6 +125,10 @@ export function shouldCacheBenchmarkBaseline(result: MacroBenchmarkResult) { return false; } + if (result.fixtureVersion !== BENCHMARK_FIXTURE_VERSION) { + return false; + } + if (result.cases.length === 0) { return false; } @@ -151,7 +162,10 @@ export function getBenchmarkCallCountText(params: { return `This run will make up to ${params.fixtureLimit} AI provider calls.`; } - if (params.cachedBaseline?.currentModel === params.currentModel) { + if ( + params.cachedBaseline?.currentModel === params.currentModel && + params.cachedBaseline?.fixtureVersion === BENCHMARK_FIXTURE_VERSION + ) { if (params.model.trim() === params.currentModel) { return "This run can use the cached baseline and may make 0 AI provider calls."; } @@ -176,6 +190,7 @@ function writeBaselineCache(result: MacroBenchmarkResult) { createdAt, currentModel: result.currentModel, fixtureLimit: result.fixtureCount, + fixtureVersion: result.fixtureVersion, fixtureIds: result.cases.map((item) => item.fixtureId), results: result.cases.map((item) => item.current), }; diff --git a/apps/web/lib/ai-model-benchmark.ts b/apps/web/lib/ai-model-benchmark.ts index 74163581..42a32656 100644 --- a/apps/web/lib/ai-model-benchmark.ts +++ b/apps/web/lib/ai-model-benchmark.ts @@ -25,6 +25,8 @@ type MacroBenchmarkFixture = { servingDescription: string; assetFileName: string; thumbnailUrl: string; + imageUrl: string; + imageSha256: string; imageSourceUrl: string; expected: MacroBenchmarkMacros; expectedSource: string; @@ -49,6 +51,8 @@ type MacroBenchmarkCaseResult = { fixtureName: string; servingDescription: string; thumbnailUrl: string; + imageUrl: string; + imageSha256: string; imageSourceUrl: string; expected: MacroBenchmarkMacros; expectedSource: string; @@ -59,9 +63,15 @@ type MacroBenchmarkCaseResult = { type MacroBenchmarkMode = "compare" | "candidate_only"; +/// Pinned fixture-set identity. Must match the backend +/// `BENCHMARK_FIXTURE_SET_VERSION` (AI-02). Baselines carrying any other +/// version are rejected so incompatible caches cannot be reused (AI-01). +export const BENCHMARK_FIXTURE_VERSION = "2026-09-17-pinned-v1"; + export type MacroBenchmarkBaseline = { currentModel: string; fixtureIds: string[]; + fixtureVersion: string; results: MacroBenchmarkModelCaseResult[]; createdAt: string; }; @@ -86,6 +96,7 @@ export type MacroBenchmarkResult = { candidateModel: string; fixtureCount: number; totalFixtureCount: number; + fixtureVersion: string; comparedSameModel: boolean; mode: MacroBenchmarkMode; usedBaseline: boolean; diff --git a/apps/web/tests/unit/admin-ai-benchmark-client.test.ts b/apps/web/tests/unit/admin-ai-benchmark-client.test.ts index 3c8d9caf..cc370efb 100644 --- a/apps/web/tests/unit/admin-ai-benchmark-client.test.ts +++ b/apps/web/tests/unit/admin-ai-benchmark-client.test.ts @@ -5,6 +5,7 @@ import { readCachedBaseline, shouldCacheBenchmarkBaseline, } from "@/components/admin-ai-benchmark-client"; +import { BENCHMARK_FIXTURE_VERSION } from "@/lib/ai-model-benchmark"; import type { MacroBenchmarkModelCaseResult, MacroBenchmarkResult, @@ -53,6 +54,7 @@ function failedResult( function benchmarkResult(params?: { currentResults?: MacroBenchmarkModelCaseResult[]; mode?: MacroBenchmarkResult["mode"]; + fixtureVersion?: string; }): MacroBenchmarkResult { const currentResults = params?.currentResults ?? [ successResult, @@ -73,6 +75,7 @@ function benchmarkResult(params?: { candidateModel: "candidate/free", fixtureCount: currentResults.length, totalFixtureCount: currentResults.length, + fixtureVersion: params?.fixtureVersion ?? BENCHMARK_FIXTURE_VERSION, comparedSameModel: false, mode: params?.mode ?? "compare", usedBaseline: false, @@ -83,6 +86,8 @@ function benchmarkResult(params?: { fixtureName: `Fixture ${index}`, servingDescription: "One serving.", thumbnailUrl: "/benchmark-foods/test.jpg", + imageUrl: "https://upload.wikimedia.org/wikipedia/commons/test.jpg", + imageSha256: "a".repeat(64), imageSourceUrl: "https://example.com/test.jpg", expected: { caloriesKcal: 105, @@ -224,6 +229,14 @@ describe("shouldCacheBenchmarkBaseline", () => { ), ).toBe(false); }); + + it("rejects baselines with a stale fixture version", () => { + expect( + shouldCacheBenchmarkBaseline( + benchmarkResult({ fixtureVersion: "stale-version" }), + ), + ).toBe(false); + }); }); describe("readCachedBaseline", () => { @@ -231,47 +244,84 @@ describe("readCachedBaseline", () => { vi.unstubAllGlobals(); }); + function storageWith(entries: Record) { + vi.stubGlobal("window", { + localStorage: { + get length() { + return Object.keys(entries).length; + }, + key(index: number) { + return Object.keys(entries)[index] ?? null; + }, + getItem(key: string) { + return entries[key] ?? null; + }, + removeItem(key: string) { + delete entries[key]; + }, + }, + }); + } + it("requires an exact configured current-model match", () => { const createdAt = new Date().toISOString(); const storageEntries: Record = { - "macro-benchmark-baseline:v2:current/free:4": JSON.stringify({ + "macro-benchmark-baseline:v3:current/free:4": JSON.stringify({ currentModel: "current/free", createdAt, fixtureLimit: 4, - fixtureIds: ["fixture-0"], - results: [successResult], + fixtureVersion: BENCHMARK_FIXTURE_VERSION, + fixtureIds: ["fixture-0", "fixture-1", "fixture-2", "fixture-3"], + results: [successResult, successResult, successResult, successResult], }), - "macro-benchmark-baseline:v2:old/free:4": JSON.stringify({ + "macro-benchmark-baseline:v3:old/free:4": JSON.stringify({ currentModel: "old/free", createdAt: new Date(Date.now() + 1000).toISOString(), fixtureLimit: 4, - fixtureIds: ["fixture-0"], - results: [successResult], + fixtureVersion: BENCHMARK_FIXTURE_VERSION, + fixtureIds: ["fixture-0", "fixture-1", "fixture-2", "fixture-3"], + results: [successResult, successResult, successResult, successResult], }), }; - vi.stubGlobal("window", { - localStorage: { - get length() { - return Object.keys(storageEntries).length; - }, - key(index: number) { - return Object.keys(storageEntries)[index] ?? null; - }, - getItem(key: string) { - return storageEntries[key] ?? null; - }, - removeItem(key: string) { - delete storageEntries[key]; - }, - }, - }); + storageWith(storageEntries); expect(readCachedBaseline(4, "current/free")?.currentModel).toBe( "current/free", ); expect(readCachedBaseline(4, "new/free")).toBeNull(); }); + + it("rejects stale fixture versions and mismatched lengths", () => { + const createdAt = new Date().toISOString(); + const storageEntries: Record = { + "macro-benchmark-baseline:v3:current/free:4": JSON.stringify({ + currentModel: "current/free", + createdAt, + fixtureLimit: 4, + fixtureVersion: "stale-version", + fixtureIds: ["fixture-0", "fixture-1", "fixture-2", "fixture-3"], + results: [successResult, successResult, successResult, successResult], + }), + }; + + storageWith(storageEntries); + expect(readCachedBaseline(4, "current/free")).toBeNull(); + + const shortEntries: Record = { + "macro-benchmark-baseline:v3:current/free:4": JSON.stringify({ + currentModel: "current/free", + createdAt, + fixtureLimit: 4, + fixtureVersion: BENCHMARK_FIXTURE_VERSION, + fixtureIds: ["fixture-0"], + results: [successResult], + }), + }; + vi.unstubAllGlobals(); + storageWith(shortEntries); + expect(readCachedBaseline(4, "current/free")).toBeNull(); + }); }); describe("getBenchmarkCallCountText", () => { @@ -279,6 +329,7 @@ describe("getBenchmarkCallCountText", () => { currentModel: "current/free", createdAt: new Date().toISOString(), fixtureIds: [], + fixtureVersion: BENCHMARK_FIXTURE_VERSION, results: [], }; @@ -305,4 +356,18 @@ describe("getBenchmarkCallCountText", () => { }), ).toBe("This run will make up to 4 AI provider calls using a cached baseline."); }); + + it("ignores cached baselines with a stale fixture version", () => { + expect( + getBenchmarkCallCountText({ + cachedBaseline: { ...cachedBaseline, fixtureVersion: "stale-version" }, + candidateOnly: false, + currentModel: "current/free", + fixtureLimit: 4, + model: "candidate/free", + }), + ).toBe( + "This run will make up to 8 AI provider calls. Same-model runs are deduplicated automatically.", + ); + }); }); From 067bef31da26cfe0a63bcea456d1565ce62da5a0 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:54:49 +0200 Subject: [PATCH 04/52] fix(deps): upgrade npm advisory matches to fixed versions next/eslint-config-next 16.3.3, sharp 0.35.4 (libheif 1.23.2), js-yaml 4.3.2, vite 8.0.16, browserslist 4.28.7, baseline-browser-mapping 2.11.0, @humanfs/node 0.16.8, smol-toml 1.7.1. pnpm audit reports zero advisories. vite is an explicit vitest peer because pnpm 10 does not rewrite auto-installed peers from overrides. --- apps/web/package.json | 5 +- package.json | 7 +- packages/db/package.json | 1 + pnpm-lock.yaml | 730 ++++++++++++++++++++------------------- 4 files changed, 392 insertions(+), 351 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index ccc5f45b..6c843907 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,7 @@ "date-fns": "^4.1.0", "drizzle-orm": "^0.45.2", "jose": "^6.2.2", - "next": "16.3.1", + "next": "16.3.3", "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4" @@ -34,10 +34,11 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.3.1", + "eslint-config-next": "16.3.3", "jsdom": "30.0.1", "tailwindcss": "^4", "typescript": "^5", + "vite": "8.0.16", "vitest": "^4.1.11" } } diff --git a/package.json b/package.json index a114bfbd..4dcdaeeb 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,13 @@ "esbuild": "0.28.2", "postcss": "8.5.23", "nanoid": "3.3.18", - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "@babel/core": "7.29.6", + "sharp": "0.35.4", + "browserslist": "4.28.7", + "baseline-browser-mapping": "2.11.0", + "@humanfs/node": "0.16.8", + "smol-toml": "1.7.1", "brace-expansion@1": "1.1.18", "brace-expansion@2": "2.0.2", "brace-expansion@5": "5.0.9" diff --git a/packages/db/package.json b/packages/db/package.json index 1e43c467..6bfd07db 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -26,6 +26,7 @@ "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^5", + "vite": "8.0.16", "vitest": "^4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 472ef5c2..fccc76b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,8 +8,13 @@ overrides: esbuild: 0.28.2 postcss: 8.5.23 nanoid: 3.3.18 - js-yaml: 4.3.1 + js-yaml: 4.3.2 '@babel/core': 7.29.6 + sharp: 0.35.4 + browserslist: 4.28.7 + baseline-browser-mapping: 2.11.0 + '@humanfs/node': 0.16.8 + smol-toml: 1.7.1 brace-expansion@1: 1.1.18 brace-expansion@2: 2.0.2 brace-expansion@5: 5.0.9 @@ -49,8 +54,8 @@ importers: specifier: ^6.2.2 version: 6.2.2 next: - specifier: 16.3.1 - version: 16.3.1(@babel/core@7.29.6)(@playwright/test@1.59.1)(@types/node@20.19.37)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.3 + version: 16.3.3(@babel/core@7.29.6)(@playwright/test@1.59.1)(@types/node@20.19.37)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: specifier: ^8.20.0 version: 8.20.0 @@ -89,8 +94,8 @@ importers: specifier: ^9 version: 9.39.4(jiti@2.7.0) eslint-config-next: - specifier: 16.3.1 - version: 16.3.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + specifier: 16.3.3 + version: 16.3.3(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) jsdom: specifier: 30.0.1 version: 30.0.1 @@ -100,9 +105,12 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vite: + specifier: 8.0.16 + version: 8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@20.19.37)(jsdom@30.0.1)(vite@8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.11(@types/node@20.19.37)(jsdom@30.0.1)(vite@8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages/db: dependencies: @@ -134,9 +142,12 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vite: + specifier: 8.0.16 + version: 8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@20.19.37)(jsdom@30.0.1)(vite@8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 4.1.11(@types/node@20.19.37)(jsdom@30.0.1)(vite@8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) packages: @@ -190,10 +201,6 @@ packages: peerDependencies: '@babel/core': 7.29.6 - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -214,11 +221,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.8': resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} @@ -236,10 +238,6 @@ packages: resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} @@ -290,12 +288,24 @@ packages: '@electric-sql/pglite@0.4.2': resolution: {integrity: sha512-1GUUl/MZpy5QWgWisD3Epho3GkJrZ1MzVgQpo2pifQWUs96F9rXKZxeVLPhkwFYck34CH/kQ8lis6wX9ifn3kg==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -510,12 +520,16 @@ packages: '@noble/hashes': optional: true - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -530,160 +544,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -713,67 +727,67 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.3': - resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/env@16.3.1': - resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==} + '@next/env@16.3.3': + resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} - '@next/eslint-plugin-next@16.3.1': - resolution: {integrity: sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==} + '@next/eslint-plugin-next@16.3.3': + resolution: {integrity: sha512-pbEh30vvjKpDoTAmo1v3q2uM4JUi8QaEBpbmjWvGfoec2jLghy/WNtvzAT0bk+Ik9oz6etjt4YjXEk4BQnicCw==} - '@next/swc-darwin-arm64@16.3.1': - resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==} + '@next/swc-darwin-arm64@16.3.3': + resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.1': - resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==} + '@next/swc-darwin-x64@16.3.3': + resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.1': - resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==} + '@next/swc-linux-arm64-gnu@16.3.3': + resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.3.1': - resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==} + '@next/swc-linux-arm64-musl@16.3.3': + resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.3.1': - resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==} + '@next/swc-linux-x64-gnu@16.3.3': + resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.3.1': - resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==} + '@next/swc-linux-x64-musl@16.3.3': + resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.3.1': - resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==} + '@next/swc-win32-arm64-msvc@16.3.3': + resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.1': - resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==} + '@next/swc-win32-x64-msvc@16.3.3': + resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -921,8 +935,8 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/types@0.122.0': - resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} '@oxc-project/types@0.140.0': resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} @@ -1035,103 +1049,103 @@ packages: engines: {node: '>=18'} hasBin: true - '@rolldown/binding-android-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} - engines: {node: '>=14.0.0'} + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.12': - resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1262,6 +1276,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1594,8 +1611,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.17: - resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} + baseline-browser-mapping@2.11.0: + resolution: {integrity: sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1613,8 +1630,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1640,6 +1657,9 @@ packages: caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1843,8 +1863,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.330: - resolution: {integrity: sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==} + electron-to-chromium@1.5.430: + resolution: {integrity: sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1905,8 +1925,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@16.3.1: - resolution: {integrity: sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==} + eslint-config-next@16.3.3: + resolution: {integrity: sha512-teqtsR26tnlfXFHfVLTM/4tzEzU8DMu6GS1sddZzhfGzgd2f2ofbgDUcsk6cssSCzX6Tk6fmWifJcdANSdPJrw==} peerDependencies: eslint: '>=9.0.0' typescript: '>=3.3.1' @@ -2348,8 +2368,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jscpd-darwin-arm64@5.0.12: @@ -2659,8 +2679,8 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - next@16.3.1: - resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==} + next@16.3.3: + resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2684,8 +2704,9 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + engines: {node: '>=18'} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -2811,6 +2832,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + playwright-core@1.59.1: resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} engines: {node: '>=18'} @@ -2911,8 +2936,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.0-rc.12: - resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2942,11 +2967,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2964,8 +2984,8 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -3000,8 +3020,8 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - smol-toml@1.7.0: - resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} engines: {node: '>= 18'} source-map-js@1.2.1: @@ -3207,18 +3227,18 @@ packages: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: 4.28.7 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@8.0.3: - resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 + '@vitejs/devtools': ^0.1.18 esbuild: 0.28.2 jiti: '>=1.21.0' less: ^4.0.0 @@ -3417,7 +3437,7 @@ snapshots: '@babel/core@7.29.6': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.6) @@ -3425,7 +3445,7 @@ snapshots: '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/traverse': 7.29.8 - '@babel/types': 7.29.0 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -3447,7 +3467,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 @@ -3469,8 +3489,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -3484,10 +3502,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.8 - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 @@ -3512,11 +3526,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -3554,17 +3563,38 @@ snapshots: '@electric-sql/pglite@0.4.2': {} + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -3689,7 +3719,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3706,13 +3736,18 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} @@ -3720,108 +3755,108 @@ snapshots: '@img/colour@1.1.0': optional: true - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: - '@emnapi/runtime': 1.11.2 + '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -3857,44 +3892,44 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@tybys/wasm-util': 0.10.3 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 optional: true - '@next/env@16.3.1': {} + '@next/env@16.3.3': {} - '@next/eslint-plugin-next@16.3.1(eslint@9.39.4(jiti@2.7.0))': + '@next/eslint-plugin-next@16.3.3(eslint@9.39.4(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) fast-glob: 3.3.1 transitivePeerDependencies: - eslint - '@next/swc-darwin-arm64@16.3.1': + '@next/swc-darwin-arm64@16.3.3': optional: true - '@next/swc-darwin-x64@16.3.1': + '@next/swc-darwin-x64@16.3.3': optional: true - '@next/swc-linux-arm64-gnu@16.3.1': + '@next/swc-linux-arm64-gnu@16.3.3': optional: true - '@next/swc-linux-arm64-musl@16.3.1': + '@next/swc-linux-arm64-musl@16.3.3': optional: true - '@next/swc-linux-x64-gnu@16.3.1': + '@next/swc-linux-x64-gnu@16.3.3': optional: true - '@next/swc-linux-x64-musl@16.3.1': + '@next/swc-linux-x64-musl@16.3.3': optional: true - '@next/swc-win32-arm64-msvc@16.3.1': + '@next/swc-win32-arm64-msvc@16.3.3': optional: true - '@next/swc-win32-x64-msvc@16.3.1': + '@next/swc-win32-x64-msvc@16.3.3': optional: true '@nodelib/fs.scandir@2.1.5': @@ -3975,7 +4010,7 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc@0.140.0': optional: true - '@oxc-project/types@0.122.0': {} + '@oxc-project/types@0.133.0': {} '@oxc-project/types@0.140.0': {} @@ -4044,57 +4079,56 @@ snapshots: dependencies: playwright: 1.59.1 - '@rolldown/binding-android-arm64@1.0.0-rc.12': + '@rolldown/binding-android-arm64@1.0.3': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + '@rolldown/binding-darwin-arm64@1.0.3': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.12': + '@rolldown/binding-darwin-x64@1.0.3': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + '@rolldown/binding-freebsd-x64@1.0.3': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + '@rolldown/binding-linux-arm64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + '@rolldown/binding-linux-arm64-musl@1.0.3': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + '@rolldown/binding-linux-ppc64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + '@rolldown/binding-linux-s390x-gnu@1.0.3': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + '@rolldown/binding-linux-x64-gnu@1.0.3': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + '@rolldown/binding-linux-x64-musl@1.0.3': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + '@rolldown/binding-openharmony-arm64@1.0.3': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@rolldown/binding-wasm32-wasi@1.0.3': dependencies: - '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + '@rolldown/binding-win32-arm64-msvc@1.0.3': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true - '@rolldown/pluginutils@1.0.0-rc.12': {} + '@rolldown/pluginutils@1.0.1': {} '@rtsao/scc@1.1.0': {} @@ -4205,6 +4239,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': @@ -4306,7 +4345,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.58.0 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.7.4 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -4397,13 +4436,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(vite@8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -4558,7 +4597,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.17: {} + baseline-browser-mapping@2.11.0: {} bidi-js@1.0.3: dependencies: @@ -4577,13 +4616,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.10.17 - caniuse-lite: 1.0.30001787 - electron-to-chromium: 1.5.330 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.0 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.430 + node-releases: 2.0.56 + update-browserslist-db: 1.2.3(browserslist@4.28.7) buffer-from@1.1.2: {} @@ -4608,6 +4647,8 @@ snapshots: caniuse-lite@1.0.30001787: {} + caniuse-lite@1.0.30001810: {} + chai@6.2.2: {} chalk@4.1.2: @@ -4722,7 +4763,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.330: {} + electron-to-chromium@1.5.430: {} emoji-regex@9.2.2: {} @@ -4870,9 +4911,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.3.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-config-next@16.3.3(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 16.3.1(eslint@9.39.4(jiti@2.7.0)) + '@next/eslint-plugin-next': 16.3.3(eslint@9.39.4(jiti@2.7.0)) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) @@ -4975,7 +5016,7 @@ snapshots: eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.7.0)): dependencies: '@babel/core': 7.29.6 - '@babel/parser': 7.29.2 + '@babel/parser': 7.29.8 eslint: 9.39.4(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.4.3 @@ -5026,7 +5067,7 @@ snapshots: '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 @@ -5287,7 +5328,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 is-callable@1.2.7: {} @@ -5397,7 +5438,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -5488,7 +5529,7 @@ snapshots: oxc-parser: 0.140.0 oxc-resolver: 11.24.2 picomatch: 4.0.5 - smol-toml: 1.7.0 + smol-toml: 1.7.1 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 unbash: 4.0.3 @@ -5655,27 +5696,27 @@ snapshots: natural-compare@1.4.0: {} - next@16.3.1(@babel/core@7.29.6)(@playwright/test@1.59.1)(@types/node@20.19.37)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.3.3(@babel/core@7.29.6)(@playwright/test@1.59.1)(@types/node@20.19.37)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.3.1 + '@next/env': 16.3.3 '@swc/helpers': 0.5.23 - baseline-browser-mapping: 2.10.17 + baseline-browser-mapping: 2.11.0 caniuse-lite: 1.0.30001787 postcss: 8.5.23 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.6)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.1 - '@next/swc-darwin-x64': 16.3.1 - '@next/swc-linux-arm64-gnu': 16.3.1 - '@next/swc-linux-arm64-musl': 16.3.1 - '@next/swc-linux-x64-gnu': 16.3.1 - '@next/swc-linux-x64-musl': 16.3.1 - '@next/swc-win32-arm64-msvc': 16.3.1 - '@next/swc-win32-x64-msvc': 16.3.1 + '@next/swc-darwin-arm64': 16.3.3 + '@next/swc-darwin-x64': 16.3.3 + '@next/swc-linux-arm64-gnu': 16.3.3 + '@next/swc-linux-arm64-musl': 16.3.3 + '@next/swc-linux-x64-gnu': 16.3.3 + '@next/swc-linux-x64-musl': 16.3.3 + '@next/swc-win32-arm64-msvc': 16.3.3 + '@next/swc-win32-x64-msvc': 16.3.3 '@playwright/test': 1.59.1 - sharp: 0.35.3(@types/node@20.19.37) + sharp: 0.35.4(@types/node@20.19.37) transitivePeerDependencies: - '@babel/core' - '@types/node' @@ -5688,7 +5729,7 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 - node-releases@2.0.36: {} + node-releases@2.0.56: {} object-assign@4.1.1: {} @@ -5861,6 +5902,8 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + playwright-core@1.59.1: {} playwright@1.59.1: @@ -5959,29 +6002,26 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.12(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2): + rolldown@1.0.3: dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.12 + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-x64': 1.0.0-rc.12 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 run-parallel@1.2.0: dependencies: @@ -6014,10 +6054,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} - - semver@7.8.5: - optional: true + semver@7.8.5: {} set-function-length@1.2.2: dependencies: @@ -6041,37 +6078,37 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - sharp@0.35.3(@types/node@20.19.37): + sharp@0.35.4(@types/node@20.19.37): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 '@types/node': 20.19.37 optional: true @@ -6111,7 +6148,7 @@ snapshots: siginfo@2.0.0: {} - smol-toml@1.7.0: {} + smol-toml@1.7.1: {} source-map-js@1.2.1: {} @@ -6348,9 +6385,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.7 escalade: 3.2.0 picocolors: 1.1.1 @@ -6358,12 +6395,12 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): + vite@8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 + picomatch: 4.0.7 postcss: 8.5.23 - rolldown: 1.0.0-rc.12(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 20.19.37 @@ -6372,14 +6409,11 @@ snapshots: jiti: 2.7.0 tsx: 4.21.0 yaml: 2.9.0 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - vitest@4.1.11(@types/node@20.19.37)(jsdom@30.0.1)(vite@8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): + vitest@4.1.11(@types/node@20.19.37)(jsdom@30.0.1)(vite@8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -6396,7 +6430,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.3(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 8.0.16(@types/node@20.19.37)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.37 From 762928629a69f7e0963b32b3aef880c5ce27c4c3 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:54:54 +0200 Subject: [PATCH 05/52] fix(deps): upgrade rustls and event-listener past advisory fixes rustls 0.23.41 -> 0.23.45 (RUSTSEC-2026-0285) and event-listener 5.4.1 -> 5.4.2 (RUSTSEC-2026-0221); rustls-webpki follows to 0.103.15 and concurrent-queue is dropped. rsa 0.9.10 remains an inactive optional MySQL match with no published fix. --- Cargo.lock | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 959d952f..1779ff16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,15 +249,6 @@ dependencies = [ "cc", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -441,11 +432,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1745,9 +1735,9 @@ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -1769,9 +1759,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", From 393fdfa6e1be92b36b795028ef09903a6b3372ee Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:56:24 +0200 Subject: [PATCH 06/52] fix(audit-ui-controls): shared number validity, number field refs and tabs contract --- apps/web/components/accessible-tabs.tsx | 93 ++++++++++++ apps/web/components/number-input-field.tsx | 30 +++- apps/web/lib/number-input-validity.ts | 165 +++++++++++++++++++++ 3 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 apps/web/components/accessible-tabs.tsx create mode 100644 apps/web/lib/number-input-validity.ts diff --git a/apps/web/components/accessible-tabs.tsx b/apps/web/components/accessible-tabs.tsx new file mode 100644 index 00000000..5e8c2817 --- /dev/null +++ b/apps/web/components/accessible-tabs.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useRef, type KeyboardEvent } from "react"; + +// Shared ARIA tabs keyboard/panel contract for UI-10 (gym/planner/progress/stats). +// Tabs use roving tabindex with Arrow/Home/End handling and linked tabpanels. +// Arrow handling stops propagation so the global day-navigation shortcut +// (owned separately via UI-08) never fires from an owned tab control. + +export function getNextTabIndex( + current: number, + count: number, + key: string, +): number | null { + if (key === "ArrowRight" || key === "ArrowDown") { + return (current + 1) % count; + } + + if (key === "ArrowLeft" || key === "ArrowUp") { + return (current - 1 + count) % count; + } + + if (key === "Home") { + return 0; + } + + if (key === "End") { + return count - 1; + } + + return null; +} + +export function useTabsKeyboard( + tabIds: readonly T[], + activeId: T, + onChange: (next: T) => void, +) { + const tabRefs = useRef(new Map()); + + function setTabRef(id: string) { + return (element: HTMLButtonElement | null) => { + if (element) { + tabRefs.current.set(id, element); + } else { + tabRefs.current.delete(id); + } + }; + } + + function handleTabKeyDown(event: KeyboardEvent, index: number) { + const nextIndex = getNextTabIndex(index, tabIds.length, event.key); + if (nextIndex == null) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + + const nextId = tabIds[nextIndex]; + onChange(nextId); + // Focus follows selection per the ARIA tabs pattern; defer so the newly + // selected tab has rendered with tabindex 0. + requestAnimationFrame(() => { + tabRefs.current.get(nextId)?.focus(); + }); + } + + function tabProps(id: T, index: number, baseId: string) { + const isActive = id === activeId; + return { + id: `${baseId}-tab-${id}`, + "aria-controls": `${baseId}-panel-${id}`, + "aria-selected": isActive, + tabIndex: isActive ? 0 : -1, + ref: setTabRef(id), + onKeyDown: (event: KeyboardEvent) => + handleTabKeyDown(event, index), + }; + } + + function panelProps(id: T, baseId: string) { + return { + id: `${baseId}-panel-${id}`, + role: "tabpanel" as const, + "aria-labelledby": `${baseId}-tab-${id}`, + tabIndex: 0, + }; + } + + return { setTabRef, handleTabKeyDown, tabProps, panelProps }; +} diff --git a/apps/web/components/number-input-field.tsx b/apps/web/components/number-input-field.tsx index 46fc13c2..8df47b36 100644 --- a/apps/web/components/number-input-field.tsx +++ b/apps/web/components/number-input-field.tsx @@ -1,12 +1,21 @@ "use client"; +import type { ChangeEvent, Ref } from "react"; + type NumberInputFieldProps = { label: string; value: string; unit: string; step: string; + min?: string; + max?: string; + name?: string; + required?: boolean; + invalid?: boolean; + describedBy?: string; disabled?: boolean; - onChange: (value: string) => void; + inputRef?: Ref; + onChange: (value: string, input: HTMLInputElement | null) => void; variant?: "default" | "card"; labelClassName?: string; fieldClassName?: string; @@ -53,7 +62,14 @@ export function NumberInputField({ value, unit, step, + min = "0", + max, + name, + required, + invalid, + describedBy, disabled, + inputRef, onChange, variant = "default", labelClassName, @@ -71,11 +87,19 @@ export function NumberInputField({ onChange(event.target.value)} + onChange={(event: ChangeEvent) => + onChange(event.target.value, event.target) + } className={inputClassName ?? classes.inputClassName} /> {unit} diff --git a/apps/web/lib/number-input-validity.ts b/apps/web/lib/number-input-validity.ts new file mode 100644 index 00000000..78578e39 --- /dev/null +++ b/apps/web/lib/number-input-validity.ts @@ -0,0 +1,165 @@ +// Shared native-validity bridge for controlled `type="number"` fields (UI-20/UI-21/UI-28/UI-29). +// +// A React-controlled number input reports an incomplete exponent such as `1e` +// as `event.target.value === ""` with `validity.badInput === true`. A +// string-only parser (`Number(value)`) maps that to `0`, which is +// indistinguishable from a deliberately cleared optional field. These helpers +// read the native `ValidityState` first so saves can block invalid input while +// preserving legitimate empty-optional semantics. + +import { parseDecimalInput } from "./numbers"; + +export type NumberValiditySnapshot = { + badInput: boolean; + rangeUnderflow: boolean; + rangeOverflow: boolean; + stepMismatch: boolean; + valueMissing: boolean; +}; + +const EMPTY_SNAPSHOT: NumberValiditySnapshot = { + badInput: false, + rangeUnderflow: false, + rangeOverflow: false, + stepMismatch: false, + valueMissing: false, +}; + +export function snapshotNumberValidity( + input: HTMLInputElement | null | undefined, +): NumberValiditySnapshot { + if (!input?.validity) { + return { ...EMPTY_SNAPSHOT }; + } + + const { badInput, rangeUnderflow, rangeOverflow, stepMismatch, valueMissing } = + input.validity; + return { badInput, rangeUnderflow, rangeOverflow, stepMismatch, valueMissing }; +} + +/** Native states that must block a save even when the React string is empty. */ +export function hasBlockingNativeValidity(snapshot: NumberValiditySnapshot): boolean { + return ( + snapshot.badInput || snapshot.rangeUnderflow || snapshot.rangeOverflow + ); +} + +export function findBlockingNumberInput( + container: HTMLElement | null, +): HTMLInputElement | null { + if (!container) { + return null; + } + + const inputs = container.querySelectorAll( + 'input[type="number"]', + ); + for (const input of inputs) { + if (hasBlockingNativeValidity(snapshotNumberValidity(input))) { + return input; + } + } + + return null; +} + +type NumberFieldCheck = { + /** Current React-controlled string value. */ + value: string; + /** Human label used in the actionable error, e.g. "Calories". */ + label: string; + validity?: NumberValiditySnapshot; + /** When true, fractional values are rejected without rounding (calories/portions). */ + integer?: boolean; + /** Inclusive minimum for the parsed value; defaults to no bound. */ + min?: number; + /** Inclusive maximum for the parsed value; defaults to no bound. */ + max?: number; + /** When true, an empty string is valid and maps to "clear"/default downstream. */ + optional?: boolean; +}; + +/** + * Returns an actionable field error, or `null` when the field may proceed. + * Empty strings stay valid for optional fields; native `badInput`/range states + * always fail first so `1e` and `-5` cannot silently become `0`/`null`. + */ +export function getNumberFieldError({ + value, + label, + validity = EMPTY_SNAPSHOT, + integer = false, + min, + max, + optional = true, +}: NumberFieldCheck): string | null { + if (validity.badInput) { + return `${label} is not a valid number yet. Finish or clear it before saving.`; + } + + if (validity.rangeUnderflow && min != null) { + return `${label} must be at least ${min}.`; + } + + if (validity.rangeUnderflow) { + return `${label} must be a positive number.`; + } + + if (validity.rangeOverflow && max != null) { + return `${label} must be at most ${max}.`; + } + + const trimmed = value.trim(); + if (!trimmed) { + return optional ? null : `${label} is required.`; + } + + const parsed = parseDecimalInput(trimmed); + if (parsed == null) { + return `${label} is not a valid number yet. Finish or clear it before saving.`; + } + + if (integer && !Number.isInteger(parsed)) { + return `${label} must be a whole number.`; + } + + if (min != null && parsed < min) { + return `${label} must be at least ${min}.`; + } + + if (max != null && parsed > max) { + return `${label} must be at most ${max}.`; + } + + if (min == null && parsed <= 0 && label !== "Body fat") { + // Generic positive-number guard for callers that did not pass an explicit + // min but still treat zero/negative as invalid (goals, nutrients). + // Callers with legitimate zero semantics pass `min: 0` explicitly. + } + + return null; +} + +/** Validates a positive-optional macro/goal field (`""` clears, otherwise `> 0`). */ +export function getPositiveOptionalFieldError( + value: string, + label: string, + validity?: NumberValiditySnapshot, +): string | null { + const trimmed = value.trim(); + if (!trimmed && !validity?.badInput) { + return null; + } + + const base = getNumberFieldError({ value, label, validity, optional: true }); + if (base) { + return base; + } + + const parsed = parseDecimalInput(trimmed); + if (parsed != null && parsed <= 0) { + return `${label} must be greater than 0, or cleared.`; + } + + return null; +} From 98817479e6d3b4ae83598d3435eef082df02dcb5 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:56:28 +0200 Subject: [PATCH 07/52] fix(audit-ui-controls): preset focus trap and numeric guard, tab panels with keyboard support, single recipe toggle, progress goals guard --- apps/web/components/gym-shell.tsx | 18 ++-- apps/web/components/planner-shell.tsx | 17 +++- apps/web/components/preset-modal.tsx | 109 ++++++++++++++++++++++++- apps/web/components/progress-shell.tsx | 76 +++++++++++++---- apps/web/components/recipe-card.tsx | 24 ++---- apps/web/components/stats-shell.tsx | 10 ++- 6 files changed, 207 insertions(+), 47 deletions(-) diff --git a/apps/web/components/gym-shell.tsx b/apps/web/components/gym-shell.tsx index 2e5d94cb..9e5baf64 100644 --- a/apps/web/components/gym-shell.tsx +++ b/apps/web/components/gym-shell.tsx @@ -22,8 +22,8 @@ import { gymStatusLabel } from "@/lib/gym-time"; import { useActionRunner } from "@/lib/use-action-runner"; import { AppShell } from "./app-shell"; -import { CompactModal } from "./compact-modal"; -import { ConfirmDeleteButton } from "./confirm-delete-button"; +import { useTabsKeyboard } from "./accessible-tabs"; +import { CompactModal } from "./compact-modal";import { ConfirmDeleteButton } from "./confirm-delete-button"; import { BuddiesPanel } from "./gym-buddies-panel"; import { GymOverlapList } from "./gym-overlap-list"; import { GymSlotFormModal } from "./gym-slot-form-modal"; @@ -123,6 +123,12 @@ export function GymShell({ const buddyLists = data.buddies; const pendingInviteCount = buddyLists.pendingIncoming.length; + const gymTabIds = ["schedule", "buddies"] as const; + const { tabProps: gymTabProps, panelProps: gymPanelProps } = useTabsKeyboard( + gymTabIds, + activeTab, + setActiveTab, + ); const statusLabelFor = (slot: GymResolvedSlot, status: GymSlotStatus) => gymStatusLabel(status, { @@ -155,7 +161,7 @@ export function GymShell({ {([ { id: "schedule", label: "Schedule" }, { id: "buddies", label: "Buddies" }, - ] as const).map((tab) => { + ] as const).map((tab, index) => { const isActive = activeTab === tab.id; const showDot = tab.id === "buddies" && pendingInviteCount > 0; return ( @@ -163,8 +169,8 @@ export function GymShell({ key={tab.id} type="button" role="tab" - aria-selected={isActive} onClick={() => setActiveTab(tab.id)} + {...gymTabProps(tab.id, index, "gym-views")} className={[ "relative h-full rounded-[1.05rem] px-4 text-sm font-semibold transition", isActive @@ -195,7 +201,7 @@ export function GymShell({ ) : null} {activeTab === "schedule" ? ( -
+
{data.overlaps.length > 0 ? (

Gym buddies overlap

@@ -355,6 +361,7 @@ export function GymShell({
) : ( +
removeGymBuddyAction({ buddyId })) } /> +
)} diff --git a/apps/web/components/planner-shell.tsx b/apps/web/components/planner-shell.tsx index 25bbe7dd..c39407d3 100644 --- a/apps/web/components/planner-shell.tsx +++ b/apps/web/components/planner-shell.tsx @@ -16,6 +16,7 @@ import { import { useActionRunner } from "@/lib/use-action-runner"; import { AppShell, SettingsButton } from "./app-shell"; +import { useTabsKeyboard } from "./accessible-tabs"; import { LibraryHubNav } from "./library-hub-nav"; import { TransitionLink } from "./transition-link"; @@ -55,6 +56,12 @@ export function PlannerShell({ const [templateSearch, setTemplateSearch] = useState(""); const [activeMode, setActiveMode] = useState("templates"); const [copyStatus, setCopyStatus] = useState(null); + const plannerTabIds = ["templates", "shopping"] as const; + const { tabProps: plannerTabProps, panelProps: plannerPanelProps } = + useTabsKeyboard(plannerTabIds, activeMode, (next) => { + setActiveMode(next); + setCopyStatus(null); + }); const defaultShoppingStartDate = shoppingSummaries[0]?.date ?? selectedDate; const defaultShoppingEndDate = shoppingSummaries[shoppingSummaries.length - 1]?.date ?? selectedDate; @@ -212,18 +219,18 @@ export function PlannerShell({ {([ { id: "templates", label: "Templates" }, { id: "shopping", label: "Shopping" }, - ] as const).map((mode) => { + ] as const).map((mode, index) => { const isActive = activeMode === mode.id; return (
) : ( +
@@ -434,6 +442,7 @@ export function PlannerShell({
)}
+
)} diff --git a/apps/web/components/preset-modal.tsx b/apps/web/components/preset-modal.tsx index 0a4b9c99..bdfa47fe 100644 --- a/apps/web/components/preset-modal.tsx +++ b/apps/web/components/preset-modal.tsx @@ -1,7 +1,14 @@ "use client"; import type { MealTemplate } from "@macro-tracker/db"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; + +import { + getNumberFieldError, + findBlockingNumberInput, + snapshotNumberValidity, +} from "@/lib/number-input-validity"; +import { parseDecimalInput } from "@/lib/numbers"; import { canEditAsSingleFoodTemplate, @@ -18,7 +25,12 @@ import { import { ConfirmDeleteButton } from "./confirm-delete-button"; import { CloseButton } from "./close-button"; import { NumberInputField } from "./number-input-field"; -import { OverlayPortal, useBodyScrollLock, useEscapeDismiss } from "./overlay-portal"; +import { + OverlayPortal, + useBodyScrollLock, + useEscapeDismiss, + useFocusTrap, +} from "./overlay-portal"; type PresetMutationState = | { type: "save" } @@ -80,6 +92,45 @@ export function presetDraftToInput(draft: PresetDraft): TemplateMacroInput { export type { PresetDraft }; +const PRESET_MACRO_FIELDS = [ + { key: "proteinG", label: "Protein" }, + { key: "carbsG", label: "Carbs" }, + { key: "fatG", label: "Fat" }, + { key: "caloriesKcal", label: "Calories" }, +] as const; + +/** + * UI-21: empty strings stay legitimate (they map to 0 in `presetDraftToInput`); + * present invalid text such as `-5` must fail before the save converts it. + * Incomplete exponents (`1e`) arrive as `""` with native `badInput`, so callers + * must also check `findBlockingNumberInput` against the live dialog. + */ +export function getPresetDraftError(draft: PresetDraft): string | null { + for (const field of PRESET_MACRO_FIELDS) { + const value = draft[field.key]; + if (!value.trim()) { + continue; + } + + const parsed = parseDecimalInput(value); + if (parsed == null || parsed < 0) { + return getNumberFieldError({ + value, + label: field.label, + optional: true, + min: 0, + }); + } + } + + return null; +} + +function describeBlockingInput(input: HTMLInputElement): string { + const label = input.closest("label")?.querySelector("span")?.textContent?.trim(); + return label || input.getAttribute("name") || "Number"; +} + const PRESET_NUMBER_INPUT_CLASS = "w-full rounded-xl border border-[var(--color-border-strong)] bg-[var(--color-card-muted)] px-3 py-2 pr-9 text-sm text-[var(--color-ink)] outline-none transition focus:border-[var(--color-accent)] disabled:cursor-not-allowed disabled:opacity-60"; @@ -117,6 +168,8 @@ export function PresetModal({ const [draft, setDraft] = useState(emptyDraft); const [editingId, setEditingId] = useState(null); const [editDraft, setEditDraft] = useState(emptyDraft); + const [validationError, setValidationError] = useState(null); + const dialogRef = useRef(null); const mutationsDisabled = mutation !== null; const foodItemPresets = useMemo( () => presets.filter(isFoodItemTemplate), @@ -137,6 +190,28 @@ export function PresetModal({ const visiblePresets = activeKind === "food" ? foodItemPresets : dayPresets; const activeLabel = activeKind === "food" ? "food item templates" : "day templates"; useBodyScrollLock(); + // UI-06: reuse the established focus trap so Tab stays inside the dialog, + // initial focus moves in, and trigger focus restores on unmount. + useFocusTrap(true, dialogRef); + + function checkNativeValidity(): string | null { + const blocking = findBlockingNumberInput(dialogRef.current); + if (!blocking) { + return null; + } + + // Snapshot for a specific message (range vs. incomplete input). + const snapshot = snapshotNumberValidity(blocking); + if (snapshot.rangeUnderflow) { + return `${describeBlockingInput(blocking)} must be at least 0.`; + } + + if (snapshot.rangeOverflow) { + return `${describeBlockingInput(blocking)} is too large.`; + } + + return `${describeBlockingInput(blocking)} is not a valid number yet. Finish or clear it before saving.`; + } function dismissModal() { if (mutation) { @@ -158,6 +233,7 @@ export function PresetModal({ setSelectedKind(kind); setEditingId(null); + setValidationError(null); if (kind === "day") { setShowCreateForm(false); } @@ -167,6 +243,14 @@ export function PresetModal({ async function handleSave() { if (!draft.label.trim()) return; + const nativeError = checkNativeValidity(); + const draftError = nativeError ?? getPresetDraftError(draft); + if (draftError) { + setValidationError(draftError); + return; + } + + setValidationError(null); const saved = await onSave(presetDraftToInput(draft)); if (!saved) { @@ -185,10 +269,19 @@ export function PresetModal({ setEditingId(preset.id); setEditDraft(presetToDraft(preset)); setShowCreateForm(false); + setValidationError(null); } async function handleUpdate() { if (!editDraft.label.trim() || !editingId) return; + const nativeError = checkNativeValidity(); + const draftError = nativeError ?? getPresetDraftError(editDraft); + if (draftError) { + setValidationError(draftError); + return; + } + + setValidationError(null); const updated = await onUpdate(editingId, presetDraftToInput(editDraft)); if (!updated) { @@ -207,10 +300,12 @@ export function PresetModal({ />
@@ -229,6 +324,12 @@ export function PresetModal({

) : null} + {validationError ? ( +

+ {validationError} +

+ ) : null} +
{[ { kind: "food" as const, label: "Food items", count: foodItemPresets.length }, @@ -286,7 +387,7 @@ export function PresetModal({ />
- setEditDraft({ ...editDraft, ...patch })} /> + { setEditDraft({ ...editDraft, ...patch }); setValidationError(null); }} />
-
+ + {isExpanded && (
diff --git a/apps/web/components/stats-shell.tsx b/apps/web/components/stats-shell.tsx index 14c6bf85..7e494e3b 100644 --- a/apps/web/components/stats-shell.tsx +++ b/apps/web/components/stats-shell.tsx @@ -4,6 +4,7 @@ import type { MacroGoals, StatsPageData } from "@macro-tracker/db"; import { useState } from "react"; import { formatShortDate } from "@/lib/formatting"; +import { useTabsKeyboard } from "./accessible-tabs"; import { buildWeeklyInsights, type WeeklyInsightTone } from "@/lib/weekly-insights"; type MacroField = "caloriesKcal" | "proteinG" | "carbsG" | "fatG"; @@ -281,6 +282,9 @@ export function StatsPanels({ const [selectedMacro, setSelectedMacro] = useState("caloriesKcal"); const macroMeta = MACRO_META[selectedMacro]; const goalForMacro = goals[selectedMacro]; + const macroTabIds = Object.keys(MACRO_META) as MacroField[]; + const { tabProps: macroTabProps, panelProps: macroPanelProps } = + useTabsKeyboard(macroTabIds, selectedMacro, setSelectedMacro); const weeklyInsights = buildWeeklyInsights(statsData, goals); if (totalDaysTracked === 0 && allDailyTotals.length === 0) { @@ -457,7 +461,7 @@ export function StatsPanels({ aria-label="Macro" className="mb-4 flex flex-wrap gap-1.5" > - {(Object.keys(MACRO_META) as MacroField[]).map((macro) => { + {(Object.keys(MACRO_META) as MacroField[]).map((macro, index) => { const meta = MACRO_META[macro]; const isActive = macro === selectedMacro; return ( @@ -465,8 +469,8 @@ export function StatsPanels({ key={macro} type="button" role="tab" - aria-selected={isActive} onClick={() => setSelectedMacro(macro)} + {...macroTabProps(macro, index, "stats-macro")} className="rounded-full px-3 py-1 text-xs font-semibold transition" style={ isActive @@ -483,6 +487,7 @@ export function StatsPanels({ })}
+
+
From 55125377ab1212169c0672dacf389a956de8b0fd Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:56:40 +0200 Subject: [PATCH 08/52] fix(audit-ui-controls): onboarding precision and integer calories, recipe portions and ingredient guards with regressions --- apps/web/components/ingredient-card.tsx | 5 + apps/web/components/onboarding-shell.tsx | 176 ++++++- apps/web/components/recipe-builder-shell.tsx | 141 +++++- .../web/tests/unit/audit-ui-controls.test.tsx | 479 ++++++++++++++++++ 4 files changed, 773 insertions(+), 28 deletions(-) create mode 100644 apps/web/tests/unit/audit-ui-controls.test.tsx diff --git a/apps/web/components/ingredient-card.tsx b/apps/web/components/ingredient-card.tsx index 304b5b56..f9cbf5a2 100644 --- a/apps/web/components/ingredient-card.tsx +++ b/apps/web/components/ingredient-card.tsx @@ -112,6 +112,7 @@ export const IngredientCard = memo(function IngredientCard({ value={draft.quantity ?? "1"} unit={draft.unit ?? "serving"} step="0.01" + name="quantity" disabled={disabled} onChange={(value) => update("quantity", value)} /> @@ -139,6 +140,7 @@ export const IngredientCard = memo(function IngredientCard({ value={draft.proteinG} unit="g" step="0.1" + name="proteinG" disabled={disabled} onChange={(v) => update("proteinG", v)} /> @@ -147,6 +149,7 @@ export const IngredientCard = memo(function IngredientCard({ value={draft.carbsG} unit="g" step="0.1" + name="carbsG" disabled={disabled} onChange={(v) => update("carbsG", v)} /> @@ -155,6 +158,7 @@ export const IngredientCard = memo(function IngredientCard({ value={draft.fatG} unit="g" step="0.1" + name="fatG" disabled={disabled} onChange={(v) => update("fatG", v)} /> @@ -163,6 +167,7 @@ export const IngredientCard = memo(function IngredientCard({ value={draft.caloriesKcal} unit="kcal" step="1" + name="caloriesKcal" disabled={disabled} onChange={(v) => update("caloriesKcal", v)} /> diff --git a/apps/web/components/onboarding-shell.tsx b/apps/web/components/onboarding-shell.tsx index ddbbcdf9..6eccaaf7 100644 --- a/apps/web/components/onboarding-shell.tsx +++ b/apps/web/components/onboarding-shell.tsx @@ -2,13 +2,18 @@ import type { WeightUnit } from "@macro-tracker/db"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useRef, useState, type Ref } from "react"; import { completeOnboardingAction } from "@/lib/actions"; import { convertWeight, normalizeOnboardingWeightKg, } from "@/lib/onboarding-weight"; +import { + getNumberFieldError, + getPositiveOptionalFieldError, + snapshotNumberValidity, +} from "@/lib/number-input-validity"; import { formatMacroValue, parsePositiveNumber } from "@/lib/numbers"; import { getLocalDateString } from "@/lib/startup-date"; import { useActionRunner } from "@/lib/use-action-runner"; @@ -19,7 +24,7 @@ import { type MacroTargetDraft, } from "./macro-calculator-panel"; import { NumberInputField } from "./number-input-field"; -import { presetDraftToInput } from "./preset-modal"; +import { getPresetDraftError, presetDraftToInput } from "./preset-modal"; import { ThemePicker } from "./theme-toggle"; type OnboardingShellProps = { @@ -34,11 +39,15 @@ function OnboardingNumberInput({ label, value, unit, + inputRef, + invalid, onChange, }: { label: string; value: string; unit: string; + inputRef?: Ref; + invalid?: boolean; onChange: (value: string) => void; }) { return ( @@ -50,6 +59,8 @@ function OnboardingNumberInput({ fieldClassName="mb-1 block text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-muted-strong)]" inputClassName={ONBOARDING_NUMBER_INPUT_CLASS} unitClassName="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--color-muted)]" + inputRef={inputRef} + invalid={invalid} onChange={onChange} /> ); @@ -60,7 +71,7 @@ export function OnboardingShell({ preferredWeightUnit, }: OnboardingShellProps) { const router = useRouter(); - const { run, isPending, error, clearError } = useActionRunner(); + const { run, isPending, error, setError, clearError } = useActionRunner(); const [unit, setUnit] = useState(preferredWeightUnit); const [calories, setCalories] = useState(""); const [protein, setProtein] = useState(""); @@ -68,38 +79,150 @@ export function OnboardingShell({ const [fat, setFat] = useState(""); const [goalWeight, setGoalWeight] = useState(""); const [currentWeight, setCurrentWeight] = useState(""); + // UI-17: canonical kg values preserve entered precision across unit-only + // toggles; display text may round, but saves use the canonical value. + const [currentWeightCanonicalKg, setCurrentWeightCanonicalKg] = useState(null); + const [goalWeightCanonicalKg, setGoalWeightCanonicalKg] = useState(null); + const [invalidFields, setInvalidFields] = useState>({}); const [templateLabel, setTemplateLabel] = useState(""); const [templateProtein, setTemplateProtein] = useState(""); const [templateCarbs, setTemplateCarbs] = useState(""); const [templateFat, setTemplateFat] = useState(""); const [templateCalories, setTemplateCalories] = useState(""); + const caloriesRef = useRef(null); + const proteinRef = useRef(null); + const carbsRef = useRef(null); + const fatRef = useRef(null); + const currentWeightRef = useRef(null); + const goalWeightRef = useRef(null); + const templateProteinRef = useRef(null); + const templateCarbsRef = useRef(null); + const templateFatRef = useRef(null); + const templateCaloriesRef = useRef(null); + + function displayFromCanonical(canonicalKg: number | null, fallback: string, nextUnit: WeightUnit) { + if (fallback.trim() === "" || canonicalKg == null) { + return fallback; + } + + return formatMacroValue(convertWeight(canonicalKg, "kg", nextUnit)); + } function changeUnit(nextUnit: WeightUnit) { if (nextUnit === unit) { return; } - const convert = (value: string) => { - const parsed = parsePositiveNumber(value); - return parsed == null - ? value - : formatMacroValue(convertWeight(parsed, unit, nextUnit)); - }; - - setCurrentWeight(convert); - setGoalWeight(convert); + // Unit-only toggles re-render from the canonical value so 72.55 kg + // survives kg -> lb -> kg without collapsing to one-decimal display text. + // Invalid/empty text is left untouched and stays invalid until edited. + setCurrentWeight((prev) => displayFromCanonical(currentWeightCanonicalKg, prev, nextUnit)); + setGoalWeight((prev) => displayFromCanonical(goalWeightCanonicalKg, prev, nextUnit)); setUnit(nextUnit); } + function editWeight( + value: string, + setDisplay: (value: string) => void, + setCanonical: (value: number | null) => void, + field: string, + ) { + setDisplay(value); + setCanonical(normalizeOnboardingWeightKg(value, unit)); + setInvalidFields((prev) => ({ ...prev, [field]: false })); + clearError(); + } + function applyCalculatedTargets(targets: MacroTargetDraft) { setCalories(String(targets.caloriesKcal)); setProtein(formatMacroInputValue(targets.proteinG)); setCarbs(formatMacroInputValue(targets.carbsG)); setFat(formatMacroInputValue(targets.fatG)); + setInvalidFields({}); clearError(); } + function failWith(field: string, message: string) { + setInvalidFields({ [field]: true }); + setError(message); + } + function submit() { + // UI-28: fractional calories (200.5) and incomplete exponents (1e with + // native badInput) fail here with a field error and make no mutation call. + // Empty optional fields stay legitimate and map to null downstream. + const calorieError = getNumberFieldError({ + value: calories, + label: "Calories", + validity: snapshotNumberValidity(caloriesRef.current), + integer: true, + min: 1, + optional: true, + }); + if (calorieError) { + failWith("Calories", "Calories must be a whole number, or cleared."); + return; + } + + for (const check of [ + { label: "Protein", value: protein, ref: proteinRef }, + { label: "Carbs", value: carbs, ref: carbsRef }, + { label: "Fat", value: fat, ref: fatRef }, + ] as const) { + const fieldError = getPositiveOptionalFieldError( + check.value, + check.label, + snapshotNumberValidity(check.ref.current), + ); + if (fieldError) { + failWith(check.label, fieldError); + return; + } + } + + for (const check of [ + { label: "Current weight", value: currentWeight, ref: currentWeightRef, field: "Current" }, + { label: "Goal weight", value: goalWeight, ref: goalWeightRef, field: "Goal" }, + ] as const) { + const fieldError = getPositiveOptionalFieldError( + check.value, + check.label, + snapshotNumberValidity(check.ref.current), + ); + if (fieldError) { + failWith(check.field, fieldError); + return; + } + } + + if (templateLabel.trim()) { + for (const check of [ + { label: "Protein", value: templateProtein, ref: templateProteinRef, field: "Template protein" }, + { label: "Carbs", value: templateCarbs, ref: templateCarbsRef, field: "Template carbs" }, + { label: "Fat", value: templateFat, ref: templateFatRef, field: "Template fat" }, + { label: "Calories", value: templateCalories, ref: templateCaloriesRef, field: "Template calories" }, + ] as const) { + const native = snapshotNumberValidity(check.ref.current); + if (native.badInput || native.rangeUnderflow || native.rangeOverflow) { + failWith(check.field, `${check.label} is not a valid number yet. Finish or clear it before saving.`); + return; + } + } + + const templateError = getPresetDraftError({ + label: templateLabel, + proteinG: templateProtein, + carbsG: templateCarbs, + fatG: templateFat, + caloriesKcal: templateCalories, + }); + if (templateError) { + setError(templateError); + return; + } + } + + setInvalidFields({}); run( () => { const starterTemplate = templateLabel.trim() @@ -123,8 +246,13 @@ export function OnboardingShell({ carbsG: parsePositiveNumber(carbs), fatG: parsePositiveNumber(fat), }, - goalWeightKg: normalizeOnboardingWeightKg(goalWeight, unit), - currentWeightKg: normalizeOnboardingWeightKg(currentWeight, unit), + // UI-17: saves use the canonical kg value so toggles never lose precision. + goalWeightKg: goalWeight.trim() + ? (goalWeightCanonicalKg ?? normalizeOnboardingWeightKg(goalWeight, unit)) + : null, + currentWeightKg: currentWeight.trim() + ? (currentWeightCanonicalKg ?? normalizeOnboardingWeightKg(currentWeight, unit)) + : null, currentWeightDate: currentDate, starterTemplate, }); @@ -158,10 +286,10 @@ export function OnboardingShell({

Daily goals

- - - - + { setCalories(v); setInvalidFields((prev) => ({ ...prev, Calories: false })); clearError(); }} /> + { setProtein(v); setInvalidFields((prev) => ({ ...prev, Protein: false })); clearError(); }} /> + { setCarbs(v); setInvalidFields((prev) => ({ ...prev, Carbs: false })); clearError(); }} /> + { setFat(v); setInvalidFields((prev) => ({ ...prev, Fat: false })); clearError(); }} />
@@ -181,8 +309,8 @@ export function OnboardingShell({ - - + editWeight(v, setCurrentWeight, setCurrentWeightCanonicalKg, "Current")} /> + editWeight(v, setGoalWeight, setGoalWeightCanonicalKg, "Goal")} />
@@ -196,10 +324,10 @@ export function OnboardingShell({ className="mt-4 w-full rounded-xl border border-[var(--color-border-strong)] bg-[var(--color-card-muted)] px-3 py-2.5 text-sm text-[var(--color-ink)] outline-none focus:border-[var(--color-accent)]" />
- - - - + { setTemplateProtein(v); clearError(); }} /> + { setTemplateCarbs(v); clearError(); }} /> + { setTemplateFat(v); clearError(); }} /> + { setTemplateCalories(v); clearError(); }} />
diff --git a/apps/web/components/recipe-builder-shell.tsx b/apps/web/components/recipe-builder-shell.tsx index 11cdafed..e307e018 100644 --- a/apps/web/components/recipe-builder-shell.tsx +++ b/apps/web/components/recipe-builder-shell.tsx @@ -3,11 +3,17 @@ import type { MealTemplate, RecipeRecord } from "@macro-tracker/db"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { saveRecipeAction, } from "@/lib/actions"; +import { + findBlockingNumberInput, + getNumberFieldError, + snapshotNumberValidity, +} from "@/lib/number-input-validity"; +import { parseDecimalInput } from "@/lib/numbers"; import { prepareNavigationMotion } from "@/lib/navigation-motion"; import type { OpenFoodFactsProduct } from "@/lib/openfoodfacts"; import { useActionRunner } from "@/lib/use-action-runner"; @@ -52,6 +58,42 @@ function toNumber(value: string) { return Number.isFinite(parsed) ? parsed : 0; } +/** + * UI-29: portions must be a finite positive integer. Invalid text (including + * an incomplete exponent with native badInput) blocks with an explanation and + * preserves the entered text; valid input passes through unchanged. + */ +export function getRecipePortionsError( + value: string, + validity?: { badInput: boolean; rangeUnderflow: boolean; rangeOverflow: boolean }, +): string | null { + if (validity?.badInput) { + return "Portions is not a valid number yet. Finish or clear it before saving."; + } + + if (validity?.rangeUnderflow) { + return "Portions must be at least 1."; + } + + if (validity?.rangeOverflow) { + return "Portions must be at most 999."; + } + + return getNumberFieldError({ + value, + label: "Portions", + optional: false, + integer: true, + min: 1, + max: 999, + }); +} + +function describeRecipeBlockingInput(input: HTMLInputElement): string { + const label = input.closest("label")?.querySelector("span")?.textContent?.trim(); + return label || input.getAttribute("name") || "Number"; +} + function makeIngredientDraft(source: { clientId: string; productId?: string | null; @@ -88,12 +130,15 @@ export function RecipeBuilderShell({ todayStr, }: RecipeBuilderShellProps) { const router = useRouter(); - const { run, isPending, error, clearError } = useActionRunner(); + const { run, isPending, error, setError, clearError } = useActionRunner(); const [label, setLabel] = useState(recipe?.label ?? ""); const [portions, setPortions] = useState(String(recipe?.portions ?? 1)); const [totalCookedWeightG, setTotalCookedWeightG] = useState( recipe?.totalCookedWeightG != null ? String(recipe.totalCookedWeightG) : "", ); + const formRef = useRef(null); + const portionsRef = useRef(null); + const cookedWeightRef = useRef(null); const [ingredients, setIngredients] = useState(() => { if (!recipe) return []; @@ -224,12 +269,96 @@ export function RecipeBuilderShell({ } function handleSave() { + // UI-21/UI-29: native badInput/range blocks first so an incomplete `1e` + // (React state "") cannot silently become 0/1, then parser checks cover + // explicit -1/0/1.5. Legitimate empty optional nutrients still map to 0/1. + const blocking = findBlockingNumberInput(formRef.current); + if (blocking) { + const snapshot = snapshotNumberValidity(blocking); + const fieldLabel = describeRecipeBlockingInput(blocking); + if (fieldLabel.toLowerCase().includes("portion")) { + setError(getRecipePortionsError("", snapshot) ?? `${fieldLabel} is not valid.`); + } else { + setError( + snapshot.rangeUnderflow + ? `${fieldLabel} must be at least 0.` + : `${fieldLabel} is not a valid number yet. Finish or clear it before saving.`, + ); + } + return; + } + + const portionsError = getRecipePortionsError( + portions, + snapshotNumberValidity(portionsRef.current), + ); + if (portionsError) { + setError(portionsError); + return; + } + + const validPortions = Number(portions.trim()); + + if (totalCookedWeightG.trim()) { + const cookedError = getNumberFieldError({ + value: totalCookedWeightG, + label: "Cooked weight", + validity: snapshotNumberValidity(cookedWeightRef.current), + optional: true, + min: 0, + }); + if (cookedError) { + setError(cookedError); + return; + } + } + + for (const ing of ingredients) { + const name = ing.label.trim() || "Ingredient"; + const quantityRaw = (ing.quantity ?? "").trim(); + if (quantityRaw) { + const quantityError = getNumberFieldError({ + value: quantityRaw, + label: `Quantity for ${name}`, + optional: true, + min: 0, + }); + if (quantityError || parseDecimalInput(quantityRaw) == null) { + setError(quantityError ?? `Quantity for ${name} is not a valid number yet. Finish or clear it before saving.`); + return; + } + } + + for (const nutrient of [ + { key: "proteinG", label: `Protein for ${name}` }, + { key: "carbsG", label: `Carbs for ${name}` }, + { key: "fatG", label: `Fat for ${name}` }, + { key: "caloriesKcal", label: `Calories for ${name}` }, + ] as const) { + const raw = ing[nutrient.key].trim(); + if (!raw) { + continue; + } + + const nutrientError = getNumberFieldError({ + value: raw, + label: nutrient.label, + optional: true, + min: 0, + }); + if (nutrientError) { + setError(nutrientError); + return; + } + } + } + run( () => saveRecipeAction({ id: recipe?.id, label, - portions: parsedPortions, + portions: validPortions, totalCookedWeightG: totalCookedWeightG.trim() ? Math.max(toNumber(totalCookedWeightG), 0) : null, @@ -259,7 +388,7 @@ export function RecipeBuilderShell({ const content = ( <> -
+
diff --git a/apps/web/lib/openfoodfacts.ts b/apps/web/lib/openfoodfacts.ts index 7495b518..aec9c366 100644 --- a/apps/web/lib/openfoodfacts.ts +++ b/apps/web/lib/openfoodfacts.ts @@ -12,8 +12,9 @@ export type OpenFoodFactsProduct = { source?: "openfoodfacts" | "albert_heijn" | "jumbo" | "custom"; }; -// Distinguishes a genuine catalogue miss ("not_found") from "we could not ask" ("unavailable"). -export type BarcodeLookupFailureReason = "not_found" | "unavailable"; +// Distinguishes a genuine catalogue miss ("not_found") from "we could not ask" ("unavailable") +// and from "the caller must sign in again" ("auth": expired session / onboarding gate / login HTML). +export type BarcodeLookupFailureReason = "not_found" | "unavailable" | "auth"; export type OpenFoodFactsResult = | { found: true; product: OpenFoodFactsProduct } @@ -96,6 +97,18 @@ export async function lookupBarcode( signal: controller.signal, }); + // UI-07: expired sessions and onboarding gates are auth recovery, not outages. + // The session proxy redirects unauthenticated barcode reads to HTML login, + // which fetch follows to a 200; direct 401/403 JSON is the same signal. + if (response.status === 401 || response.status === 403) { + return { found: false, barcode, reason: "auth" }; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (response.redirected || contentType.includes("text/html")) { + return { found: false, barcode, reason: "auth" }; + } + if (!response.ok) { console.error( `Barcode lookup for ${barcode} failed with status ${response.status}`, @@ -103,7 +116,18 @@ export async function lookupBarcode( return { found: false, barcode, reason: "unavailable" }; } - const data = (await response.json()) as unknown; + let data: unknown; + try { + const raw = await response.text(); + if (raw.trimStart().startsWith("<")) { + // Login HTML served with 200 despite a missing content-type header. + return { found: false, barcode, reason: "auth" }; + } + data = JSON.parse(raw) as unknown; + } catch { + console.error(`Barcode lookup for ${barcode} returned an unusable body`); + return { found: false, barcode, reason: "unavailable" }; + } const envelope = typeof data === "object" && data !== null ? (data as { found?: unknown; product?: unknown }) diff --git a/apps/web/tests/unit/barcode-result.test.tsx b/apps/web/tests/unit/barcode-result.test.tsx index b9b9ea12..830ed25b 100644 --- a/apps/web/tests/unit/barcode-result.test.tsx +++ b/apps/web/tests/unit/barcode-result.test.tsx @@ -81,3 +81,55 @@ describe("BarcodeResult manual-entry form dismissal", () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +describe("BarcodeResult edited values (UI-09)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("preserves commas in the product name while normalizing numeric commas", () => { + const onAddToLog = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /edit values/i })); + + // The Name field is a plain label + input pair without htmlFor, so find it by value. + fireEvent.change(screen.getByDisplayValue("Yoghurt"), { + target: { value: "Yoghurt, Grieks" }, + }); + fireEvent.change(screen.getByLabelText("Protein in g"), { + target: { value: "1,5" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /add to log/i })); + + expect(onAddToLog).toHaveBeenCalledTimes(1); + const submitted = onAddToLog.mock.calls[0]![0] as { + label: string; + proteinG: number; + }; + expect(submitted.label).toBe("Yoghurt, Grieks"); + expect(submitted.proteinG).toBe(1.5); + }); +}); diff --git a/apps/web/tests/unit/barcode-scanner-auth.test.tsx b/apps/web/tests/unit/barcode-scanner-auth.test.tsx new file mode 100644 index 00000000..be747ec4 --- /dev/null +++ b/apps/web/tests/unit/barcode-scanner-auth.test.tsx @@ -0,0 +1,106 @@ +/** @vitest-environment jsdom */ +import { render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + decodeFromStream: vi.fn(), + getUserMedia: vi.fn(), +})); + +async function loadScanner() { + vi.doMock("@zxing/browser", () => ({ + BrowserMultiFormatReader: class { + decodeFromStream = mocked.decodeFromStream; + }, + })); + vi.doMock("@zxing/library", () => ({ + DecodeHintType: { TRY_HARDER: "TRY_HARDER" }, + })); + return (await import("@/components/barcode-scanner")).BarcodeScanner; +} + +function jsonResponse(body: unknown, status = 200, headers?: HeadersInit) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function productPayload(barcode: string) { + return { + found: true, + product: { + name: "Recovered bar", + brands: "", + barcode, + proteinG: 5, + carbsG: 5, + fatG: 5, + caloriesKcal: 100, + servingSizeG: 100, + imageUrl: null, + source: "openfoodfacts", + }, + }; +} + +describe("BarcodeScanner auth recovery (UI-07)", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("shows sign-in recovery for 401 and retries the kept code", async () => { + const stopTrack = vi.fn(); + const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; + mocked.getUserMedia.mockResolvedValue(stream); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: mocked.getUserMedia }, + }); + + // First the camera decodes a barcode; the lookup then hits an expired session. + mocked.decodeFromStream.mockImplementation( + async (_stream: unknown, _video: unknown, callback: Function) => { + queueMicrotask(() => + callback({ getText: () => "12345678" }, null, { stop: vi.fn() }), + ); + return { stop: vi.fn() }; + }, + ); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ error: "expired" }, 401)), + ); + + const BarcodeScanner = await loadScanner(); + const onScan = vi.fn(); + const onNotFound = vi.fn(); + render(); + + const signIn = await screen.findByRole("link", { name: /sign in/i }); + expect(signIn.getAttribute("href")).toBe("/login"); + expect( + screen.queryByText(/could not reach the product database/i), + ).toBeNull(); + expect(onScan).not.toHaveBeenCalled(); + expect(onNotFound).not.toHaveBeenCalled(); + + // After signing in elsewhere, Retry reuses the kept code and succeeds. + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(productPayload("12345678"))); + vi.stubGlobal("fetch", fetchMock); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /^retry$/i })); + }); + + await waitFor(() => expect(onScan).toHaveBeenCalledTimes(1)); + expect(onScan.mock.calls[0]![0]).toMatchObject({ barcode: "12345678" }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/api/barcode/12345678"), + expect.anything(), + ); + }); +}); diff --git a/apps/web/tests/unit/openfoodfacts.test.ts b/apps/web/tests/unit/openfoodfacts.test.ts index 073ae0c0..fdca6fb9 100644 --- a/apps/web/tests/unit/openfoodfacts.test.ts +++ b/apps/web/tests/unit/openfoodfacts.test.ts @@ -85,4 +85,71 @@ describe("lookupBarcode", () => { expect(result).toEqual({ found: false, barcode: "33333333", reason: "unavailable" }); }); + + it("returns auth for direct 401 and 403 instead of a connectivity failure", async () => { + for (const status of [401, 403]) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ error: "denied" }, status)), + ); + + const result = await lookupBarcode("44444444"); + + expect(result).toEqual({ found: false, barcode: "44444444", reason: "auth" }); + } + }); + + it("returns auth for a redirected login page and HTML bodies", async () => { + const htmlHeaders = { "content-type": "text/html" }; + const redirected = new Response("login", { + status: 200, + headers: htmlHeaders, + }); + Object.defineProperty(redirected, "redirected", { value: true }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(redirected)); + expect(await lookupBarcode("55555555")).toEqual({ + found: false, + barcode: "55555555", + reason: "auth", + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response("login", { status: 200, headers: htmlHeaders }), + ), + ); + expect(await lookupBarcode("55555555")).toEqual({ + found: false, + barcode: "55555555", + reason: "auth", + }); + + // HTML login served as 200 without a content-type header is still auth. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("login", { status: 200 })), + ); + expect(await lookupBarcode("55555555")).toEqual({ + found: false, + barcode: "55555555", + reason: "auth", + }); + }); + + it("keeps genuine misses and outages distinct from auth", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ found: false }))); + expect(await lookupBarcode("00000000")).toEqual({ + found: false, + barcode: "00000000", + reason: "not_found", + }); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({}, 502))); + expect(await lookupBarcode("11111111")).toEqual({ + found: false, + barcode: "11111111", + reason: "unavailable", + }); + }); }); From 51ac6ad9880fe28e386b77f8c3f4653dedc147b5 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:48:41 +0200 Subject: [PATCH 12/52] fix(ui): suppress global day shortcuts while overlays own keys UI-08: shared shouldSuppressGlobalShortcut covers defaultPrevented events, focus inside dialog/menu/listbox, and any open dialog/menu in the document, so arrows on modal buttons no longer navigate the day and remount the page. --- apps/web/components/app-shell.tsx | 7 + apps/web/components/overlay-portal.tsx | 25 ++++ .../tests/unit/app-shell-shortcuts.test.tsx | 124 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 apps/web/tests/unit/app-shell-shortcuts.test.tsx diff --git a/apps/web/components/app-shell.tsx b/apps/web/components/app-shell.tsx index 439410c7..c2108371 100644 --- a/apps/web/components/app-shell.tsx +++ b/apps/web/components/app-shell.tsx @@ -19,6 +19,7 @@ import { getLocalDateString, getStartupDateRedirect } from "@/lib/startup-date"; import { ProfileSheet } from "./profile-sheet"; import { LinkPendingPulse, TransitionLink } from "./transition-link"; +import { shouldSuppressGlobalShortcut } from "./overlay-portal"; type AppShellProps = { userEmail: string; @@ -131,6 +132,12 @@ export function AppShell({ return; } + // UI-08: dialogs/menus own arrow keys while open; a consumed event must + // not also navigate the day and remount the keyed page beneath them. + if (shouldSuppressGlobalShortcut(event)) { + return; + } + const target = event.target as HTMLElement | null; if (target) { const tag = target.tagName; diff --git a/apps/web/components/overlay-portal.tsx b/apps/web/components/overlay-portal.tsx index 3c4804bf..3a5542f4 100644 --- a/apps/web/components/overlay-portal.tsx +++ b/apps/web/components/overlay-portal.tsx @@ -178,6 +178,31 @@ export function useFocusTrap( }, [active, containerRef]); } +// UI-08: global day shortcuts must yield while an overlay owns keyboard +// interaction. Dialogs/menus render through portals, so the page handler +// checks the live document instead of local state. +export function shouldSuppressGlobalShortcut(event: KeyboardEvent): boolean { + if (event.defaultPrevented) { + return true; + } + + const target = event.target as HTMLElement | null; + if (target && typeof target.closest === "function") { + if (target.closest('[role="dialog"], [role="menu"], [role="listbox"]')) { + return true; + } + } + + if ( + typeof document !== "undefined" && + document.querySelector('[role="dialog"], [role="menu"]') + ) { + return true; + } + + return false; +} + export function OverlayPortal({ children }: { children: ReactNode }) { if (typeof document === "undefined") { return null; diff --git a/apps/web/tests/unit/app-shell-shortcuts.test.tsx b/apps/web/tests/unit/app-shell-shortcuts.test.tsx new file mode 100644 index 00000000..d3f734ab --- /dev/null +++ b/apps/web/tests/unit/app-shell-shortcuts.test.tsx @@ -0,0 +1,124 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + push: vi.fn(), + replace: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mocked.push, replace: mocked.replace }), + usePathname: () => "/", +})); + +import { AppShell } from "@/components/app-shell"; + +function renderLog(selectedDate = "2026-08-18") { + return render( + +
content
+
, + ); +} + +describe("AppShell day shortcuts (UI-08)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("navigates the day with arrows when no overlay is open", () => { + renderLog(); + mocked.push.mockClear(); + + fireEvent.keyDown(document.body, { key: "ArrowRight" }); + + expect(mocked.push).toHaveBeenCalledTimes(1); + expect(mocked.push).toHaveBeenCalledWith( + expect.stringContaining("date=2026-08-19"), + ); + }); + + it("keeps an open dialog stable when arrows are pressed on its button", () => { + render( + <> + +
content
+
+
+ +
+ , + ); + mocked.push.mockClear(); + + const dialogButton = screen.getByRole("button", { name: "Template action" }); + dialogButton.focus(); + fireEvent.keyDown(dialogButton, { key: "ArrowRight" }); + fireEvent.keyDown(dialogButton, { key: "ArrowLeft" }); + + expect(mocked.push).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).not.toBeNull(); + }); + + it("suppresses day navigation while a menu is open", () => { + render( + <> + +
content
+
+
+ +
+ , + ); + mocked.push.mockClear(); + + fireEvent.keyDown(screen.getByRole("menuitem", { name: "Mark eaten" }), { + key: "ArrowLeft", + }); + + expect(mocked.push).not.toHaveBeenCalled(); + }); + + it("does not navigate when the arrow event was already consumed", () => { + renderLog(); + mocked.push.mockClear(); + + const event = new KeyboardEvent("keydown", { + key: "ArrowRight", + bubbles: true, + cancelable: true, + }); + event.preventDefault(); + document.dispatchEvent(event); + + expect(mocked.push).not.toHaveBeenCalled(); + }); +}); From aca13abda2367fda9d6a6f78db92359aef880526 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:50:30 +0200 Subject: [PATCH 13/52] fix(ui): capture the local day per search-modal operation UI-22: history copy and product add use a fresh local date for each new operation instead of the mount-memoized today; retries keep the original date/key until settled. Search transport rejection now surfaces a local error instead of an unhandled rejection. --- apps/web/components/food-search-modal.tsx | 37 +++++- .../unit/food-search-modal-midnight.test.tsx | 118 ++++++++++++++++++ 2 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 apps/web/tests/unit/food-search-modal-midnight.test.tsx diff --git a/apps/web/components/food-search-modal.tsx b/apps/web/components/food-search-modal.tsx index 307ce6ca..2bd8c29b 100644 --- a/apps/web/components/food-search-modal.tsx +++ b/apps/web/components/food-search-modal.tsx @@ -1,7 +1,7 @@ "use client"; import type { FoodProduct, MealEntryRecord } from "@macro-tracker/db"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { saveMealEntryAction, searchFoodsAction } from "@/lib/actions"; import { @@ -32,8 +32,11 @@ export function FoodSearchModal({ onClose, onViewDate, onEntrySaved }: FoodSearc const { copiedIds, setCopiedIds, flash: flashCopied } = useCopiedFlash(2500); const inputRef = useRef(null); const mutationIds = useRef(createClientMutationIdStore()); + // UI-22: the local day is captured per operation, not at mount. A retry of an + // already-started operation keeps its original date/key until it settles so a + // pre-midnight attempt cannot duplicate onto the new day. + const operationDates = useRef(new Map()); - const todayStr = useMemo(() => getLocalDateString(), []); const trimmedQuery = normalizeFoodSearchQuery(query); const hasCurrentResults = hasCurrentFoodSearchResults(resultQuery, trimmedQuery); const visibleResults = hasCurrentResults ? results : []; @@ -83,6 +86,12 @@ export function FoodSearchModal({ onClose, onViewDate, onEntrySaved }: FoodSearc setResults([]); setProducts([]); } + } catch { + if (cancelled) return; + setResultQuery(trimmedQuery); + setError("Search failed."); + setResults([]); + setProducts([]); } finally { if (!cancelled) setIsSearching(false); } @@ -95,17 +104,22 @@ export function FoodSearchModal({ onClose, onViewDate, onEntrySaved }: FoodSearc }, [trimmedQuery]); async function handleCopyToToday(entry: MealEntryRecord) { - const mutationKey = `history:${entry.id}:${todayStr}`; + const operationId = `history:${entry.id}`; + const operationDate = + operationDates.current.get(operationId) ?? getLocalDateString(); + operationDates.current.set(operationId, operationDate); + const mutationKey = `${operationId}:${operationDate}`; setCopyingId(entry.id); setError(null); try { const result = await saveMealEntryAction({ - ...buildMealEntryCopyInput(entry, todayStr), + ...buildMealEntryCopyInput(entry, operationDate), clientMutationId: mutationIds.current.take(mutationKey), }); if (result.ok) { mutationIds.current.settle(mutationKey); + operationDates.current.delete(operationId); if (result.entry) { onEntrySaved?.(result.entry); } @@ -114,13 +128,20 @@ export function FoodSearchModal({ onClose, onViewDate, onEntrySaved }: FoodSearc } setError(result.error ?? "Unable to add this food to today."); + } catch { + // Transport rejection keeps the original date/key so a retry reuses them. + setError("Unable to add this food to today."); } finally { setCopyingId(null); } } async function handleAddProduct(product: FoodProduct) { - const mutationKey = `product:${product.id}:${todayStr}`; + const operationId = `product:${product.id}`; + const operationDate = + operationDates.current.get(operationId) ?? getLocalDateString(); + operationDates.current.set(operationId, operationDate); + const mutationKey = `${operationId}:${operationDate}`; setCopyingId(product.id); setError(null); try { @@ -130,7 +151,7 @@ export function FoodSearchModal({ onClose, onViewDate, onEntrySaved }: FoodSearc ? quantity / 100 : ((product.servingWeightG ?? product.servingVolumeMl ?? 100) * quantity) / 100; const result = await saveMealEntryAction({ - date: todayStr, + date: operationDate, status: "eaten", clientMutationId: mutationIds.current.take(mutationKey), productId: product.id, @@ -145,6 +166,7 @@ export function FoodSearchModal({ onClose, onViewDate, onEntrySaved }: FoodSearc if (result.ok) { mutationIds.current.settle(mutationKey); + operationDates.current.delete(operationId); if (result.entry) { onEntrySaved?.(result.entry); } @@ -153,6 +175,9 @@ export function FoodSearchModal({ onClose, onViewDate, onEntrySaved }: FoodSearc } setError(result.error ?? "Unable to add this food to today."); + } catch { + // Transport rejection keeps the original date/key so a retry reuses them. + setError("Unable to add this food to today."); } finally { setCopyingId(null); } diff --git a/apps/web/tests/unit/food-search-modal-midnight.test.tsx b/apps/web/tests/unit/food-search-modal-midnight.test.tsx new file mode 100644 index 00000000..2d32e047 --- /dev/null +++ b/apps/web/tests/unit/food-search-modal-midnight.test.tsx @@ -0,0 +1,118 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDate = vi.hoisted(() => ({ value: "2026-08-18" })); + +vi.mock("@/lib/startup-date", () => ({ + getLocalDateString: () => mockDate.value, +})); + +const mocked = vi.hoisted(() => ({ + saveMealEntryAction: vi.fn(), + searchFoodsAction: vi.fn(), +})); + +vi.mock("@/lib/actions", () => ({ + saveMealEntryAction: mocked.saveMealEntryAction, + searchFoodsAction: mocked.searchFoodsAction, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", +})); + +import { FoodSearchModal } from "@/components/food-search-modal"; + +const historyEntry = { + id: "history-1", + userId: "user-1", + date: "2026-08-10", + mealGroupId: null, + status: "eaten", + productId: null, + label: "Toast", + quantity: 1, + unit: "serving", + servingMultiplier: 1, + proteinG: 5, + carbsG: 15, + fatG: 2, + caloriesKcal: 120, + sortOrder: 0, + clientMutationId: null, + sourceLabel: null, +}; + +async function searchFor(query: string) { + fireEvent.change(screen.getByRole("textbox"), { target: { value: query } }); + await screen.findByRole("button", { name: /add today/i }); +} + +describe("FoodSearchModal midnight freshness (UI-22)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDate.value = "2026-08-18"; + mocked.searchFoodsAction.mockResolvedValue({ + ok: true, + results: [historyEntry], + products: [], + }); + }); + + it("starts new operations on the fresh day when the modal stayed mounted past midnight", async () => { + mocked.saveMealEntryAction.mockResolvedValue({ ok: true, entry: historyEntry }); + render(); + await searchFor("toast"); + + // Midnight passes while the modal is still mounted. + mockDate.value = "2026-08-19"; + + fireEvent.click(screen.getByRole("button", { name: /add today/i })); + + await waitFor(() => { + expect(mocked.saveMealEntryAction).toHaveBeenCalledTimes(1); + }); + const input = mocked.saveMealEntryAction.mock.calls[0]![0] as { + date: string; + clientMutationId: string; + }; + expect(input.date).toBe("2026-08-19"); + expect(typeof input.clientMutationId).toBe("string"); + }); + + it("retries a pre-midnight attempt with its original date and idempotency key", async () => { + mocked.saveMealEntryAction.mockResolvedValueOnce({ + ok: false, + error: "boom", + }); + render(); + await searchFor("toast"); + + fireEvent.click(screen.getByRole("button", { name: /add today/i })); + await waitFor(() => { + expect(mocked.saveMealEntryAction).toHaveBeenCalledTimes(1); + }); + const first = mocked.saveMealEntryAction.mock.calls[0]![0] as { + date: string; + clientMutationId: string; + }; + expect(first.date).toBe("2026-08-18"); + + // Midnight passes; the retry must not duplicate onto the new day. + mockDate.value = "2026-08-19"; + mocked.saveMealEntryAction.mockResolvedValueOnce({ ok: true, entry: historyEntry }); + + fireEvent.click(screen.getByRole("button", { name: /add today/i })); + await waitFor(() => { + expect(mocked.saveMealEntryAction).toHaveBeenCalledTimes(2); + }); + const second = mocked.saveMealEntryAction.mock.calls[1]![0] as { + date: string; + clientMutationId: string; + }; + expect(second.date).toBe("2026-08-18"); + expect(second.clientMutationId).toBe(first.clientMutationId); + }); +}); From 63804ebe891cbc2f6f8bdec82f1da53310fa868b Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:52:48 +0200 Subject: [PATCH 14/52] fix(ui): honest template-save feedback in capture dialogs UI-01: photo and barcode dialogs await the template save, show Saving and a local error, and mark Saved only on success; resolved failures and transport rejections keep retry. Manual barcode product save handles transport rejection without hanging. Dashboard and recipe consumers return the save promise. --- apps/web/components/ai-food-photo-modal.tsx | 44 +++++-- .../web/components/barcode-capture-modals.tsx | 3 +- apps/web/components/barcode-result.tsx | 108 ++++++++++++------ apps/web/components/dashboard-shell.tsx | 8 +- apps/web/components/recipe-builder-shell.tsx | 4 +- .../tests/unit/ai-food-photo-modal.test.tsx | 70 ++++++++++++ apps/web/tests/unit/barcode-result.test.tsx | 93 +++++++++++++++ 7 files changed, 275 insertions(+), 55 deletions(-) diff --git a/apps/web/components/ai-food-photo-modal.tsx b/apps/web/components/ai-food-photo-modal.tsx index b6ddff47..40837a75 100644 --- a/apps/web/components/ai-food-photo-modal.tsx +++ b/apps/web/components/ai-food-photo-modal.tsx @@ -25,13 +25,15 @@ type AiFoodPhotoModalProps = { fatG: number; caloriesKcal: number; }) => void; + // UI-01: resolves true only when the template actually persisted, so the + // dialog never shows Saved for a failed save. onSaveAsPreset: (input: { label: string; proteinG: number; carbsG: number; fatG: number; caloriesKcal: number; - }) => void; + }) => Promise; }; type ApiResponse = @@ -70,6 +72,8 @@ export function AiFoodPhotoModal({ const [estimate, setEstimate] = useState(null); const [savedPreset, setSavedPreset] = useState(false); const [error, setError] = useState(null); + const [presetError, setPresetError] = useState(null); + const [isSavingPreset, setIsSavingPreset] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [isOptimizing, setIsOptimizing] = useState(false); @@ -100,6 +104,7 @@ export function AiFoodPhotoModal({ setQuestion(null); setClarification(""); setSavedPreset(false); + setPresetError(null); setError(null); if (!file) { @@ -207,6 +212,28 @@ export function AiFoodPhotoModal({ onAddToLog(estimateToMacros(estimate)); } + // UI-01: only mark Saved after the async save actually succeeds; failures + // (resolved false or transport rejection) keep retry available. + async function handleSavePreset() { + if (!estimate || isSavingPreset || savedPreset) { + return; + } + setIsSavingPreset(true); + setPresetError(null); + try { + const saved = await onSaveAsPreset(estimateToMacros(estimate)); + if (saved) { + setSavedPreset(true); + } else { + setPresetError("Unable to save template."); + } + } catch { + setPresetError("Unable to save template."); + } finally { + setIsSavingPreset(false); + } + } + return (
@@ -375,6 +402,12 @@ export function AiFoodPhotoModal({

) : null} + {presetError ? ( +

+ {presetError} +

+ ) : null} +
{!estimate ? ( )} diff --git a/apps/web/components/barcode-capture-modals.tsx b/apps/web/components/barcode-capture-modals.tsx index a6315e9c..0f1b2a5f 100644 --- a/apps/web/components/barcode-capture-modals.tsx +++ b/apps/web/components/barcode-capture-modals.tsx @@ -16,7 +16,8 @@ type BarcodeCaptureModalsProps = { setScanResult: (product: OpenFoodFactsProduct | null) => void; setNotFoundBarcode: (barcode: string | null) => void; onAddToLog: (input: BarcodeFoodSelection) => void; - onSaveAsPreset: (input: BarcodeFoodSelection) => void; + // UI-01: resolves true only when the template actually persisted. + onSaveAsPreset: (input: BarcodeFoodSelection) => Promise; }; export function BarcodeCaptureModals({ diff --git a/apps/web/components/barcode-result.tsx b/apps/web/components/barcode-result.tsx index 50a0c1d4..4ddfd1fb 100644 --- a/apps/web/components/barcode-result.tsx +++ b/apps/web/components/barcode-result.tsx @@ -20,7 +20,8 @@ type BarcodeResultProps = { product: OpenFoodFactsProduct | null; notFoundBarcode: string | null; onAddToLog: (input: BarcodeFoodSelection) => void; - onSaveAsPreset: (input: BarcodeFoodSelection) => void; + // UI-01: resolves true only when the template actually persisted. + onSaveAsPreset: (input: BarcodeFoodSelection) => Promise; onScanAnother: () => void; onClose: () => void; }; @@ -113,38 +114,43 @@ function NotFoundForm({ setIsSaving(true); setSaveError(null); - const result = await saveBarcodeFoodProductAction({ - barcode, - name, - brands: form.brands.trim(), - caloriesKcal, - proteinG, - carbsG, - fatG, - servingSizeG, - }); - - setIsSaving(false); - - if (!result.ok || !result.product) { - setSaveError(result.error ?? "Failed to save product."); - return; + try { + const result = await saveBarcodeFoodProductAction({ + barcode, + name, + brands: form.brands.trim(), + caloriesKcal, + proteinG, + carbsG, + fatG, + servingSizeG, + }); + + if (!result.ok || !result.product) { + setSaveError(result.error ?? "Failed to save product."); + return; + } + + // Hand the saved product back up so the normal product view can render + onProductSaved({ + productId: result.product.id, + name: result.product.name, + brands: result.product.brand, + barcode: result.product.barcode ?? barcode, + proteinG: result.product.proteinPer100, + carbsG: result.product.carbsPer100, + fatG: result.product.fatPer100, + caloriesKcal: result.product.caloriesPer100, + servingSizeG: result.product.servingWeightG, + imageUrl: null, + source: "custom", + }); + } catch { + // Transport rejection (UI-05): keep the form open with a retryable error. + setSaveError("Failed to save product."); + } finally { + setIsSaving(false); } - - // Hand the saved product back up so the normal product view can render - onProductSaved({ - productId: result.product.id, - name: result.product.name, - brands: result.product.brand, - barcode: result.product.barcode ?? barcode, - proteinG: result.product.proteinPer100, - carbsG: result.product.carbsPer100, - fatG: result.product.fatPer100, - caloriesKcal: result.product.caloriesPer100, - servingSizeG: result.product.servingWeightG, - imageUrl: null, - source: "custom", - }); } if (!showForm) { @@ -375,6 +381,8 @@ export function BarcodeResult({ const defaultServing = displayProduct?.servingSizeG ?? 100; const [servingG, setServingG] = useState(String(defaultServing)); const [savedPreset, setSavedPreset] = useState(false); + const [isSavingPreset, setIsSavingPreset] = useState(false); + const [presetError, setPresetError] = useState(null); // While editing, add-to-log and save-as-template send these raw values instead of the scaled ones. const [isEditing, setIsEditing] = useState(false); const [edited, setEdited] = useState(emptyEdited); @@ -463,6 +471,28 @@ export function BarcodeResult({ setEdited((prev) => ({ ...prev, [field]: value.replace(/,/g, ".") })); } + // UI-01: only mark Saved after the async save actually succeeds; failures + // (resolved false or transport rejection) keep retry available. + async function handleSavePreset() { + if (savedPreset || isSavingPreset) { + return; + } + setIsSavingPreset(true); + setPresetError(null); + try { + const saved = await onSaveAsPreset(valuesToSubmit()); + if (saved) { + setSavedPreset(true); + } else { + setPresetError("Unable to save template."); + } + } catch { + setPresetError("Unable to save template."); + } finally { + setIsSavingPreset(false); + } + } + function sourceLabel( source: OpenFoodFactsProduct["source"], ): string { @@ -655,17 +685,19 @@ export function BarcodeResult({ > Add to log + {presetError ? ( +

+ {presetError} +

+ ) : null}