Commit 99d02bfbdf for handsontable.com

commit 99d02bfbdf5f32dac04961732f09218fea09b268
Author: Artur Mędrygał <artur.medrygal@handsontable.com>
Date:   Tue Sep 1 12:23:55 2026 +0200

    DEV-2710: Replace Argos with reg-suit and Cloudflare R2 (#13313)

    * DEV-2710: Replace Argos with reg-suit and Cloudflare R2

    * DEV-2710: Ignore local .env files holding R2 credentials

    * DEV-2710: Seed golden records when a base branch has none

    * DEV-2710: Document the golden-record bootstrap behavior

    * DEV-2710: Tag textExtractor with @configScope to unblock develop

    * DEV-2710: Add a flow diagram for the visual comparison

    * DEV-2710: TEMP break cell padding to prove the visual gate catches it

    * DEV-2710: TEMP recolor cell text to prove the visual gate catches it

    * DEV-2710: TEMP run Visual even when an earlier job is red

    * DEV-2710: Pass the head ref so reg-suit can comment on the PR

    * DEV-2710: Comment the visual verdict with the report link and how to approve

    * DEV-2710: Revert the temporary visual regression and CI gate

    * DEV-2710: Tolerate antialiasing noise in the visual comparison

    * DEV-2710: Address review findings from demtario and Bugbot

    * DEV-2710: Address the second review round

    * DEV-2710: Address Daniel's review findings

    * DEV-2710: Address budnix's review findings

    * DEV-2710: Block an empty report on the bootstrap path too

    * DEV-2710: Guard the reconcile step and count deleted items

    * DEV-2710: Count only rendered buckets in the reconcile guard

diff --git a/.ai/STACK.md b/.ai/STACK.md
index ed5d287929..dabd37ffdb 100644
--- a/.ai/STACK.md
+++ b/.ai/STACK.md
@@ -184,7 +184,8 @@
 - `manual-qa.yml` - the human merge gate, and a `CI Gate` dependency like every module above
 - `integration.yml` - wrapper tests, ESM/CJS format checks, pkg.pr.new PR package preview
 - `performance.yml` (PR) / `performance-tests.yml` (develop golden baseline) - the CDP perf suite
-- `visual.yml` - visual regression render matrix plus the Argos upload
+- `visual.yml` - visual regression render matrix plus the reg-suit comparison and approval gate
+- `visual-cleanup.yml` - clears the `visual-approved` label on new commits (the R2 screenshot purge lives in `pr-cleanup.yml`)
 - `publish.yml` - package publishing pipeline to npm (the only workflow npm trusted publishing trusts)
 - `docs.yml` / `docs-staging.yml` / `docs-production.yml` - docs gates and deployment (Cloudflare Pages)
 - `docs-visual-tests.yml` - visual regression testing for docs
diff --git a/.ai/STRUCTURE.md b/.ai/STRUCTURE.md
index a5686d21c6..98e7904adb 100644
--- a/.ai/STRUCTURE.md
+++ b/.ai/STRUCTURE.md
@@ -62,7 +62,7 @@ packages:
 | `wrappers/react-wrapper/` | `@handsontable/react-wrapper` | React component wrapper |
 | `wrappers/angular-wrapper/` | `@handsontable/angular-wrapper` | Angular component wrapper |
 | `wrappers/vue3/` | `@handsontable/vue3` | Vue 3 component wrapper |
-| `visual-tests/` | `handsontable-visual-tests` | Playwright/Argos visual regression suite |
+| `visual-tests/` | `handsontable-visual-tests` | Playwright/reg-suit visual regression suite |
 | `examples/` | `handsontable-examples-internal` | Code examples for documentation |
 | `docs/` | `handsontable-documentation` | Astro/Starlight documentation site (Node 20) |
 | `docs/angular-type-check/` | `docs-angular-type-check` | Internal Angular type-check workspace for the docs site |
diff --git a/.ai/TESTING.md b/.ai/TESTING.md
index e0aa955f50..28479ac37a 100644
--- a/.ai/TESTING.md
+++ b/.ai/TESTING.md
@@ -19,7 +19,7 @@ global helpers, mocking, fixtures, custom matchers), see
 | React wrapper | Jest + `@testing-library/react` | — | `npm --prefix wrappers/react-wrapper run test` | `wrappers/react-wrapper/AGENTS.md` |
 | Vue 3 wrapper | Jest + `@vue/test-utils` | — | `npm --prefix wrappers/vue3 run test` | `wrappers/vue3/AGENTS.md` |
 | Angular wrapper | `jest-preset-angular` | — | `npm --prefix wrappers/angular-wrapper run test` | `wrappers/angular-wrapper/AGENTS.md` |
-| Visual regression | Playwright + Argos | — | `npm --prefix visual-tests run test` | `visual-tests/AGENTS.md`; skills `visual-testing`, `creating-visual-test-examples` |
+| Visual regression | Playwright + reg-suit | — | `npm --prefix visual-tests run test` | `visual-tests/AGENTS.md`; skills `visual-testing`, `creating-visual-test-examples` |
 | Performance | Playwright + CDP traces | — | `node scripts/run.mjs` (run from `performance-tests/`) | skill `performance-testing` |

 ## Notes
diff --git a/.claude/skills/visual-testing/SKILL.md b/.claude/skills/visual-testing/SKILL.md
index f11a9738e3..a7a7a41ee6 100644
--- a/.claude/skills/visual-testing/SKILL.md
+++ b/.claude/skills/visual-testing/SKILL.md
@@ -1,14 +1,14 @@
 ---
 name: visual-testing
 path: visual-tests/**
-description: Use when writing Playwright visual regression tests or creating visual test examples for Handsontable - covers the custom tablePage fixture, screenshot workflow, Argos CI integration, and example creation in examples/next/docs/
+description: Use when writing Playwright visual regression tests or creating visual test examples for Handsontable - covers the custom tablePage fixture, screenshot workflow, reg-suit comparison, and example creation in examples/next/docs/
 ---

 # Visual Regression Testing with Playwright

 ## Overview

-Visual tests live in `visual-tests/` and use Playwright with TypeScript. Screenshots are compared against baselines by Argos CI to detect visual regressions. There are two workflows: (a) add visual tests for existing examples, and (b) create a new example in `examples/next/docs/` and write visual tests for it.
+Visual tests live in `visual-tests/` and use Playwright with TypeScript. Screenshots are compared against golden records by reg-suit to detect visual regressions. There are two workflows: (a) add visual tests for existing examples, and (b) create a new example in `examples/next/docs/` and write visual tests for it.

 ## Custom Fixture: `tablePage`

@@ -46,7 +46,7 @@ test(__filename, async({ tablePage }) => {
 });
 ```

-Always use `helpers.screenshotPath()` for the `path` argument. It auto-generates unique, deterministic file names based on the test file path, browser, framework, and screenshot index. Using any other naming approach will break Argos CI comparison.
+Always use `helpers.screenshotPath()` for the `path` argument. It auto-generates unique, deterministic file names based on the test file path, browser, framework, and screenshot index. Using any other naming approach will break the comparison, which matches screenshots by path.

 ## Test Organization

@@ -93,7 +93,7 @@ When creating a new example at `examples/next/docs/js/demo/`, include:

 ## Common Mistakes

-- Not using `helpers.screenshotPath()` -- breaks Argos CI screenshot matching.
+- Not using `helpers.screenshotPath()` -- breaks screenshot matching, which is purely path-based.
 - Forgetting to wait for animations or async rendering before taking a screenshot.
 - Placing JS-only tests in `multi-frameworks/` or vice versa.
 - Not disabling animations -- the `tablePage` fixture handles this, but if you use `goto` to navigate to a different page, animations are still disabled automatically by the fixture.
diff --git a/.github/scripts/__tests__/fork-guards.test.mjs b/.github/scripts/__tests__/fork-guards.test.mjs
index a3c4553665..7246166189 100644
--- a/.github/scripts/__tests__/fork-guards.test.mjs
+++ b/.github/scripts/__tests__/fork-guards.test.mjs
@@ -72,6 +72,11 @@ const GUARDED_SITES = [
   ['.github/workflows/code-quality.yml', '  sonarcloud:'],
   ['.github/workflows/code-quality.yml', '  fossa:'],
   ['.github/workflows/docs.yml', '  preview:'],
+  ['.github/workflows/visual.yml', 'name: Compare against the golden records'],
+  ['.github/workflows/visual.yml', 'name: Seed the golden records'],
+  ['.github/workflows/visual.yml', 'name: Comment the visual verdict on the pull request'],
+  ['.github/workflows/pr-cleanup.yml', '  purge-visual-screenshots:'],
+  ['.github/workflows/visual-cleanup.yml', '  reset-approval:'],
 ];

 test('every fork-hostile site carries both halves of the canonical guard', () => {
@@ -121,20 +126,37 @@ test('the guarded-site list in AGENTS.md names every file that carries a guard',
   }
 });

-// Regression lock. @argos-ci/core falls back to tokenless upload on public GitHub
-// Actions repos, so this step genuinely works with an empty ARGOS_TOKEN: verified
-// on fork PR #13207 (run 32481421878), where it passed and the Argos App reported
-// build 5874. It was guarded once, on the false premise that naming a secret
-// implies needing one, which silently removed visual review for every external
-// contributor. An absent secret is an empty string, not a 403.
-test('the Argos upload is NOT fork-guarded (tokenless upload works on forks)', () => {
-  const expression = guardAfter(read('.github/workflows/visual.yml'), 'name: Upload the screenshots to Argos CI');
+// Regression lock. Writing to R2 needs real credentials, which a fork never
+// gets, so the credentialed comparison carries the canonical guard. Guarding it
+// without a replacement would delete visual review for every external
+// contributor -- the mistake made once on this same workflow and reverted in
+// #13222 (DEV-2592). The fork path reads the golden records from the public bucket
+// over anonymous HTTPS and publishes nothing, so both paths end at the same
+// `visual-gate.mjs` verdict and an external contribution is held to it too.
+test('a fork still gets a visual comparison', () => {
+  const visual = read('.github/workflows/visual.yml');
+  const anchor = 'name: Compare against the golden records (no credentials)';
+
+  assert.ok(
+    visual.includes(anchor),
+    'visual.yml has no credential-free comparison step, so fork and Dependabot PRs '
+      + 'get no visual review at all. Restore it rather than guarding the gate away.'
+  );
+
+  const expression = guardAfter(visual, anchor);
+
+  assert.ok(expression, `${anchor}: no if: condition found, so it would also run on same-repo PRs`);
+
+  assert.match(
+    expression,
+    /head\.repo\.full_name != github\.repository/,
+    `${anchor}: must be the fork half of the split`
+  );

-  assert.equal(
+  assert.match(
     expression,
-    null,
-    'the Argos upload step has an if: condition. If it is a fork guard, remove it: '
-      + 'tokenless upload works on forks and guarding it deletes visual review for external contributors.'
+    /github\.actor == 'dependabot\[bot\]'/,
+    `${anchor}: must also cover Dependabot, whose token is downgraded like a fork's`
   );
 });

diff --git a/.github/workflows/pr-cleanup.yml b/.github/workflows/pr-cleanup.yml
index 438063e264..33f2442db3 100644
--- a/.github/workflows/pr-cleanup.yml
+++ b/.github/workflows/pr-cleanup.yml
@@ -65,3 +65,34 @@ jobs:
           CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
           CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
           CF_PROJECT_NAME: handsontable-docs-staging
+
+  purge-visual-screenshots:
+    name: Purge visual screenshots
+    # Same-repo only. A fork or Dependabot run gets no secrets, so `aws s3 rm`
+    # would hard-fail -- and it would be a no-op anyway, because the credentialed
+    # publish steps in visual.yml are themselves same-repo-guarded and
+    # compare-fork.mjs never writes to R2, so no `pr-<n>/` prefix exists. One
+    # exception: a human pushing to a Dependabot branch flips the actor and
+    # re-enables the publish, yet Dependabot closes its own PR, so that tree is
+    # left to the 30-day R2 lifecycle rule.
+    if: github.event_name == 'pull_request'
+      && (github.event.pull_request.head.repo.full_name == github.repository
+          && github.actor != 'dependabot[bot]')
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: .
+    env:
+      R2_BUCKET_NAME: handsontable-visual
+    steps:
+      # The AWS CLI ships on the runner image. `wrangler r2 object delete` has no
+      # recursive mode, so the S3 API is the practical way to drop a prefix.
+      - name: Delete this pull request's screenshots from R2
+        env:
+          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+          AWS_DEFAULT_REGION: auto
+        run: |
+          aws s3 rm "s3://$R2_BUCKET_NAME/pr-${{ github.event.number }}/" \
+            --recursive \
+            --endpoint-url "https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
diff --git a/.github/workflows/visual-cleanup.yml b/.github/workflows/visual-cleanup.yml
new file mode 100644
index 0000000000..3647fdc1ea
--- /dev/null
+++ b/.github/workflows/visual-cleanup.yml
@@ -0,0 +1,51 @@
+name: Visual approval reset
+
+# Clears the `visual-approved` label whenever a pull request gains new commits,
+# so an approval only ever covers the screenshots someone actually reviewed.
+#
+# On-PR-CLOSE cleanup does NOT belong here: `pr-cleanup.yml` is the single
+# close-triggered pipeline, and the R2 screenshot purge lives there as
+# `purge-visual-screenshots`. This file exists only because `synchronize` and
+# `reopened` are outside that workflow's trigger.
+on:
+  pull_request:
+    types: [ synchronize, reopened ]
+
+permissions:
+  contents: read
+
+jobs:
+  reset-approval:
+    name: Reset visual approval
+    # Same-repo only, for the same reason: a fork or Dependabot pull_request run
+    # holds a read-only token, so this write 403s. The visual gate compensates by
+    # ignoring `visual-approved` entirely on those runs, so a label that cannot
+    # be cleared here can never approve anything either.
+    if: github.event_name == 'pull_request'
+      && (github.event.action == 'synchronize' || github.event.action == 'reopened')
+      && (github.event.pull_request.head.repo.full_name == github.repository
+          && github.actor != 'dependabot[bot]')
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      pull-requests: write
+    steps:
+      - name: Drop the visual-approved label
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_REPO: ${{ github.repository }}
+          PR_NUMBER: ${{ github.event.number }}
+        # An absent label is the common case and fine. Anything else is not:
+        # a silently swallowed failure would leave a stale approval covering
+        # screenshots nobody reviewed, which is the one thing this job exists
+        # to prevent.
+        run: |
+          if gh pr edit "$PR_NUMBER" --remove-label visual-approved 2>/tmp/err; then
+            echo "Removed the visual-approved label."
+          elif grep -qiE "not (found|labeled)|does not have" /tmp/err; then
+            echo "No visual-approved label to remove."
+          else
+            echo "::error::Could not remove the visual-approved label:"
+            cat /tmp/err
+            exit 1
+          fi
diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml
index 0c8d91aa57..cb4efc3c1f 100644
--- a/.github/workflows/visual.yml
+++ b/.github/workflows/visual.yml
@@ -2,12 +2,17 @@ name: Visual

 # Visual regression — the post-test budgeting gate (the orchestrator runs it
 # only after the functional tests pass). Two render configs (multi-framework
-# chromium, cross-browser chromium+firefox+webkit) as a matrix; then the Argos
-# upload consumes both screenshot sets. Each config downloads the core + wrapper
+# chromium, cross-browser chromium+firefox+webkit) as a matrix; then the compare
+# job consumes both screenshot sets. Each config downloads the core + wrapper
 # build artifacts, falling back to a local build if an artifact is missing.
 on:
   workflow_call:

+# Not secrets, so these stay readable on fork runs too.
+env:
+  R2_BUCKET_NAME: handsontable-visual
+  VISUAL_REPORT_DOMAIN: visual.handsontable.com
+
 jobs:
   render:
     name: ${{ matrix.config.name }}
@@ -124,17 +129,28 @@ jobs:
         with:
           name: ${{ matrix.config.artifact }}
           path: ${{ matrix.config.tar }}
+          # "Re-run all jobs" re-uploads under a name that already exists; without
+          # this the render job fails and Compare never runs.
+          overwrite: true

-  argos:
-    name: Argos
+  compare:
+    name: Compare
     runs-on: ubuntu-latest
     needs: [ render ]
-    if: ${{ !failure() && !cancelled() }}
+    # Tags reach here through publish.yml -> test.yml. They are not base
+    # branches: publishing would leave a full tree under `base/<tag>/` that
+    # nothing reads and no lifecycle rule purges.
+    if: ${{ !failure() && !cancelled() && github.ref_type != 'tag' }}
+    # Job-level, mirroring integration.yml's preview job: the sticky-comment
+    # step below needs it to post the verdict. Kept off the workflow level so
+    # the `render` jobs keep the caller's default grant untouched.
+    permissions:
+      contents: read
+      pull-requests: write
     steps:
+      # No `fetch-depth: 0` — the snapshot keys come from the event context, not
+      # from walking the git graph, so a shallow clone is enough.
       - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # https://github.com/actions/checkout/releases/tag/v6.0.2
-        with:
-          fetch-depth: 0 # full history for Argos CI
-          filter: blob:none # blobless clone
       - uses: ./.github/actions/setup-workspace
       - name: Download multi-framework screenshots
         uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # https://github.com/actions/download-artifact/releases/tag/v8.0.0
@@ -149,19 +165,267 @@ jobs:
       - run: |
           tar -zxf screenshots-multi.tar.gz
           tar -zxf screenshots-cross.tar.gz
-      - run: tar -zcf screenshots.tar.gz ./visual-tests/screenshots
-      - name: Upload the test results artifact
+      # Golden records live under `base/<branch>` and are rewritten by every
+      # build of that branch. A PR compares against the branch it targets, so
+      # PRs into a release or LTS branch pick up the right baseline for free.
+      - name: Resolve the snapshot keys
+        # Context values go through the environment rather than into the script
+        # body, so a branch name can never be read as shell.
+        env:
+          PR_NUMBER: ${{ github.event.pull_request.number }}
+          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+          BASE_REF: ${{ github.base_ref }}
+          REF_NAME: ${{ github.ref_name }}
+        run: |
+          if [ -n "$PR_NUMBER" ]; then
+            echo "REG_EXPECTED_KEY=base/$BASE_REF" >> "$GITHUB_ENV"
+            echo "REG_ACTUAL_KEY=pr-$PR_NUMBER/$PR_HEAD_SHA" >> "$GITHUB_ENV"
+          else
+            # `lts/*` is listed for correctness, but nothing triggers this
+            # workflow on an LTS push today: test.yml pushes on master,
+            # develop.yml on develop, publish.yml on release/** and explicitly
+            # excludes lts/. So an LTS baseline is seeded by the first pull
+            # request into that branch and never refreshed -- see the caveat in
+            # visual-tests/README.md.
+            case "$REF_NAME" in
+              develop|master|release/*|lts/*) ;;
+              *)
+                echo "::notice::$REF_NAME is not a base branch; skipping the visual comparison."
+                echo "VISUAL_SKIP=true" >> "$GITHUB_ENV"
+                ;;
+            esac
+            echo "REG_EXPECTED_KEY=base/$REF_NAME" >> "$GITHUB_ENV"
+            echo "REG_ACTUAL_KEY=base/$REF_NAME" >> "$GITHUB_ENV"
+          fi
+
+      # A branch with no golden records yet must not wedge every PR opened
+      # against it. The probe is a plain HTTPS GET against the public bucket, so
+      # it works on fork runs too, and it reads the same `out.json` that the
+      # fetch contract relies on.
+      - name: Check for golden records
+        if: env.VISUAL_SKIP != 'true'
+        run: |
+          # Only a definite 404 means "no baseline yet". A transport failure or
+          # any other status must never be read as absence: the seed step below
+          # would then overwrite `base/<branch>`, the baseline every other pull
+          # request compares against, with this build's render.
+          code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 \
+            --retry 3 --retry-all-errors --retry-delay 2 \
+            -L -I "https://$VISUAL_REPORT_DOMAIN/$REG_EXPECTED_KEY/out.json") || code=000
+          case "$code" in
+            2??)
+              echo "VISUAL_BOOTSTRAP=false" >> "$GITHUB_ENV"
+              echo "Golden records found for $REG_EXPECTED_KEY."
+              ;;
+            404)
+              echo "VISUAL_BOOTSTRAP=true" >> "$GITHUB_ENV"
+              echo "No golden records for $REG_EXPECTED_KEY — this build seeds them."
+              ;;
+            *)
+              echo "::error::Could not tell whether golden records exist for $REG_EXPECTED_KEY (HTTP $code)."
+              echo "Refusing to guess — seeding on a transient failure would overwrite the baseline."
+              exit 1
+              ;;
+          esac
+
+      # Two comparison paths, one verdict. Both write `.reg/out.json`, and the
+      # gate below reads only that, so a fork is held to the same standard.
+      - name: Compare against the golden records
+        if: env.VISUAL_SKIP != 'true'
+          && (github.event_name != 'pull_request'
+              || (github.event.pull_request.head.repo.full_name == github.repository
+                  && github.actor != 'dependabot[bot]'))
+        env:
+          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+          R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
+        run: npm run in visual-tests compare
+
+      # A fork gets no secrets, so it reads the golden records over anonymous
+      # HTTPS and publishes nothing. Guarding the comparison away instead would
+      # delete visual review for external contributors -- see the fork-guard
+      # rules in the root AGENTS.md.
+      - name: Compare against the golden records (no credentials)
+        if: github.event_name == 'pull_request'
+          && (github.event.pull_request.head.repo.full_name != github.repository
+              || github.actor == 'dependabot[bot]')
+        env:
+          BASE_REF: ${{ github.base_ref }}
+        run: node ./visual-tests/scripts/compare-fork.mjs "$BASE_REF"
+
+      # reg-suit only ever PUTs, and its fetch lists the whole prefix, so a
+      # deleted or renamed screenshot would linger under `base/<branch>/actual/`
+      # and show up as a phantom deletion on every later pull request. Reconcile
+      # the prefix to exactly what this build rendered.
+      - name: Reconcile the golden records
+        if: env.VISUAL_SKIP != 'true' && github.event_name != 'pull_request'
+        env:
+          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+          AWS_DEFAULT_REGION: auto
+          R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
+        run: |
+          # `--delete` makes a failed render catastrophic rather than harmless:
+          # it would remove objects under `base/<branch>/actual/`, and the gate is
+          # pull-request-only, so this run stays green while every open pull
+          # request fails against a baseline that is no longer there.
+          #
+          # Count only the buckets that prove something was rendered. A missing
+          # screenshots directory still fetches the goldens, so the report comes
+          # back as all `deletedItems` and a plain "is the report empty" test
+          # passes it straight through.
+          rendered=$(jq '(.failedItems | length) + (.newItems | length) + (.passedItems | length)' \
+            ./visual-tests/.reg/out.json)
+          deleted=$(jq '(.deletedItems | length)' ./visual-tests/.reg/out.json)
+
+          if [ "$rendered" = "0" ]; then
+            echo "::error::The comparison rendered no screenshots; refusing to reconcile."
+            echo "Syncing with --delete here would erase the baseline ($deleted goldens)."
+            exit 1
+          fi
+
+          # A partial render is not distinguishable from a deliberate mass
+          # removal, so this warns rather than blocks -- a false refusal here
+          # blocks trunk, which is worse. It gives the run a searchable marker if
+          # a later PR reports a baseline that lost screenshots.
+          if [ "$deleted" -gt "$((rendered / 4))" ]; then
+            echo "::warning::Reconcile is removing $deleted goldens against $rendered rendered." \
+              "If that was not a deliberate removal or rename, check the render step."
+          fi
+
+          aws s3 sync ./visual-tests/.reg/actual "s3://$R2_BUCKET_NAME/$REG_EXPECTED_KEY/actual" \
+            --delete --endpoint-url "$R2_ENDPOINT"
+
+      # First build for this base branch: promote what we just rendered to the
+      # golden records, so the next PR has something to compare against. The
+      # base branch's own next build overwrites this with the authoritative
+      # render, so an unreviewed baseline can survive at most one merge.
+      - name: Seed the golden records
+        if: env.VISUAL_BOOTSTRAP == 'true'
+          && github.event_name == 'pull_request'
+          && (github.event.pull_request.head.repo.full_name == github.repository
+              && github.actor != 'dependabot[bot]')
+        env:
+          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+          AWS_DEFAULT_REGION: auto
+          R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
+        run: |
+          # The probe alone is not enough. A base build killed mid-publish leaves
+          # `actual/**` up with no manifest: the probe then reads 404 while
+          # reg-suit compares against those actuals for real. Seeding there would
+          # replace the baseline with one pull request's render, and this step
+          # runs before the gate, so the damage would be done before anything
+          # went red. Same signal the evaluator uses.
+          if [ -f ./visual-tests/.reg/out.json ]; then
+            checked=$(jq '(.failedItems | length) + (.passedItems | length) + (.deletedItems | length)' \
+              ./visual-tests/.reg/out.json)
+            total=$(jq '(.failedItems | length) + (.newItems | length) + (.deletedItems | length) + (.passedItems | length)' \
+              ./visual-tests/.reg/out.json)
+
+            if [ "$checked" != "0" ]; then
+              echo "::error::The probe reported no golden records, but the comparison found some."
+              echo "Refusing to seed: the baseline exists and this build must not replace it."
+              exit 1
+            fi
+
+            # An empty report means reg-suit globbed nothing, not that this is a
+            # clean first build. Seeding it would publish a blank manifest, and
+            # the probe would then return 200 forever.
+            if [ "$total" = "0" ]; then
+              echo "::error::The comparison produced an empty report; there is nothing to seed."
+              exit 1
+            fi
+          fi
+
+          aws s3 cp ./visual-tests/screenshots "s3://$R2_BUCKET_NAME/$REG_EXPECTED_KEY/actual" \
+            --recursive --endpoint-url "$R2_ENDPOINT"
+          aws s3 cp ./visual-tests/.reg/out.json "s3://$R2_BUCKET_NAME/$REG_EXPECTED_KEY/out.json" \
+            --endpoint-url "$R2_ENDPOINT"
+
+      # `reg-suit run` exits 0 whatever it finds, so this step is the whole gate.
+      # It always runs, even when approved, so the comment below is refreshed
+      # rather than left showing a verdict that no longer holds.
+      # `always()`, not the implicit `success()`: an earlier failure would
+      # otherwise skip this, leave `.reg/comment.md` unwritten, and let the
+      # sticky step below fail on a missing path while the previous push's
+      # verdict stayed up -- the staleness this whole arrangement avoids.
+      - name: Visual approval gate
+        if: always() && github.event_name == 'pull_request'
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          PR_NUMBER: ${{ github.event.pull_request.number }}
+          REPO: ${{ github.repository }}
+          # `visual-cleanup.yml` cannot strip the label on a fork or Dependabot
+          # push (read-only token), so an approval there would outlive the
+          # screenshots it covered. Those runs ignore the label entirely.
+          IS_UNTRUSTED: ${{ github.event.pull_request.head.repo.full_name != github.repository
+            || github.actor == 'dependabot[bot]' }}
+          VISUAL_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        run: |
+          # A fork run publishes nothing, so there is no hosted report to link.
+          if [ "$IS_UNTRUSTED" = "true" ]; then
+            export VISUAL_PUBLISHED=false
+          else
+            export VISUAL_PUBLISHED=true
+          fi
+
+          # Read the labels live. `github.event.pull_request.labels` is a
+          # snapshot of the triggering event, so a re-run after applying the
+          # label would still see the old set and could never turn green.
+          if [ "$IS_UNTRUSTED" = "true" ]; then
+            approved=false
+          elif ! approved=$(gh api "repos/$REPO/issues/$PR_NUMBER/labels" \
+                 --jq 'any(.[]; .name == "visual-approved")' 2>/tmp/label-err); then
+            # Steps run under `set -e`, so an unhandled failure here would abort
+            # before the gate runs and leave the sticky comment showing a stale
+            # verdict. Fall back to "not approved": the safe direction, and the
+            # gate still writes a fresh comment explaining the result.
+            echo "::warning::Could not read the pull request labels; treating as not approved."
+            cat /tmp/label-err
+            approved=false
+          fi
+          echo "visual-approved present: $approved"
+
+          # Tell the contributor why their label did nothing, rather than
+          # leaving them to re-apply it against instructions that cannot work.
+          if [ "$IS_UNTRUSTED" = "true" ] \
+            && gh api "repos/$REPO/issues/$PR_NUMBER/labels" \
+                 --jq 'any(.[]; .name == "visual-approved")' 2>/dev/null | grep -q true; then
+            echo "::warning::visual-approved is ignored on fork and Dependabot runs, because" \
+              "nothing can clear it when you push again. A maintainer has to re-raise the branch" \
+              "from the main repository to approve these differences."
+          fi
+
+          VISUAL_APPROVED="$approved" VISUAL_SEEDED="$([ "$IS_UNTRUSTED" = true ] && echo false || echo true)" \
+            node ./visual-tests/scripts/visual-gate.mjs
+
+      # One sticky comment, rewritten in place, carrying the counts, the report
+      # link and how to accept the changes. Fork runs have no write token, so
+      # they are excluded by the same guard the rest of the repo uses.
+      # Unguarded, and needs no token: on a fork run the comment step below is
+      # guarded off, so this is the only place the verdict is readable. Root
+      # AGENTS.md: "Withheld content goes to $GITHUB_STEP_SUMMARY".
+      - name: Mirror the verdict to the job summary
+        if: always() && github.event_name == 'pull_request'
+        run: cat visual-tests/.reg/comment.md >> "$GITHUB_STEP_SUMMARY" || true
+
+      # Kept for the runs that produced no hosted report, or a red gate. A green
+      # same-repo run needs neither: the report is already published to R2.
+      - name: Upload the visual diff report
+        if: failure() && github.event_name == 'pull_request'
         uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # https://github.com/actions/upload-artifact/releases/tag/v7.0.0
         with:
-          name: visual-tests-screenshots
-          path: screenshots.tar.gz
-      # Deliberately NOT fork-guarded. @argos-ci/core falls back to tokenless
-      # upload on public GitHub Actions repos, so this genuinely works with an
-      # empty ARGOS_TOKEN: verified on fork PR #13207 (run 32481421878), where
-      # the step passed and the Argos App reported build 5874. Guarding it would
-      # delete visual review for every external contributor.
-      - name: Upload the screenshots to Argos CI
-        env:
-          ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
-          ARGOS_BRANCH: ${{ github.head_ref || github.ref_name }}
-        run: npm run in visual-tests upload
+          name: visual-diff-report
+          path: visual-tests/.reg
+          retention-days: 3
+          overwrite: true
+
+      - name: Comment the visual verdict on the pull request
+        if: ${{ !cancelled() && github.event_name == 'pull_request'
+          && (github.event.pull_request.head.repo.full_name == github.repository
+              && github.actor != 'dependabot[bot]') }}
+        uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # https://github.com/marocchino/sticky-pull-request-comment/releases/tag/v3.0.4
+        with:
+          header: visual-tests
+          path: visual-tests/.reg/comment.md
diff --git a/.gitignore b/.gitignore
index e874f14b6a..88b8d65102 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,10 @@ node_modules/
 docs/tests/test-artifacts/
 docs/tests/.env
 docs/.env
+# Local credentials for any package (R2 keys for the visual suite, etc.).
+.env
+.env.local
+**/.env

 *.nupkg

diff --git a/AGENTS.md b/AGENTS.md
index 845e2dd1bc..f960c536f7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -214,7 +214,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. Changel
         && github.actor != 'dependabot[bot]')
   ```
   Both halves earn their place. The `github.event_name != 'pull_request'` half is **mandatory**: `publish.yml` calls `test.yml` on the RC path, `github.event.pull_request` is null there, and without it a bare comparison reads as "fork" and silently disables the step on every release run. Use `github.actor`, not `pull_request.user.login`, so a human pushing to a Dependabot branch re-enables the step along with the token. Carry both halves at every site, even where a job's own `if:` already restricts to `pull_request`: one shape across all of them is what makes the set greppable and testable.
-- **Do not guard a step just because it names a secret.** An absent secret is an empty string, not an error, and several paths degrade gracefully: `visual.yml`'s Argos upload works **tokenless** on public repos (verified on fork PR #13207, run `32481421878`, which produced Argos build 5874), and `docs/angular-type-check/check.mjs` turns a Checks-API 403 into a `::warning`. Guarding either would delete working coverage for external contributors, which the "functional continuity" rule above forbids. Confirm against a real fork or Dependabot run before adding a guard. Current guarded sites: `.github/actions/performance-run/action.yml` and `performance-tests.yml` (gh-pages push, sticky comment, report URL), `integration.yml`'s `preview-packages` (sticky comment), `code-quality.yml`'s `sonarcloud` and `fossa` (hard secret dependency), `docs.yml`'s `preview` (the `docs-staging.yml` call, whose first step is a sticky comment). `.github/scripts/__tests__/fork-guards.test.mjs` asserts this list against the workflows, so add new sites in both places. Prefer **step-level** guards where the job still does useful work; job-level only where the job is entirely secret-dependent. Withheld content goes to `$GITHUB_STEP_SUMMARY`, which needs no token. Never reach for `pull_request_target`: it would run fork-controlled code that builds and publishes preview packages with a write token.
+- **Do not guard a step just because it names a secret.** An absent secret is an empty string, not an error, and several paths degrade gracefully: `visual.yml` carries a second, **credential-free** comparison for fork and Dependabot runs, which reads the golden records from the public bucket over anonymous HTTPS so external contributors keep visual review even though the R2 write path needs secrets (guarding the comparison away outright removed that coverage once, reverted in #13222), and `docs/angular-type-check/check.mjs` turns a Checks-API 403 into a `::warning`. Guarding either would delete working coverage for external contributors, which the "functional continuity" rule above forbids. Confirm against a real fork or Dependabot run before adding a guard. Current guarded sites: `.github/actions/performance-run/action.yml` and `performance-tests.yml` (gh-pages push, sticky comment, report URL), `integration.yml`'s `preview-packages` (sticky comment), `code-quality.yml`'s `sonarcloud` and `fossa` (hard secret dependency), `docs.yml`'s `preview` (the `docs-staging.yml` call, whose first step is a sticky comment), `visual.yml`'s credentialed compare, seed and PR-comment steps, `pr-cleanup.yml`'s `purge-visual-screenshots` (R2 delete), and `visual-cleanup.yml`'s `reset-approval` (label removal). `.github/scripts/__tests__/fork-guards.test.mjs` asserts this list against the workflows, so add new sites in both places. Prefer **step-level** guards where the job still does useful work; job-level only where the job is entirely secret-dependent. Withheld content goes to `$GITHUB_STEP_SUMMARY`, which needs no token. Never reach for `pull_request_target`: it would run fork-controlled code that builds and publishes preview packages with a write token.
 - **A release cut must never regenerate `pnpm-lock.yaml`.** The lockfile records `specifier: workspace:^` for every in-repo dependency and never a package's own version, so a version bump cannot legitimately change it — and 15 specifiers are `latest`, which re-resolve to whatever the registry serves that day. Deleting it and reinstalling floats the build toolchain (core-js, browserslist, caniuse-lite) straight into the shipped bundle: that is DEV-2667, which floated 509 packages at the `18.1.0-rc1` cut and left every production-bundle leg red for six release candidates. Nothing downstream catches it — a floated lockfile is internally consistent, so `pnpm install --frozen-lockfile` installs it happily. `.github/scripts/lockfile-float-gate.mjs` is the only check, wired at **six** sites in `publish.yml`: after each of the three version bumps (`first-rc-build`, `rc-build`, `stable-prepare`) and before each of their three `git add .` commits. Pass it the branch the job builds from — `develop` for the first RC, the release branch for the other two — or the error sends the operator to fix the wrong branch. The two `pnpm install --lockfile-only` calls in `stable-merge` resolve a real merge conflict and are deliberately ungated. `.github/scripts/__tests__/release-lockfile.test.mjs` pins all of that — the gate count, the ungated-call count, the step ordering, and a ban on `pnpm install --force` and on deleting the lockfile anywhere under `.github/workflows/` or `.github/actions/` — so add new sites in both places.
 - The docs site (`docs/`) uses Node 22 (its own `.nvmrc`) and is not needed for core library development.
 - Walkontable (the rendering engine) lives inside `handsontable/src/3rdparty/walkontable/` and has its **own test runner** — do not mix Walkontable tests with main E2E tests.
diff --git a/handsontable/.ai/INTEGRATIONS.md b/handsontable/.ai/INTEGRATIONS.md
index 862d415fa6..fdbf420796 100644
--- a/handsontable/.ai/INTEGRATIONS.md
+++ b/handsontable/.ai/INTEGRATIONS.md
@@ -53,7 +53,7 @@ Handsontable is a frontend-only library with no built-in external API integratio
 - Key workflows: `test.yml`, `build-all.yml`, `code-quality.yml`, `publish.yml`

 **Visual Regression Testing:**
-- Argos CI (`@argos-ci/core` ^5.1.1) - Screenshot comparison service used in `visual-tests/`
+- reg-suit (`reg-suit` ^0.14.5) - Screenshot comparison used in `visual-tests/`; golden records and HTML diff reports are stored in Cloudflare R2
 - Playwright for screenshot capture

 **Documentation Search:**
diff --git a/package.json b/package.json
index ab3cc1ca5f..cb98f4d5ca 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
     "lint": "npm run eslint && npm run all lint -- --if-present && npm run lint --prefix performance-tests && npm run lint --prefix tests",
     "eslint": "eslint .github/scripts/ bin/changelog scripts/",
     "test": "npm run all test -- -e=examples",
-    "test:tooling": "node --test .github/scripts/__tests__/*.test.mjs scripts/__tests__/*.test.mjs scripts/claude/__tests__/*.test.mjs evals/__tests__/*.test.mjs",
+    "test:tooling": "node --test .github/scripts/__tests__/*.test.mjs scripts/__tests__/*.test.mjs scripts/claude/__tests__/*.test.mjs evals/__tests__/*.test.mjs performance-tests/lib/__tests__/*.test.mjs visual-tests/lib/__tests__/*.test.mjs",
     "build": "npm run all build -- -e=examples",
     "freeze": "node scripts/freeze.mjs",
     "release": "node scripts/release.mjs",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a1ec63e145..81883eeef5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -665,9 +665,6 @@ importers:

   visual-tests:
     devDependencies:
-      '@argos-ci/core':
-        specifier: ^5.1.1
-        version: 5.3.1
       '@playwright/test':
         specifier: ~1.60.0
         version: 1.60.0
@@ -689,6 +686,18 @@ importers:
       ps-tree:
         specifier: ^1.2.0
         version: 1.2.0
+      reg-cli:
+        specifier: ^0.18.5
+        version: 0.18.16
+      reg-publish-s3-plugin:
+        specifier: ^0.14.4
+        version: 0.14.4
+      reg-simple-keygen-plugin:
+        specifier: ^0.14.2
+        version: 0.14.2
+      reg-suit:
+        specifier: ^0.14.5
+        version: 0.14.5
       rimraf:
         specifier: ^4.1.2
         version: 4.4.1
@@ -1330,18 +1339,6 @@ packages:
       '@angular/platform-browser': 19.2.25
       rxjs: ^6.5.3 || ^7.4.0

-  '@argos-ci/api-client@0.19.0':
-    resolution: {integrity: sha512-6nxyWg0DBqBeRMIV/Efa881yHYPtFuzonkY5ckLwnOnAZqhk3pz6tVAn1WILJ/pL4eZvA98nQQt9WVofA+Mrfw==}
-    engines: {node: '>=20.0.0'}
-
-  '@argos-ci/core@5.3.1':
-    resolution: {integrity: sha512-lU8IOeb0pHJHUSS/pl7oVG7mTdNslOH7PiVbIW2w8004nhSYQciqhM98vWLrfmRLQfHJjpPIM3KCVg2qBKPKXQ==}
-    engines: {node: '>=20.0.0'}
-
-  '@argos-ci/util@3.4.0':
-    resolution: {integrity: sha512-rPa8xu6k0TkK1V4CIapcx2x/ncB7dvGa0adXCkflQf+rZZvZaVI3jCVXR57bCZn+S5AWndj4+A/7pX5xV9krvQ==}
-    engines: {node: '>=20.0.0'}
-
   '@asamuzakjp/css-color@3.2.0':
     resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}

@@ -1457,6 +1454,78 @@ packages:
     resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==}
     engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}

+  '@aws-sdk/checksums@3.1000.29':
+    resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/client-s3@3.1123.0':
+    resolution: {integrity: sha512-InD/KXgF4clIFtqo8yzQQG755/BBoLstsC5WITLrMckKUUaACQgbpbZcdsySAXU3Uukp/3RV0Aiv5H3eDNGXcw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/core@3.977.9':
+    resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-env@3.972.70':
+    resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-http@3.972.72':
+    resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-ini@3.973.15':
+    resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-login@3.972.77':
+    resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-node@3.972.82':
+    resolution: {integrity: sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-process@3.972.70':
+    resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-sso@3.973.14':
+    resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/credential-provider-web-identity@3.972.76':
+    resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/middleware-sdk-s3@3.972.75':
+    resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/nested-clients@3.997.44':
+    resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/signature-v4-multi-region@3.996.46':
+    resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/token-providers@3.1116.0':
+    resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/types@3.974.5':
+    resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws-sdk/xml-builder@3.972.40':
+    resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
+    engines: {node: '>=20.0.0'}
+
+  '@aws/lambda-invoke-store@0.3.0':
+    resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
+    engines: {node: '>=18.0.0'}
+
   '@babel/cli@7.29.7':
     resolution: {integrity: sha512-/75HwRbAYPqXv/Ax1h7Fg3IZfXgdU98jnA8H93/m/QBaPV3Hp5ICoLqzGYye1yHBCgpmXvtqgSUN8oOKX5tojQ==}
     engines: {node: '>=6.9.0'}
@@ -4050,6 +4119,22 @@ packages:
   '@octokit/types@6.41.0':
     resolution: {integrity: sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==}

+  '@oozcitak/dom@2.0.2':
+    resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==}
+    engines: {node: '>=20.0'}
+
+  '@oozcitak/infra@2.0.2':
+    resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==}
+    engines: {node: '>=20.0'}
+
+  '@oozcitak/url@3.0.0':
+    resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==}
+    engines: {node: '>=20.0'}
+
+  '@oozcitak/util@10.0.0':
+    resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==}
+    engines: {node: '>=20.0'}
+
   '@oslojs/encoding@1.1.0':
     resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}

@@ -5542,6 +5627,30 @@ packages:
   '@sinonjs/fake-timers@8.1.0':
     resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==}

+  '@smithy/core@3.33.3':
+    resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/credential-provider-imds@4.5.2':
+    resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/fetch-http-handler@5.7.2':
+    resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/node-http-handler@4.12.0':
+    resolution: {integrity: sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/signature-v4@5.7.3':
+    resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
+    engines: {node: '>=18.0.0'}
+
+  '@smithy/types@4.17.2':
+    resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
+    engines: {node: '>=18.0.0'}
+
   '@stryker-mutator/api@9.6.1':
     resolution: {integrity: sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==}
     engines: {node: '>=20.0.0'}
@@ -5744,6 +5853,12 @@ packages:
   '@types/bonjour@3.5.13':
     resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==}

+  '@types/cli-progress@3.11.6':
+    resolution: {integrity: sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==}
+
+  '@types/cli-spinner@0.2.3':
+    resolution: {integrity: sha512-TMO6mWltW0lCu1de8DMRq9+59OP/tEjghS+rs8ZEQ2EgYP5yV3bGw0tS14TMyJGqFaoVChNvhkVzv9RC1UgX+w==}
+
   '@types/connect-history-api-fallback@1.5.4':
     resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==}

@@ -5774,6 +5889,9 @@ packages:
   '@types/express@4.17.25':
     resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}

+  '@types/glob@8.1.0':
+    resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==}
+
   '@types/graceful-fs@4.1.9':
     resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}

@@ -5821,6 +5939,9 @@ packages:
   '@types/linkify-it@5.0.0':
     resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}

+  '@types/lodash@4.17.25':
+    resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
+
   '@types/markdown-it@14.1.2':
     resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}

@@ -5833,9 +5954,21 @@ packages:
   '@types/mdx@2.0.14':
     resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==}

+  '@types/mime-types@2.1.4':
+    resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==}
+
   '@types/mime@1.3.5':
     resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}

+  '@types/minimatch@5.1.2':
+    resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
+
+  '@types/minimist@1.2.5':
+    resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==}
+
+  '@types/mkdirp@1.0.2':
+    resolution: {integrity: sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==}
+
   '@types/ms@2.1.0':
     resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}

@@ -5860,6 +5993,9 @@ packages:
   '@types/node@25.9.2':
     resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}

+  '@types/normalize-package-data@2.4.4':
+    resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
+
   '@types/pako@2.0.4':
     resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}

@@ -6597,6 +6733,10 @@ packages:
     resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
     engines: {node: '>= 0.4'}

+  arrify@1.0.1:
+    resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==}
+    engines: {node: '>=0.10.0'}
+
   asn1@0.2.6:
     resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}

@@ -6899,6 +7039,9 @@ packages:
   boolbase@1.0.0:
     resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}

+  bowser@2.14.1:
+    resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
+
   brace-expansion@1.1.15:
     resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}

@@ -7005,6 +7148,10 @@ packages:
     resolution: {integrity: sha512-Ol6L8HQ0n1qFAOmuXcXcOXVpcQ+x5/N8ff+i6G76a4sDjRVtCWmpsG6RorloS1vVG9O0IGq9l7sqaxBnwG/LUA==}
     deprecated: no longer maintained

+  camelcase-keys@6.2.2:
+    resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==}
+    engines: {node: '>=8'}
+
   camelcase@1.2.1:
     resolution: {integrity: sha512-wzLkDa4K/mzI1OSITC+DUyjgIl/ETNHE9QvYgy6J6Jvqyyz4C0Xfd+lQhb19sX2jMpZV4IssUn0VDVmglV+s4g==}
     engines: {node: '>=0.10.0'}
@@ -7144,6 +7291,14 @@ packages:
     resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
     engines: {node: '>=18'}

+  cli-progress@3.12.0:
+    resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==}
+    engines: {node: '>=4'}
+
+  cli-spinner@0.2.10:
+    resolution: {integrity: sha512-U0sSQ+JJvSLi1pAYuJykwiA8Dsr15uHEy85iCJ6A+0DjVxivr3d+N2Wjvodeg89uP5K6TswFkKBfAD7B3YSn/Q==}
+    engines: {node: '>=0.10'}
+
   cli-spinners@2.9.2:
     resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
     engines: {node: '>=6'}
@@ -7342,10 +7497,6 @@ packages:
   convert-source-map@2.0.0:
     resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}

-  convict@6.2.5:
-    resolution: {integrity: sha512-JtXpxqDqJ8P0UwEHwhxLzCIXQy97vlYBZR222Sbzb1q1Erex9ASrztJ29SyhWFQjod1AeFBaPzEEC8YvtZMIYg==}
-    engines: {node: '>=6'}
-
   cookie-es@1.2.3:
     resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}

@@ -7405,6 +7556,10 @@ packages:
       typescript:
         optional: true

+  cp-file@9.1.0:
+    resolution: {integrity: sha512-3scnzFj/94eb7y4wyXRWwvzLFaQp87yyfTnChIjlfYrVqp5lVO3E2hIJMeQIltUT0K2ZAB3An1qXcBmwGyvuwA==}
+    engines: {node: '>=10'}
+
   crc-32@1.2.2:
     resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
     engines: {node: '>=0.8'}
@@ -7586,12 +7741,23 @@ packages:
       supports-color:
         optional: true

+  decamelize-keys@1.1.1:
+    resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==}
+    engines: {node: '>=0.10.0'}
+
+  decamelize@1.2.0:
+    resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+    engines: {node: '>=0.10.0'}
+
   decimal.js@10.6.0:
     resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}

   decode-named-character-reference@1.3.0:
     resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}

+  decode-tiff@0.2.1:
+    resolution: {integrity: sha512-v/7hQBv/DrOVQ+Eljg0BLMRbXZYuuw3YZ8duZuFxYpo6qUkdn7oFRkN95RZKbnh08EHNjrMXMbEUNhTLuhPvvA==}
+
   decode-uri-component@0.2.2:
     resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
     engines: {node: '>=0.10'}
@@ -7656,6 +7822,10 @@ packages:
     resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
     engines: {node: '>= 14'}

+  del@8.0.1:
+    resolution: {integrity: sha512-gPqh0mKTPvaUZGAuHbrBUYKZWBNAeHG7TU3QH5EhVwPMyKvmfJaNXhcD2jTcXsJRRcffuho4vaYweu80dRrMGA==}
+    engines: {node: '>=18'}
+
   delayed-stream@1.0.0:
     resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
     engines: {node: '>=0.4.0'}
@@ -8696,6 +8866,10 @@ packages:
     engines: {node: '>=6'}
     deprecated: this library is no longer supported

+  hard-rejection@2.1.0:
+    resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==}
+    engines: {node: '>=6'}
+
   has-ansi@0.1.0:
     resolution: {integrity: sha512-1YsTg1fk2/6JToQhtZkArMkurq8UoWU1Qe0aR3VUHjgij4nOylSWLWAtBXoZ4/dXOmugfLGm1c+QhuD0JyedFA==}
     engines: {node: '>=0.10.0'}
@@ -8816,6 +8990,13 @@ packages:
   hookified@2.2.0:
     resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==}

+  hosted-git-info@2.8.9:
+    resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
+
+  hosted-git-info@4.1.0:
+    resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
+    engines: {node: '>=10'}
+
   hosted-git-info@8.1.0:
     resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==}
     engines: {node: ^18.17.0 || >=20.5.0}
@@ -8986,6 +9167,10 @@ packages:
     resolution: {integrity: sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==}
     engines: {node: ^18.17.0 || >=20.5.0}

+  ignore@5.3.1:
+    resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
+    engines: {node: '>= 4'}
+
   ignore@5.3.2:
     resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
     engines: {node: '>= 4'}
@@ -8999,6 +9184,9 @@ packages:
     engines: {node: '>=0.10.0'}
     hasBin: true

+  img-diff-js@0.5.4:
+    resolution: {integrity: sha512-FTGQ7i/1W/3nmhiYxoN81FBcxESVSgv/4/e5LnfRzd/w5hBcD1LPxmYu2N8euzVsLrsgztOQcSTDQZKaf0+LOA==}
+
   immediate@3.0.6:
     resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}

@@ -9025,6 +9213,10 @@ packages:
     resolution: {integrity: sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==}
     engines: {node: '>=0.10.0'}

+  indent-string@4.0.0:
+    resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+    engines: {node: '>=8'}
+
   inflight@1.0.6:
     resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
     deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
@@ -9049,6 +9241,10 @@ packages:
     resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==}
     engines: {node: '>=8.0.0'}

+  inquirer@8.2.6:
+    resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==}
+    engines: {node: '>=12.0.0'}
+
   inquirer@8.2.7:
     resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==}
     engines: {node: '>=12.0.0'}
@@ -9228,10 +9424,22 @@ packages:
   is-object@1.0.2:
     resolution: {integrity: sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==}

+  is-path-cwd@3.0.0:
+    resolution: {integrity: sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
   is-path-inside@3.0.3:
     resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
     engines: {node: '>=8'}

+  is-path-inside@4.0.0:
+    resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==}
+    engines: {node: '>=12'}
+
+  is-plain-obj@1.1.0:
+    resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==}
+    engines: {node: '>=0.10.0'}
+
   is-plain-obj@3.0.0:
     resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==}
     engines: {node: '>=10'}
@@ -9694,6 +9902,9 @@ packages:
     resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
     hasBin: true

+  jpeg-js@0.4.4:
+    resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
+
   js-md4@0.3.2:
     resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==}

@@ -10196,6 +10407,10 @@ packages:
   lru-cache@5.1.1:
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}

+  lru-cache@6.0.0:
+    resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
+    engines: {node: '>=10'}
+
   lru-cache@7.18.3:
     resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
     engines: {node: '>=12'}
@@ -10235,6 +10450,14 @@ packages:
   makeerror@1.0.12:
     resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}

+  map-obj@1.0.1:
+    resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==}
+    engines: {node: '>=0.10.0'}
+
+  map-obj@4.3.0:
+    resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
+    engines: {node: '>=8'}
+
   map-stream@0.1.0:
     resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}

@@ -10275,6 +10498,11 @@ packages:
   maybe-callback@2.1.0:
     resolution: {integrity: sha512-P8CekEs8v3zn0bM/tXgfL0UkBXO//BAQkoAmn8s+eFmH451+7wBWY2aKEmyItoZUcbpaI2OCcM6tcuIil/n5mA==}

+  md5-file@4.0.0:
+    resolution: {integrity: sha512-UC0qFwyAjn4YdPpKaDNw6gNxRf7Mcx7jC1UGCY4boCzgvU2Aoc1mOGzTtrjjLKhM5ivsnhoKpQVxKPp+1j1qwg==}
+    engines: {node: '>=6.0'}
+    hasBin: true
+
   mdast-util-definitions@6.0.0:
     resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==}

@@ -10354,6 +10582,10 @@ packages:
     resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==}
     engines: {node: '>=18'}

+  meow@9.0.0:
+    resolution: {integrity: sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==}
+    engines: {node: '>=10'}
+
   merge-descriptors@1.0.3:
     resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}

@@ -10492,10 +10724,6 @@ packages:
     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
     engines: {node: '>= 0.6'}

-  mime-types@3.0.2:
-    resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
-    engines: {node: '>=18'}
-
   mime@1.6.0:
     resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
     engines: {node: '>=4'}
@@ -10513,6 +10741,10 @@ packages:
     resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
     engines: {node: '>=18'}

+  min-indent@1.0.1:
+    resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+    engines: {node: '>=4'}
+
   mini-css-extract-plugin@2.9.2:
     resolution: {integrity: sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==}
     engines: {node: '>= 12.13.0'}
@@ -10541,6 +10773,10 @@ packages:
     resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
     engines: {node: '>=16 || 14 >=14.17'}

+  minimist-options@4.1.0:
+    resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==}
+    engines: {node: '>= 6'}
+
   minimist@1.2.8:
     resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}

@@ -10619,6 +10855,10 @@ packages:
     resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==}
     hasBin: true

+  mustache@4.2.0:
+    resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
+    hasBin: true
+
   mutation-server-protocol@0.4.1:
     resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==}
     engines: {node: '>=18'}
@@ -10685,6 +10925,9 @@ packages:
     resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
     engines: {node: '>= 10'}

+  nested-error-stacks@2.1.1:
+    resolution: {integrity: sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==}
+
   netmask@2.1.1:
     resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==}
     engines: {node: '>= 0.4.0'}
@@ -10770,6 +11013,13 @@ packages:
     engines: {node: '>= 0.10'}
     deprecated: try optionator

+  normalize-package-data@2.5.0:
+    resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
+
+  normalize-package-data@3.0.3:
+    resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==}
+    engines: {node: '>=10'}
+
   normalize-path@3.0.0:
     resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
     engines: {node: '>=0.10.0'}
@@ -10922,12 +11172,6 @@ packages:
     resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==}
     engines: {node: '>=18'}

-  openapi-fetch@0.17.0:
-    resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==}
-
-  openapi-typescript-helpers@0.1.0:
-    resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==}
-
   opener@1.5.2:
     resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
     hasBin: true
@@ -10965,6 +11209,14 @@ packages:
     resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
     engines: {node: '>= 0.4'}

+  p-event@4.2.0:
+    resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
+    engines: {node: '>=8'}
+
+  p-finally@1.0.0:
+    resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
+    engines: {node: '>=4'}
+
   p-limit@2.3.0:
     resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
     engines: {node: '>=6'}
@@ -11009,6 +11261,10 @@ packages:
     resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==}
     engines: {node: '>=16.17'}

+  p-timeout@3.2.0:
+    resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
+    engines: {node: '>=8'}
+
   p-timeout@7.0.1:
     resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==}
     engines: {node: '>=20'}
@@ -11199,6 +11455,10 @@ packages:
   piscina@4.9.2:
     resolution: {integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==}

+  pixelmatch@5.3.0:
+    resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==}
+    hasBin: true
+
   pkg-dir@3.0.0:
     resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==}
     engines: {node: '>=6'}
@@ -11244,6 +11504,14 @@ packages:
   pluralize@1.2.1:
     resolution: {integrity: sha512-TH+BeeL6Ct98C7as35JbZLf8lgsRzlNJb5gklRIGHKaPkGl1esOKBc5ALUMd+q08Sr6tiEKM+Icbsxg5vuhMKQ==}

+  pngjs@6.0.0:
+    resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
+    engines: {node: '>=12.13.0'}
+
+  pngjs@7.0.0:
+    resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
+    engines: {node: '>=14.19.0'}
+
   portfinder@1.0.38:
     resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==}
     engines: {node: '>= 10.12'}
@@ -11343,6 +11611,10 @@ packages:
     resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
     engines: {node: '>= 0.8.0'}

+  presentable-error@0.0.1:
+    resolution: {integrity: sha512-E6rsNU1QNJgB3sjj7OANinGncFKuK+164sLXw1/CqBjj/EkXSoSdHCtWQGBNlREIGLnL7IEUEGa08YFVUbrhVg==}
+    engines: {node: '>=16'}
+
   pretty-format@27.5.1:
     resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
     engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -11458,6 +11730,10 @@ packages:
   queue-microtask@1.2.3:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}

+  quick-lru@4.0.1:
+    resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
+    engines: {node: '>=8'}
+
   radix-ui@1.6.2:
     resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==}
     peerDependencies:
@@ -11569,6 +11845,14 @@ packages:
     resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
     engines: {node: '>=0.10.0'}

+  read-pkg-up@7.0.1:
+    resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==}
+    engines: {node: '>=8'}
+
+  read-pkg@5.2.0:
+    resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
+    engines: {node: '>=8'}
+
   readable-stream@1.0.34:
     resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==}

@@ -11608,6 +11892,10 @@ packages:
   recma-stringify@1.0.0:
     resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}

+  redent@3.0.0:
+    resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+    engines: {node: '>=8'}
+
   reduce-flatten@1.0.1:
     resolution: {integrity: sha512-j5WfFJfc9CoXv/WbwVLHq74i/hdTUpy+iNC534LxczMRP67vJeK3V9JOdnL0N1cIRbn9mYhE2yVjvvKXDxvNXQ==}
     engines: {node: '>=0.10.0'}
@@ -11637,6 +11925,27 @@ packages:
     resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
     engines: {node: '>= 0.4'}

+  reg-cli@0.18.16:
+    resolution: {integrity: sha512-j6XfP1YENP3rnAYTBgbn+NkDFOUs2qLlrFh8jN1ISrq+/3fudboeQv+n5UHKLIpDLiq3UUlzDs0g497l0QaRSA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  reg-publish-s3-plugin@0.14.4:
+    resolution: {integrity: sha512-2M//em2oVQ2LkawFfKTWCnowyk+5cMY3X/go1Dk5ZEZQjOyt1E1KOxFaITOyOVLCFwF7YfmYHlp/wWeZbfPh/w==}
+
+  reg-simple-keygen-plugin@0.14.2:
+    resolution: {integrity: sha512-49vn+OFw1Bw5GY0tBuOSG5TL9hSSIf4DT69ILaMETA6HxEYrIQ2bcQ5dQ9NFrEzZTBNDrFLXig+LWM/pwOYcrg==}
+
+  reg-suit-core@0.14.5:
+    resolution: {integrity: sha512-7wIA+bcggLfcPqkMyWfKKYYfQClIQ2gKgyD/LujinyKUZesOyZ3sYd3UPlWB0SBxViNgDtQ5C50EkyCKUW2e3g==}
+
+  reg-suit-util@0.14.3:
+    resolution: {integrity: sha512-1Ddn2Nm2Ha7z07ubZeWuINjXRXr6uQWAdUpfbwt7cZyo8wlX2QBtjeet8WM0KVvrz9Ykj8suvUhhd3HLLyOYCQ==}
+
+  reg-suit@0.14.5:
+    resolution: {integrity: sha512-AWenzVuJ/MBUt8Ejaaom7SDN8PF71seIjIBBnYcdPcrs5CHEuCAERCCkv6EgEuU85vXS5gc0XzuJVMbj3ay0hQ==}
+    hasBin: true
+
   regenerate-unicode-properties@10.2.2:
     resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==}
     engines: {node: '>=4'}
@@ -11861,6 +12170,10 @@ packages:
     engines: {node: '>=14'}
     hasBin: true

+  rimraf@5.0.10:
+    resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
+    hasBin: true
+
   robot-directives@0.3.0:
     resolution: {integrity: sha512-mROGTXczU5H5jpfwIpy4TUcmoZH033UXvkI2QZfbfH+qugWr7XzmXmMtKcqqrTn1d4wabmwfVG9xaVQQ66ti8Q==}
     engines: {node: '>= 0.10'}
@@ -12493,6 +12806,10 @@ packages:
     resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
     engines: {node: '>=18'}

+  strip-indent@3.0.0:
+    resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+    engines: {node: '>=8'}
+
   strip-json-comments@2.0.1:
     resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
     engines: {node: '>=0.10.0'}
@@ -12831,6 +13148,10 @@ packages:
   trim-lines@3.0.1:
     resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}

+  trim-newlines@3.0.1:
+    resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
+    engines: {node: '>=8'}
+
   trough@2.2.0:
     resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}

@@ -12947,6 +13268,10 @@ packages:
     resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
     engines: {node: '>=4'}

+  type-fest@0.18.1:
+    resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==}
+    engines: {node: '>=10'}
+
   type-fest@0.20.2:
     resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
     engines: {node: '>=10'}
@@ -12955,6 +13280,14 @@ packages:
     resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
     engines: {node: '>=10'}

+  type-fest@0.6.0:
+    resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==}
+    engines: {node: '>=8'}
+
+  type-fest@0.8.1:
+    resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==}
+    engines: {node: '>=8'}
+
   type-fest@4.41.0:
     resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
     engines: {node: '>=16'}
@@ -13308,6 +13641,11 @@ packages:
     deprecated: uuid@10 and below is no longer supported.  For ESM codebases, update to uuid@latest.  For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
     hasBin: true

+  uuid@9.0.1:
+    resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+    deprecated: uuid@10 and below is no longer supported.  For ESM codebases, update to uuid@latest.  For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
+    hasBin: true
+
   v8-to-istanbul@8.1.1:
     resolution: {integrity: sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==}
     engines: {node: '>=10.12.0'}
@@ -13846,6 +14184,9 @@ packages:
       utf-8-validate:
         optional: true

+  x-img-diff-js@0.3.5:
+    resolution: {integrity: sha512-B97ztoc2JeM+62HH1zFhmTyilsVqL486WMm8X3oQz16lTCGITY1cz+H57mTsNG0QuyVxv1yGq06qC8wy6UPCmQ==}
+
   xlsx@0.18.5:
     resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
     engines: {node: '>=0.8'}
@@ -13862,6 +14203,10 @@ packages:
     resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
     engines: {node: '>=18'}

+  xmlbuilder2@4.0.3:
+    resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==}
+    engines: {node: '>=20.0'}
+
   xmlchars@2.2.0:
     resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}

@@ -13911,6 +14256,10 @@ packages:
     resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
     engines: {node: '>=10'}

+  yargs@17.0.1:
+    resolution: {integrity: sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ==}
+    engines: {node: '>=12'}
+
   yargs@17.7.2:
     resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
     engines: {node: '>=12'}
@@ -14422,28 +14771,6 @@ snapshots:
       rxjs: 7.8.2
       tslib: 2.8.1

-  '@argos-ci/api-client@0.19.0':
-    dependencies:
-      debug: 4.4.3
-      openapi-fetch: 0.17.0
-    transitivePeerDependencies:
-      - supports-color
-
-  '@argos-ci/core@5.3.1':
-    dependencies:
-      '@argos-ci/api-client': 0.19.0
-      '@argos-ci/util': 3.4.0
-      convict: 6.2.5
-      debug: 4.4.3
-      fast-glob: 3.3.3
-      mime-types: 3.0.2
-      sharp: 0.34.5
-      tmp: 0.2.7
-    transitivePeerDependencies:
-      - supports-color
-
-  '@argos-ci/util@3.4.0': {}
-
   '@asamuzakjp/css-color@3.2.0':
     dependencies:
       '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -14647,6 +14974,171 @@ snapshots:
       is-wsl: 3.1.1
       which-pm-runs: 1.1.0

+  '@aws-sdk/checksums@3.1000.29':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/client-s3@3.1123.0':
+    dependencies:
+      '@aws-sdk/checksums': 3.1000.29
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/credential-provider-node': 3.972.82
+      '@aws-sdk/middleware-sdk-s3': 3.972.75
+      '@aws-sdk/signature-v4-multi-region': 3.996.46
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/fetch-http-handler': 5.7.2
+      '@smithy/node-http-handler': 4.12.0
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/core@3.977.9':
+    dependencies:
+      '@aws-sdk/types': 3.974.5
+      '@aws-sdk/xml-builder': 3.972.40
+      '@aws/lambda-invoke-store': 0.3.0
+      '@smithy/core': 3.33.3
+      '@smithy/signature-v4': 5.7.3
+      '@smithy/types': 4.17.2
+      bowser: 2.14.1
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-env@3.972.70':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-http@3.972.72':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/fetch-http-handler': 5.7.2
+      '@smithy/node-http-handler': 4.12.0
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-ini@3.973.15':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/credential-provider-env': 3.972.70
+      '@aws-sdk/credential-provider-http': 3.972.72
+      '@aws-sdk/credential-provider-login': 3.972.77
+      '@aws-sdk/credential-provider-process': 3.972.70
+      '@aws-sdk/credential-provider-sso': 3.973.14
+      '@aws-sdk/credential-provider-web-identity': 3.972.76
+      '@aws-sdk/nested-clients': 3.997.44
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/credential-provider-imds': 4.5.2
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-login@3.972.77':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/nested-clients': 3.997.44
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-node@3.972.82':
+    dependencies:
+      '@aws-sdk/credential-provider-env': 3.972.70
+      '@aws-sdk/credential-provider-http': 3.972.72
+      '@aws-sdk/credential-provider-ini': 3.973.15
+      '@aws-sdk/credential-provider-process': 3.972.70
+      '@aws-sdk/credential-provider-sso': 3.973.14
+      '@aws-sdk/credential-provider-web-identity': 3.972.76
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/credential-provider-imds': 4.5.2
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-process@3.972.70':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-sso@3.973.14':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/nested-clients': 3.997.44
+      '@aws-sdk/token-providers': 3.1116.0
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/credential-provider-web-identity@3.972.76':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/nested-clients': 3.997.44
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/middleware-sdk-s3@3.972.75':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/signature-v4-multi-region': 3.996.46
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/nested-clients@3.997.44':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/signature-v4-multi-region': 3.996.46
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/fetch-http-handler': 5.7.2
+      '@smithy/node-http-handler': 4.12.0
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/signature-v4-multi-region@3.996.46':
+    dependencies:
+      '@aws-sdk/types': 3.974.5
+      '@smithy/signature-v4': 5.7.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/token-providers@3.1116.0':
+    dependencies:
+      '@aws-sdk/core': 3.977.9
+      '@aws-sdk/nested-clients': 3.997.44
+      '@aws-sdk/types': 3.974.5
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/types@3.974.5':
+    dependencies:
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws-sdk/xml-builder@3.972.40':
+    dependencies:
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@aws/lambda-invoke-store@0.3.0': {}
+
   '@babel/cli@7.29.7(@babel/core@7.29.7)':
     dependencies:
       '@babel/core': 7.29.7
@@ -16185,7 +16677,8 @@ snapshots:

   '@humanwhocodes/object-schema@2.0.3': {}

-  '@img/colour@1.1.0': {}
+  '@img/colour@1.1.0':
+    optional: true

   '@img/sharp-darwin-arm64@0.34.5':
     optionalDependencies:
@@ -17524,6 +18017,23 @@ snapshots:
     dependencies:
       '@octokit/openapi-types': 12.11.0

+  '@oozcitak/dom@2.0.2':
+    dependencies:
+      '@oozcitak/infra': 2.0.2
+      '@oozcitak/url': 3.0.0
+      '@oozcitak/util': 10.0.0
+
+  '@oozcitak/infra@2.0.2':
+    dependencies:
+      '@oozcitak/util': 10.0.0
+
+  '@oozcitak/url@3.0.0':
+    dependencies:
+      '@oozcitak/infra': 2.0.2
+      '@oozcitak/util': 10.0.0
+
+  '@oozcitak/util@10.0.0': {}
+
   '@oslojs/encoding@1.1.0': {}

   '@oxc-project/types@0.138.0': {}
@@ -18900,6 +19410,39 @@ snapshots:
     dependencies:
       '@sinonjs/commons': 1.8.6

+  '@smithy/core@3.33.3':
+    dependencies:
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@smithy/credential-provider-imds@4.5.2':
+    dependencies:
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@smithy/fetch-http-handler@5.7.2':
+    dependencies:
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@smithy/node-http-handler@4.12.0':
+    dependencies:
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@smithy/signature-v4@5.7.3':
+    dependencies:
+      '@smithy/core': 3.33.3
+      '@smithy/types': 4.17.2
+      tslib: 2.8.1
+
+  '@smithy/types@4.17.2':
+    dependencies:
+      tslib: 2.8.1
+
   '@stryker-mutator/api@9.6.1':
     dependencies:
       mutation-testing-metrics: 3.7.3
@@ -19139,6 +19682,14 @@ snapshots:
     dependencies:
       '@types/node': 20.19.42

+  '@types/cli-progress@3.11.6':
+    dependencies:
+      '@types/node': 20.19.42
+
+  '@types/cli-spinner@0.2.3':
+    dependencies:
+      '@types/node': 20.19.42
+
   '@types/connect-history-api-fallback@1.5.4':
     dependencies:
       '@types/express-serve-static-core': 4.19.8
@@ -19184,6 +19735,11 @@ snapshots:
       '@types/qs': 6.15.1
       '@types/serve-static': 1.15.10

+  '@types/glob@8.1.0':
+    dependencies:
+      '@types/minimatch': 5.1.2
+      '@types/node': 20.19.42
+
   '@types/graceful-fs@4.1.9':
     dependencies:
       '@types/node': 22.19.20
@@ -19237,6 +19793,8 @@ snapshots:

   '@types/linkify-it@5.0.0': {}

+  '@types/lodash@4.17.25': {}
+
   '@types/markdown-it@14.1.2':
     dependencies:
       '@types/linkify-it': 5.0.0
@@ -19250,8 +19808,18 @@ snapshots:

   '@types/mdx@2.0.14': {}

+  '@types/mime-types@2.1.4': {}
+
   '@types/mime@1.3.5': {}

+  '@types/minimatch@5.1.2': {}
+
+  '@types/minimist@1.2.5': {}
+
+  '@types/mkdirp@1.0.2':
+    dependencies:
+      '@types/node': 20.19.42
+
   '@types/ms@2.1.0': {}

   '@types/nlcst@2.0.3':
@@ -19280,6 +19848,8 @@ snapshots:
     dependencies:
       undici-types: 7.24.6

+  '@types/normalize-package-data@2.4.4': {}
+
   '@types/pako@2.0.4': {}

   '@types/papaparse@5.5.2':
@@ -20321,6 +20891,8 @@ snapshots:
       get-intrinsic: 1.3.0
       is-array-buffer: 3.0.5

+  arrify@1.0.1: {}
+
   asn1@0.2.6:
     dependencies:
       safer-buffer: 2.1.2
@@ -20753,6 +21325,8 @@ snapshots:

   boolbase@1.0.0: {}

+  bowser@2.14.1: {}
+
   brace-expansion@1.1.15:
     dependencies:
       balanced-match: 1.0.2
@@ -20894,6 +21468,12 @@ snapshots:

   calmcard@0.1.1: {}

+  camelcase-keys@6.2.2:
+    dependencies:
+      camelcase: 5.3.1
+      map-obj: 4.3.0
+      quick-lru: 4.0.1
+
   camelcase@1.2.1: {}

   camelcase@5.3.1: {}
@@ -21034,6 +21614,12 @@ snapshots:
     dependencies:
       restore-cursor: 5.1.0

+  cli-progress@3.12.0:
+    dependencies:
+      string-width: 4.2.3
+
+  cli-spinner@0.2.10: {}
+
   cli-spinners@2.9.2: {}

   cli-table@0.3.11:
@@ -21225,11 +21811,6 @@ snapshots:

   convert-source-map@2.0.0: {}

-  convict@6.2.5:
-    dependencies:
-      lodash.clonedeep: 4.5.0
-      yargs-parser: 20.2.9
-
   cookie-es@1.2.3: {}

   cookie-signature@1.0.7: {}
@@ -21290,6 +21871,13 @@ snapshots:
     optionalDependencies:
       typescript: 6.0.3

+  cp-file@9.1.0:
+    dependencies:
+      graceful-fs: 4.2.11
+      make-dir: 3.1.0
+      nested-error-stacks: 2.1.1
+      p-event: 4.2.0
+
   crc-32@1.2.2: {}

   crc32-stream@4.0.3:
@@ -21484,12 +22072,21 @@ snapshots:
     dependencies:
       ms: 2.1.3

+  decamelize-keys@1.1.1:
+    dependencies:
+      decamelize: 1.2.0
+      map-obj: 1.0.1
+
+  decamelize@1.2.0: {}
+
   decimal.js@10.6.0: {}

   decode-named-character-reference@1.3.0:
     dependencies:
       character-entities: 2.0.2

+  decode-tiff@0.2.1: {}
+
   decode-uri-component@0.2.2: {}

   dedent@0.7.0: {}
@@ -21560,6 +22157,16 @@ snapshots:
       escodegen: 2.1.0
       esprima: 4.0.1

+  del@8.0.1:
+    dependencies:
+      globby: 14.1.0
+      is-glob: 4.0.3
+      is-path-cwd: 3.0.0
+      is-path-inside: 4.0.0
+      p-map: 7.0.4
+      presentable-error: 0.0.1
+      slash: 5.1.0
+
   delayed-stream@1.0.0: {}

   depd@1.1.2: {}
@@ -22940,6 +23547,8 @@ snapshots:
       ajv: 6.15.0
       har-schema: 2.0.0

+  hard-rejection@2.1.0: {}
+
   has-ansi@0.1.0:
     dependencies:
       ansi-regex: 0.2.1
@@ -23177,6 +23786,12 @@ snapshots:

   hookified@2.2.0: {}

+  hosted-git-info@2.8.9: {}
+
+  hosted-git-info@4.1.0:
+    dependencies:
+      lru-cache: 6.0.0
+
   hosted-git-info@8.1.0:
     dependencies:
       lru-cache: 10.4.3
@@ -23391,6 +24006,8 @@ snapshots:
     dependencies:
       minimatch: 9.0.9

+  ignore@5.3.1: {}
+
   ignore@5.3.2: {}

   ignore@7.0.5: {}
@@ -23398,6 +24015,14 @@ snapshots:
   image-size@0.5.5:
     optional: true

+  img-diff-js@0.5.4:
+    dependencies:
+      decode-tiff: 0.2.1
+      jpeg-js: 0.4.4
+      mkdirp: 1.0.4
+      pixelmatch: 5.3.0
+      pngjs: 7.0.0
+
   immediate@3.0.6: {}

   immer@10.2.0: {}
@@ -23420,6 +24045,8 @@ snapshots:
     dependencies:
       repeating: 2.0.1

+  indent-string@4.0.0: {}
+
   inflight@1.0.6:
     dependencies:
       once: 1.4.0
@@ -23453,6 +24080,24 @@ snapshots:
       strip-ansi: 6.0.1
       through: 2.3.8

+  inquirer@8.2.6:
+    dependencies:
+      ansi-escapes: 4.3.2
+      chalk: 4.1.2
+      cli-cursor: 3.1.0
+      cli-width: 3.0.0
+      external-editor: 3.1.0
+      figures: 3.2.0
+      lodash: 4.18.1
+      mute-stream: 0.0.8
+      ora: 5.4.1
+      run-async: 2.4.1
+      rxjs: 7.8.2
+      string-width: 4.2.3
+      strip-ansi: 6.0.1
+      through: 2.3.8
+      wrap-ansi: 6.2.0
+
   inquirer@8.2.7(@types/node@20.19.42):
     dependencies:
       '@inquirer/external-editor': 1.0.3(@types/node@20.19.42)
@@ -23620,8 +24265,14 @@ snapshots:

   is-object@1.0.2: {}

+  is-path-cwd@3.0.0: {}
+
   is-path-inside@3.0.3: {}

+  is-path-inside@4.0.0: {}
+
+  is-plain-obj@1.1.0: {}
+
   is-plain-obj@3.0.0: {}

   is-plain-obj@4.1.0: {}
@@ -24635,6 +25286,8 @@ snapshots:

   jiti@1.21.7: {}

+  jpeg-js@0.4.4: {}
+
   js-md4@0.3.2: {}

   js-tokens@4.0.0: {}
@@ -25248,6 +25901,10 @@ snapshots:
     dependencies:
       yallist: 3.1.1

+  lru-cache@6.0.0:
+    dependencies:
+      yallist: 4.0.0
+
   lru-cache@7.18.3: {}

   lz-string@1.5.0: {}
@@ -25301,6 +25958,10 @@ snapshots:
     dependencies:
       tmpl: 1.0.5

+  map-obj@1.0.1: {}
+
+  map-obj@4.3.0: {}
+
   map-stream@0.1.0: {}

   markdown-extensions@2.0.0: {}
@@ -25331,6 +25992,8 @@ snapshots:

   maybe-callback@2.1.0: {}

+  md5-file@4.0.0: {}
+
   mdast-util-definitions@6.0.0:
     dependencies:
       '@types/mdast': 4.0.4
@@ -25543,6 +26206,21 @@ snapshots:

   meow@13.2.0: {}

+  meow@9.0.0:
+    dependencies:
+      '@types/minimist': 1.2.5
+      camelcase-keys: 6.2.2
+      decamelize: 1.2.0
+      decamelize-keys: 1.1.1
+      hard-rejection: 2.1.0
+      minimist-options: 4.1.0
+      normalize-package-data: 3.0.3
+      read-pkg-up: 7.0.1
+      redent: 3.0.0
+      trim-newlines: 3.0.1
+      type-fest: 0.18.1
+      yargs-parser: 20.2.9
+
   merge-descriptors@1.0.3: {}

   merge-stream@2.0.0: {}
@@ -25838,10 +26516,6 @@ snapshots:
     dependencies:
       mime-db: 1.52.0

-  mime-types@3.0.2:
-    dependencies:
-      mime-db: 1.54.0
-
   mime@1.6.0: {}

   mimic-fn@1.2.0: {}
@@ -25850,6 +26524,8 @@ snapshots:

   mimic-function@5.0.1: {}

+  min-indent@1.0.1: {}
+
   mini-css-extract-plugin@2.9.2(webpack@5.105.0(esbuild@0.28.0)(lightningcss@1.32.0)(postcss@8.5.12)):
     dependencies:
       schema-utils: 4.3.3
@@ -25878,6 +26554,12 @@ snapshots:
     dependencies:
       brace-expansion: 2.1.1

+  minimist-options@4.1.0:
+    dependencies:
+      arrify: 1.0.1
+      is-plain-obj: 1.1.0
+      kind-of: 6.0.3
+
   minimist@1.2.8: {}

   minipass-collect@2.0.1:
@@ -25956,6 +26638,8 @@ snapshots:
       dns-packet: 5.6.1
       thunky: 1.1.0

+  mustache@4.2.0: {}
+
   mutation-server-protocol@0.4.1:
     dependencies:
       zod: 4.4.3
@@ -26000,6 +26684,8 @@ snapshots:

   neotraverse@0.6.18: {}

+  nested-error-stacks@2.1.1: {}
+
   netmask@2.1.1: {}

   ng-packagr@19.2.2(@angular/compiler-cli@19.2.25(@angular/compiler@19.2.25)(typescript@5.8.3))(tslib@2.8.1)(typescript@5.8.3):
@@ -26108,6 +26794,20 @@ snapshots:
       object-assign: 2.1.1
       splitargs: 0.0.7

+  normalize-package-data@2.5.0:
+    dependencies:
+      hosted-git-info: 2.8.9
+      resolve: 1.22.12
+      semver: 5.7.2
+      validate-npm-package-license: 3.0.4
+
+  normalize-package-data@3.0.3:
+    dependencies:
+      hosted-git-info: 4.1.0
+      is-core-module: 2.16.2
+      semver: 7.8.4
+      validate-npm-package-license: 3.0.4
+
   normalize-path@3.0.0: {}

   normalize-range@0.1.2: {}
@@ -26118,7 +26818,7 @@ snapshots:

   npm-install-checks@7.1.2:
     dependencies:
-      semver: 7.7.1
+      semver: 7.8.4

   npm-normalize-package-bin@4.0.0: {}

@@ -26282,12 +26982,6 @@ snapshots:
       is-inside-container: 1.0.0
       is-wsl: 3.1.1

-  openapi-fetch@0.17.0:
-    dependencies:
-      openapi-typescript-helpers: 0.1.0
-
-  openapi-typescript-helpers@0.1.0: {}
-
   opener@1.5.2: {}

   optionator@0.9.4:
@@ -26340,6 +27034,12 @@ snapshots:
       object-keys: 1.1.1
       safe-push-apply: 1.0.0

+  p-event@4.2.0:
+    dependencies:
+      p-timeout: 3.2.0
+
+  p-finally@1.0.0: {}
+
   p-limit@2.3.0:
     dependencies:
       p-try: 2.2.0
@@ -26385,6 +27085,10 @@ snapshots:
       is-network-error: 1.3.2
       retry: 0.13.1

+  p-timeout@3.2.0:
+    dependencies:
+      p-finally: 1.0.0
+
   p-timeout@7.0.1: {}

   p-try@2.2.0: {}
@@ -26586,6 +27290,10 @@ snapshots:
     optionalDependencies:
       '@napi-rs/nice': 1.1.1

+  pixelmatch@5.3.0:
+    dependencies:
+      pngjs: 6.0.0
+
   pkg-dir@3.0.0:
     dependencies:
       find-up: 3.0.0
@@ -26624,6 +27332,10 @@ snapshots:

   pluralize@1.2.1: {}

+  pngjs@6.0.0: {}
+
+  pngjs@7.0.0: {}
+
   portfinder@1.0.38:
     dependencies:
       async: 3.2.6
@@ -26738,6 +27450,8 @@ snapshots:

   prelude-ls@1.2.1: {}

+  presentable-error@0.0.1: {}
+
   pretty-format@27.5.1:
     dependencies:
       ansi-regex: 5.0.1
@@ -26877,6 +27591,8 @@ snapshots:

   queue-microtask@1.2.3: {}

+  quick-lru@4.0.1: {}
+
   radix-ui@1.6.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
     dependencies:
       '@radix-ui/primitive': 1.1.5
@@ -27043,6 +27759,19 @@ snapshots:
     dependencies:
       loose-envify: 1.4.0

+  read-pkg-up@7.0.1:
+    dependencies:
+      find-up: 4.1.0
+      read-pkg: 5.2.0
+      type-fest: 0.8.1
+
+  read-pkg@5.2.0:
+    dependencies:
+      '@types/normalize-package-data': 2.4.4
+      normalize-package-data: 2.5.0
+      parse-json: 5.2.0
+      type-fest: 0.6.0
+
   readable-stream@1.0.34:
     dependencies:
       core-util-is: 1.0.3
@@ -27107,6 +27836,11 @@ snapshots:
       unified: 11.0.5
       vfile: 6.0.3

+  redent@3.0.0:
+    dependencies:
+      indent-string: 4.0.0
+      strip-indent: 3.0.0
+
   reduce-flatten@1.0.1: {}

   reduce-flatten@3.0.1: {}
@@ -27136,6 +27870,65 @@ snapshots:
       get-proto: 1.0.1
       which-builtin-type: 1.2.1

+  reg-cli@0.18.16:
+    dependencies:
+      bluebird: 3.7.2
+      chalk: 4.1.2
+      cli-spinner: 0.2.10
+      cross-spawn: 7.0.6
+      del: 8.0.1
+      glob: 7.2.3
+      img-diff-js: 0.5.4
+      jpeg-js: 0.4.4
+      lodash: 4.18.1
+      make-dir: 3.1.0
+      md5-file: 4.0.0
+      meow: 9.0.0
+      mustache: 4.2.0
+      x-img-diff-js: 0.3.5
+      xmlbuilder2: 4.0.3
+      yargs-parser: 21.1.1
+
+  reg-publish-s3-plugin@0.14.4:
+    dependencies:
+      '@aws-sdk/client-s3': 3.1123.0
+      mkdirp: 1.0.4
+      reg-suit-util: 0.14.3
+      uuid: 9.0.1
+
+  reg-simple-keygen-plugin@0.14.2: {}
+
+  reg-suit-core@0.14.5:
+    dependencies:
+      reg-cli: 0.18.16
+      reg-suit-util: 0.14.3
+      rimraf: 5.0.10
+
+  reg-suit-util@0.14.3:
+    dependencies:
+      '@types/cli-progress': 3.11.6
+      '@types/cli-spinner': 0.2.3
+      '@types/glob': 8.1.0
+      '@types/lodash': 4.17.25
+      '@types/mime-types': 2.1.4
+      '@types/mkdirp': 1.0.2
+      chalk: 4.1.2
+      cli-progress: 3.12.0
+      cli-spinner: 0.2.10
+      glob: 7.2.3
+      lodash: 4.18.1
+      mime-types: 2.1.35
+      mkdirp: 1.0.4
+
+  reg-suit@0.14.5:
+    dependencies:
+      cp-file: 9.1.0
+      ignore: 5.3.1
+      inquirer: 8.2.6
+      reg-suit-core: 0.14.5
+      reg-suit-util: 0.14.3
+      yargs: 17.0.1
+
   regenerate-unicode-properties@10.2.2:
     dependencies:
       regenerate: 1.4.2
@@ -27444,6 +28237,10 @@ snapshots:
     dependencies:
       glob: 9.3.5

+  rimraf@5.0.10:
+    dependencies:
+      glob: 10.5.0
+
   robot-directives@0.3.0:
     dependencies:
       isbot: 2.5.7
@@ -27839,6 +28636,7 @@ snapshots:
       '@img/sharp-win32-arm64': 0.34.5
       '@img/sharp-win32-ia32': 0.34.5
       '@img/sharp-win32-x64': 0.34.5
+    optional: true

   shebang-command@1.2.0:
     dependencies:
@@ -28281,6 +29079,10 @@ snapshots:

   strip-final-newline@4.0.0: {}

+  strip-indent@3.0.0:
+    dependencies:
+      min-indent: 1.0.1
+
   strip-json-comments@2.0.1: {}

   strip-json-comments@3.1.1: {}
@@ -28661,6 +29463,8 @@ snapshots:

   trim-lines@3.0.1: {}

+  trim-newlines@3.0.1: {}
+
   trough@2.2.0: {}

   ts-api-utils@1.4.3(typescript@5.8.3):
@@ -28785,10 +29589,16 @@ snapshots:

   type-detect@4.0.8: {}

+  type-fest@0.18.1: {}
+
   type-fest@0.20.2: {}

   type-fest@0.21.3: {}

+  type-fest@0.6.0: {}
+
+  type-fest@0.8.1: {}
+
   type-fest@4.41.0: {}

   type-is@1.6.18:
@@ -29086,6 +29896,8 @@ snapshots:

   uuid@8.3.2: {}

+  uuid@9.0.1: {}
+
   v8-to-istanbul@8.1.1:
     dependencies:
       '@types/istanbul-lib-coverage': 2.0.6
@@ -29699,6 +30511,8 @@ snapshots:

   ws@8.21.0: {}

+  x-img-diff-js@0.3.5: {}
+
   xlsx@0.18.5:
     dependencies:
       adler-32: 1.3.1
@@ -29715,6 +30529,13 @@ snapshots:

   xml-name-validator@5.0.0: {}

+  xmlbuilder2@4.0.3:
+    dependencies:
+      '@oozcitak/dom': 2.0.2
+      '@oozcitak/infra': 2.0.2
+      '@oozcitak/util': 10.0.0
+      js-yaml: 4.2.0
+
   xmlchars@2.2.0: {}

   xmlcreate@2.0.4: {}
@@ -29751,6 +30572,16 @@ snapshots:
       y18n: 5.0.8
       yargs-parser: 20.2.9

+  yargs@17.0.1:
+    dependencies:
+      cliui: 7.0.4
+      escalade: 3.2.0
+      get-caller-file: 2.0.5
+      require-directory: 2.1.1
+      string-width: 4.2.3
+      y18n: 5.0.8
+      yargs-parser: 20.2.9
+
   yargs@17.7.2:
     dependencies:
       cliui: 8.0.1
diff --git a/visual-tests/.eslintrc.js b/visual-tests/.eslintrc.js
index f4eec3db78..d2f8f52488 100644
--- a/visual-tests/.eslintrc.js
+++ b/visual-tests/.eslintrc.js
@@ -3,12 +3,6 @@ module.exports = {
     '../.eslintrc.js',
     'plugin:import/typescript'
   ],
-  rules: {
-    // @argos-ci/core is an ESM-only package that uses the `exports` field in package.json.
-    // The default eslint-import-resolver-node does not support the `exports` field, so the
-    // module cannot be resolved statically even though it is installed correctly.
-    'import/no-unresolved': ['error', { ignore: ['@argos-ci/core'] }],
-  },
   overrides: [
     {
       files: ['*.ts'],
@@ -26,6 +20,24 @@ module.exports = {
           typedefs: false
         }]
       }
+    },
+    {
+      // Same treatment the root config gives `scripts/**/*.mjs`: Node ESM needs
+      // the file extension on relative imports, which the base config forbids.
+      files: ['lib/**/*.mjs'],
+      rules: {
+        'import/extensions': [
+          'error',
+          'never',
+          {
+            js: ['error', 'always'],
+            mjs: ['error', 'always'],
+            json: ['error', 'always'],
+          }
+        ],
+        'no-restricted-globals': 'off',
+        'no-console': 'off',
+      }
     }
   ]
 };
diff --git a/visual-tests/.gitignore b/visual-tests/.gitignore
index aab75e62e4..0c218e1ba9 100644
--- a/visual-tests/.gitignore
+++ b/visual-tests/.gitignore
@@ -4,3 +4,4 @@ node_modules/
 /playwright/.cache/
 /screenshots/
 .env
+.reg/
diff --git a/visual-tests/AGENTS.md b/visual-tests/AGENTS.md
index a99e48b528..0ef181cf76 100644
--- a/visual-tests/AGENTS.md
+++ b/visual-tests/AGENTS.md
@@ -1,6 +1,8 @@
 # Visual Regression Tests

-Playwright-based visual regression testing with Argos CI for screenshot comparison.
+Playwright-based visual regression testing. Screenshots are compared by
+[reg-suit](https://github.com/reg-viz/reg-suit); golden records and the HTML diff reports live in
+Cloudflare R2.

 ## Framework

@@ -37,7 +39,7 @@ test(__filename, async({ tablePage }) => {

 ## Golden snapshots: js-copied baselines (critical gotcha)

-The reference (golden) baseline and PR builds are generated **differently**, and this asymmetry is a recurring source of false-positive Argos diffs.
+The reference (golden) baseline and PR builds are generated **differently**, and this asymmetry is a recurring source of false-positive diffs.

 - **Reference branch (`develop`)** — `scripts/run-tests.mjs` renders **only the `js` framework** (`getFrameworkList()` returns `[REFERENCE_FRAMEWORK]` when `isReferenceBranch()`), then **copies** the js `multi-frameworks` screenshots into the `react-wrapper` / `vue3` / `angular-wrapper` baselines. The wrapper screenshots in the golden set are therefore **identical to the js render** — the wrappers are never actually rendered on `develop`.
 - **Pull requests (non-reference branches)** — every framework (`js` + all wrappers) is rendered for real from its own visual-test example, and each is compared against the copied js baseline.
@@ -54,8 +56,57 @@ The reference (golden) baseline and PR builds are generated **differently**, and
 - `src/helpers.ts`: screenshotPath, DOM selectors, platform detection
 - `src/page-helpers.ts`: selectCell, menu navigation, high-level interactions

+## Comparison and approval (reg-suit)
+
+`npm run in visual-tests compare` runs `scripts/compare.mjs`, which wraps `reg-suit run`. The wrapper
+refuses to run when `REG_ACTUAL_KEY` starts with `base/` outside CI, so a local debugging session holding R2
+credentials cannot overwrite the golden records every pull request is compared against — use a `local/...`
+key instead. `reg-suit` itself fetches the golden records, diffs them against `screenshots/`, and publishes
+the images plus a self-contained `index.html` to R2. reg-suit posts nothing
+itself — no notifier plugin is configured. The pull request comment is written by `visual-gate.mjs` to
+`.reg/comment.md` and posted by the `marocchino/sticky-pull-request-comment` step in `visual.yml`, which is
+why it carries the approval instructions as well as the counts.
+
+Five things about this pipeline are worth knowing before changing it.
+
+- **`reg-suit run` exits 0 no matter what it finds.** A comparison result never fails it; fetch, publish
+  and comparison-runtime errors do. Notifier errors are the one class it deliberately swallows
+  (`processor.js`: "Don't re-throw notifiers error because it's not fatal"), which is why a broken notifier
+  is invisible. `scripts/visual-gate.mjs` reads `.reg/out.json` and is the only thing that turns the check
+  red. Never assume a green `compare` step means no differences.
+- **Approval is all-or-nothing and is a GitHub label.** The `visual-approved` label on a pull request skips
+  the gate for the whole build; there is no per-screenshot review. The label is removed automatically on
+  every push (`.github/workflows/visual-cleanup.yml`), so approval never carries over to unreviewed
+  screenshots.
+- **The comparison tolerates antialiasing, deliberately.** `regconfig.json` sets `enableAntialias` and
+  `thresholdPixel: 150`. Chromium's text antialiasing is not bit-stable between runs: a measured example
+  differed by 78 pixels out of 921,600 with no visible change, and at zero tolerance that failed 104 of
+  1,646 screenshots — all of them focus- or menu-state captures. Do not lower these back to zero without
+  re-measuring; a real regression is orders of magnitude larger.
+- **A missing baseline never blocks.** `Check for golden records` probes
+  `https://<domain>/base/<branch>/out.json` over plain HTTPS. When that 404s the run sets
+  `VISUAL_BOOTSTRAP=true`: `visual-gate.mjs` passes without reading a report, and a same-repo build promotes
+  its own screenshots to that branch's golden records. A fork cannot seed (no credentials), so it skips the
+  comparison instead. An unreviewed baseline survives at most one merge on `develop`, `master` and
+  `release/*`, because that branch's next build overwrites it. **Not on `lts/*`:** nothing triggers this
+  workflow on an LTS push, so an LTS baseline is whatever the first pull request rendered, indefinitely.
+- **A golden record is just a previous build's `actual/` directory.** reg-suit fetches
+  `<expectedKey>/actual/**` into the local `expected/` dir, so the goldens and a normal build share one
+  format. There is no separate baseline artifact to maintain.
+
+Snapshot keys, set in `.github/workflows/visual.yml`:
+
+```
+base/<branch>/     golden records, rewritten by every build of that branch
+pr-<number>/<sha>/ report and images for one pull request build, deleted when the PR closes
+```
+
+`EXPECTED_KEY` derives from `github.base_ref`, so a pull request is always compared against the branch it
+targets. The `js`-to-wrapper baseline copy in `run-tests.mjs` (see the golden snapshots gotcha above) still
+applies — reg-suit matches screenshots by their path.
+
 ## Run

-See `package.json` scripts for build, test, and upload commands.
+See `package.json` scripts for build, test, and comparison commands.

 For detailed guidance: use skills `visual-testing`, `creating-visual-test-examples`
diff --git a/visual-tests/README.md b/visual-tests/README.md
index 8035dc7954..ad553d5192 100644
--- a/visual-tests/README.md
+++ b/visual-tests/README.md
@@ -9,30 +9,110 @@ We run visual tests automatically by using the following tools:
 | Tool                                                                   | Description                                                                                                                                             |
 | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
 | [Playwright](https://playwright.dev/docs/intro)                        | An open-source testing framework backed by Microsoft. We use it to write and run visual tests.                                                          |
-| [Argos](https://argos-ci.com/docs/visual-testing)                      | An external visual testing service. We use it to compare screenshots.                                                                                   |
+| [reg-suit](https://github.com/reg-viz/reg-suit)                        | An open-source visual regression suite. We use it to compare screenshots and to publish an HTML report.                                                 |
+| [Cloudflare R2](https://developers.cloudflare.com/r2/)                 | Object storage. We use it to hold the golden records and to serve the diff reports.                                                                     |
 | [GitHub Actions](https://github.com/handsontable/handsontable/actions) | GitHub's CI platform. We use it to automate our [test workflows](https://github.com/handsontable/handsontable/blob/develop/.github/workflows/test.yml). |

 When you push changes to a GitHub pull request:
-1. The [Visual tests linter](https://github.com/handsontable/handsontable/actions/workflows/visual-tests-linter.yml)
-   workflow checks the code of each visual test.
+1. The **Lint / visual tests** check ([`lint.yml`](https://github.com/handsontable/handsontable/blob/develop/.github/workflows/lint.yml))
+   checks the code of each visual test.
 2. The [Tests](https://github.com/handsontable/handsontable/blob/develop/.github/workflows/test.yml) workflow runs all
    of Handsontable's tests.
-3. After all tests pass successfully, the [Visual tests](https://github.com/handsontable/handsontable/blob/develop/.github/workflows/test.yml#L432-L502)
-   job runs the visual tests and uploads the resulting screenshots to Argos.
-4. Argos compares your feature branch screenshots against the reference branch (`develop`) screenshots
-   (so-called "reference", "baseline" or "golden" screenshots).
-
-If Argos spots differences between two corresponding screenshots,
-the **Visual tests** check on on your pull request fails, and you can't merge your changes to `develop`. In that case:
-1. Open the log of the **Visual tests** job:<br>
-   At the bottom of your pull request, find the **Visual tests** check. Select **Details**.
-2. Open the Argos URL and [review the differences](https://argos-ci.com/docs/visual-testing#reviewing-visual-changes).
-   You can:
-      - [Reject the modified screenshots](https://argos-ci.com/docs/visual-testing#-reject-a-build-workflow), update your code,
-        and [re-run the visual tests](#run-visual-tests-through-github-actions).
-      - [Accept the modified screenshots](https://argos-ci.com/docs/visual-testing#-approving-a-build).
-        You can then merge your changes to `develop`.
-        As a result, the modified screenshots become the new baseline.
+3. After all tests pass successfully, the [Visual](https://github.com/handsontable/handsontable/blob/develop/.github/workflows/visual.yml)
+   workflow runs the visual tests, then compares the resulting screenshots against the golden records.
+4. The golden records come from the branch your pull request targets — usually `develop`. Every build of a
+   base branch rewrites that branch's golden records, so a pull request into `develop`, `master`, or a
+   release branch is compared against the right baseline with no extra configuration.
+
+   **Exception — LTS branches.** No workflow currently runs the visual tests on a push to `lts/*`, so an
+   LTS baseline is created by the first pull request into that branch and is never replaced. Later LTS
+   pull requests are therefore compared against one contributor's unreviewed screenshots. Treat an LTS
+   result as advisory until an LTS push trigger exists.
+
+If reg-suit spots differences, the **Compare** check on your pull request fails, and you can't merge your
+changes. In that case:
+1. Open the report. The **Visual** workflow comments the report URL on your pull request. If that URL is
+   unreachable, download the `visual-diff-report` artifact from the workflow run instead.
+2. Decide what the differences mean:
+      - They are a regression. Push a commit that removes them, and the check goes green.
+      - They are intentional. Add the `visual-approved` label to the pull request, then re-run the
+        **Compare** job. Approval covers the whole build — there is no per-screenshot review.
+
+Approval binds to one set of screenshots. Pushing a new commit removes the `visual-approved` label, so
+screenshots nobody has looked at never inherit an earlier approval.
+
+Two cases the label cannot solve:
+
+- **Your pull request comes from a fork, or from Dependabot.** Those runs get no secrets, so nothing can
+  clear the label when you push again — which means it is ignored there rather than trusted. The check
+  reports the real verdict, the workflow run's job summary carries it, and the `visual-diff-report`
+  artifact holds the images. To accept intentional differences, a maintainer has to re-raise the branch
+  from the main repository.
+- **A visual change merged into the branch you target.** The golden records always come from that branch's
+  latest build, so once someone else's intentional change lands, your next run inherits their differences
+  as well as yours. **Rebase** — approving would also approve any real regression of your own that the same
+  build contains.
+
+Approval is read live at the moment the gate runs, so it binds to wall-clock time rather than to a commit.
+Applying the label while a newer push is still rendering approves whatever that build produces.
+
+If the branch you target has no golden records yet, the check does not fail. The build promotes its own
+screenshots to that branch's golden records and passes, so a fresh branch cannot wedge every pull request
+opened against it. The next build of the base branch overwrites them with the authoritative render — on
+every base branch except `lts/*`, which has no push trigger (see the exception above).
+
+## How the comparison works
+
+```mermaid
+flowchart TD
+    PR["Push to a pull request"] --> RENDER
+    DEV["Push to develop or a release branch"] --> RENDER
+
+    subgraph RENDER["Render (matrix)"]
+        R1["multi-framework<br/>js + 3 wrappers, 4 themes"]
+        R2["cross-browser<br/>chromium, firefox, webkit"]
+    end
+
+    RENDER --> KEYS{"Which ref?"}
+    KEYS -->|"pull request"| KPR["expected = base/TARGET<br/>actual = pr-NUMBER/SHA"]
+    KEYS -->|"base branch"| KBR["expected = actual = base/BRANCH"]
+
+    KPR --> PROBE
+    KBR --> PROBE
+    PROBE{"Do golden records exist?<br/>GET /base/BRANCH/out.json"}
+
+    PROBE -->|"404, none yet"| SEED["Promote this build to<br/>the golden records"]
+    SEED --> PASS
+
+    PROBE -->|"200"| WHO{"Fork or Dependabot?"}
+    WHO -->|"no, has secrets"| SUIT["reg-suit run<br/>fetch, diff, publish"]
+    WHO -->|"yes, no secrets"| FORK["compare-fork.mjs<br/>anonymous HTTPS, publishes nothing"]
+
+    SUIT --> OUT["screenshots compared<br/>.reg/out.json"]
+    FORK --> OUT
+
+    OUT --> GATE{"visual-gate.mjs<br/>any differences?"}
+    GATE --> COMMENT["visual-gate.mjs writes the comment,<br/>sticky action posts it"]
+    GATE -->|"none"| PASS["Check passes, PR mergeable"]
+    GATE -->|"differences found"| LABEL{"visual-approved<br/>label present?"}
+    LABEL -->|"yes"| PASS
+    LABEL -->|"no"| FAIL["Check fails, PR blocked"]
+
+    FAIL --> REVIEW["Open the report URL<br/>or the visual-diff-report artifact"]
+    REVIEW -->|"a regression: fix it"| PR
+    REVIEW -->|"intentional: add the label"| PR
+
+    KBR -.->|"rewrites the baseline"| BUCKET[("Cloudflare R2<br/>base/BRANCH/actual/")]
+    SEED -.-> BUCKET
+    BUCKET -.->|"read as expected"| PROBE
+
+    CLOSED["Pull request closed"] --> PURGE["Delete pr-NUMBER/ from R2"]
+```
+
+Two behaviors are worth reading off the diagram:
+
+- **Approval is all or nothing.** The `visual-approved` label accepts every difference in the build at once. Pushing a new commit removes the label, so an approval covers exactly the screenshots someone looked at.
+- **A missing baseline never blocks.** The first build for a branch promotes its own screenshots to the golden records and passes. The next build of that branch replaces them, so an unreviewed baseline survives at most one merge.

 ## Visual tests structure

@@ -69,23 +149,28 @@ There main demo available for all frameworks is served on `/`. There are additio

 Our GitHub Actions configuration runs the visual tests automatically, but you can run them manually as well:

-1. On GitHub, at the bottom of your pull request, find the **Visual tests** check. Select **Details**.
-2. On the left, next to the **Visual tests** job, select 🔄.
+1. On GitHub, at the bottom of your pull request, find the **Visual / Compare** check. Select **Details**.
+2. On the left, next to the **Compare** job, select 🔄.
 3. Select **Re-run jobs**.

 ## Run visual tests locally

-You can manually run visual tests on your machine and then upload the resulting screenshots to Argos.
+You can manually run visual tests on your machine and then compare the resulting screenshots against the
+golden records.

 First, prepare your local visual testing environment:

 1. Make sure you're using the Node and npm versions mentioned [here](https://handsontable.com/docs/react-data-grid/custom-builds/#build-requirements).
 2. From the `./visual-tests/` directory, run `npm install`.
-3. In the `./visual-tests/` directory, create a file called `.env`. In the file, add the Argos token:
+3. In the `./visual-tests/` directory, create a file called `.env`. In the file, add the R2 credentials:
    ```bash
-   ARGOS_TOKEN=xxx
+   AWS_ACCESS_KEY_ID=xxx
+   AWS_SECRET_ACCESS_KEY=xxx
+   R2_BUCKET_NAME=xxx
+   R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com
+   VISUAL_REPORT_DOMAIN=xxx
    ```
-   Ask your supervisor about the token's value.
+   Ask your supervisor about the values.

 To run the visual tests locally:

@@ -97,8 +182,14 @@ To run the visual tests locally:
    | `npx playwright test {{ file name }}` | Run a specific test.<br><br>For example: `npx playwright test mouse-wheel`                         |

    The resulting screenshots are saved in `./visual-tests/screenshots/`.
-2. From the `./visual-tests/` directory, run `npm run upload`.
-3. Open the Argos URL displayed in the terminal.
+2. From the `./visual-tests/` directory, set the snapshot keys and run the comparison:
+   ```bash
+   REG_EXPECTED_KEY=base/develop REG_ACTUAL_KEY=local/$(git rev-parse --short HEAD) npm run compare
+   ```
+   `compare` loads `./.env` itself if the file exists, so the credentials from step 3 are picked up
+   without exporting them by hand.
+   A local run never writes to `base/`, so it cannot overwrite a golden record.
+3. Open the report URL printed in the terminal, or open `./visual-tests/.reg/index.html` directly.

 ## Write a new visual test

@@ -114,8 +205,8 @@ To add a new visual test:
       - [Helpers](#helpers)
       - [Take screenshots](#take-screenshots)
 4. Push your changes to a pull request.<br>
-   The [Visual tests linter](https://github.com/handsontable/handsontable/actions/workflows/visual-tests-linter.yml)
-   workflow checks the code of your test.
+   The **Lint / visual tests** check ([`lint.yml`](https://github.com/handsontable/handsontable/blob/develop/.github/workflows/lint.yml))
+   checks the code of your test.

 ### Take screenshots

diff --git a/visual-tests/lib/__tests__/visual-gate.test.mjs b/visual-tests/lib/__tests__/visual-gate.test.mjs
new file mode 100644
index 0000000000..8df69ddcd7
--- /dev/null
+++ b/visual-tests/lib/__tests__/visual-gate.test.mjs
@@ -0,0 +1,145 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { evaluate } from '../visual-gate.mjs';
+
+// This module decides whether a pull request can merge, so each branch is
+// pinned: a silent flip from `blocked: true` to `false` would let unreviewed
+// screenshots through with a green check and no other signal.
+
+const report = (counts = {}) => ({
+  failedItems: Array(counts.changed ?? 0).fill('a.png'),
+  newItems: Array(counts.added ?? 0).fill('b.png'),
+  deletedItems: Array(counts.deleted ?? 0).fill('c.png'),
+  passedItems: Array(counts.passed ?? 0).fill('d.png'),
+});
+
+test('seeding a baseline passes without comparing', () => {
+  const v = evaluate({ report: null, bootstrap: true });
+
+  assert.equal(v.blocked, false);
+  assert.match(v.comment, /baseline created/);
+});
+
+test('bootstrap covers a missing report', () => {
+  // The credential-free path writes no report when there are no golden records,
+  // so bootstrap has to be honoured before the report is required.
+  const v = evaluate({ report: null, bootstrap: true, approved: false });
+
+  assert.equal(v.blocked, false);
+});
+
+test('an empty report blocks even on the bootstrap path', () => {
+  // The composition that made the two fixes cancel out: a first run that globbed
+  // nothing has no failed and no passed items, so it looks identical to a
+  // legitimate first build. Passing it seeds a blank manifest, after which the
+  // probe returns 200 forever and nothing is ever compared again.
+  const v = evaluate({ report: report({}), bootstrap: true });
+
+  assert.equal(v.blocked, true);
+  assert.match(v.comment, /nothing was compared/i);
+  assert.doesNotMatch(v.comment, /baseline created/);
+});
+
+test('bootstrap covers an all-new report', () => {
+  // The normal seeding shape: everything is new because nothing existed.
+  const v = evaluate({ report: report({ added: 1646 }), bootstrap: true });
+
+  assert.equal(v.blocked, false);
+  assert.match(v.comment, /baseline created/);
+});
+
+test('a real comparison overrides a stale bootstrap probe', () => {
+  // A base build killed mid-publish can leave `actual/**` up with no manifest.
+  // The probe then reports "no baseline" while reg-suit compares against those
+  // actuals for real. Passing that would overwrite the baseline with this build.
+  const v = evaluate({ report: report({ changed: 12, passed: 1634 }), bootstrap: true });
+
+  assert.equal(v.blocked, true);
+  assert.match(v.comment, /changes detected/);
+});
+
+test('deleted-only differences count as a real comparison', () => {
+  // A build that renames every screenshot produces only deletions plus new
+  // items. Those deletions prove a baseline existed, so a stale probe must not
+  // be able to seed over it.
+  const v = evaluate({ report: report({ deleted: 1646, added: 1646 }), bootstrap: true });
+
+  assert.equal(v.blocked, true);
+  assert.doesNotMatch(v.comment, /baseline created/);
+});
+
+test('a run that cannot seed says so instead of claiming a baseline was created', () => {
+  const v = evaluate({ report: null, bootstrap: true, seeded: false });
+
+  assert.equal(v.blocked, false);
+  assert.match(v.comment, /nothing to compare/i);
+  assert.doesNotMatch(v.comment, /became the baseline/);
+});
+
+test('an unreadable report blocks rather than passing silently', () => {
+  const v = evaluate({ report: null });
+
+  assert.equal(v.blocked, true);
+  assert.match(v.comment, /could not compare/);
+});
+
+test('no differences passes', () => {
+  const v = evaluate({ report: report({ passed: 1646 }) });
+
+  assert.equal(v.blocked, false);
+  assert.match(v.comment, /All 1646 screenshots match/);
+});
+
+test('an empty report blocks instead of reporting a pass', () => {
+  // reg-suit exits 0 having globbed nothing when the config or the screenshots
+  // are missing. Reading that as "no changes" would merge a broken setup.
+  const v = evaluate({ report: report({}) });
+
+  assert.equal(v.blocked, true);
+  assert.match(v.comment, /nothing was compared/i);
+  assert.doesNotMatch(v.comment, /All 0 screenshots match/);
+});
+
+test('changed items block and the comment explains how to approve', () => {
+  const v = evaluate({ report: report({ changed: 1573, passed: 73 }), reportUrl: 'https://x/i.html' });
+
+  assert.equal(v.blocked, true);
+  assert.match(v.comment, /\| 1573 \| 0 \| 0 \| 73 \|/);
+  assert.match(v.comment, /visual-approved/);
+  assert.match(v.comment, /https:\/\/x\/i\.html/);
+});
+
+test('new items alone block', () => {
+  assert.equal(evaluate({ report: report({ added: 3 }) }).blocked, true);
+});
+
+test('deleted items alone block', () => {
+  assert.equal(evaluate({ report: report({ deleted: 2 }) }).blocked, true);
+});
+
+test('the approval label unblocks the same differences', () => {
+  const counts = { changed: 1573, passed: 73 };
+
+  assert.equal(evaluate({ report: report(counts) }).blocked, true);
+  assert.equal(evaluate({ report: report(counts), approved: true }).blocked, false);
+});
+
+test('an approved verdict says so rather than reprinting the instructions', () => {
+  const v = evaluate({ report: report({ changed: 5 }), approved: true });
+
+  assert.match(v.comment, /changes approved/);
+  assert.doesNotMatch(v.comment, /Add the \*\*`visual-approved`\*\* label/);
+});
+
+test('approval cannot fabricate a pass out of an unreadable report', () => {
+  // Approval accepts differences; it must not paper over not knowing what they are.
+  const v = evaluate({ report: null, approved: true });
+
+  assert.equal(v.blocked, true);
+});
+
+test('a missing report URL degrades to the artifact instructions', () => {
+  const v = evaluate({ report: report({ changed: 1 }) });
+
+  assert.match(v.comment, /visual-diff-report/);
+});
diff --git a/visual-tests/lib/visual-gate.mjs b/visual-tests/lib/visual-gate.mjs
new file mode 100644
index 0000000000..056c7b070f
--- /dev/null
+++ b/visual-tests/lib/visual-gate.mjs
@@ -0,0 +1,184 @@
+/**
+ * Decides the visual verdict and renders the pull request comment.
+ *
+ * Pure: no file, network, or environment access, so the branching that governs
+ * whether a pull request can merge is unit-testable. `scripts/visual-gate.mjs`
+ * is the thin wrapper that reads `.reg/out.json` and writes `.reg/comment.md`.
+ */
+
+/**
+ * @typedef {object} Verdict
+ * @property {boolean} blocked Whether the check should fail.
+ * @property {string} summary One-line result for the job log.
+ * @property {string} comment Markdown body for the pull request comment.
+ */
+
+/**
+ * Evaluate a comparison result.
+ *
+ * @param {object} options Evaluation inputs.
+ * @param {object|null} options.report Parsed `out.json`, or `null` when unreadable.
+ * @param {boolean} [options.bootstrap] Whether the probe found no golden records.
+ * @param {boolean} [options.seeded] Whether this run may write the baseline.
+ * @param {boolean} [options.approved] Whether `visual-approved` is on the pull request.
+ * @param {string} [options.reportUrl] Published report URL, or '' when nothing was published.
+ * @param {string} [options.runUrl] Workflow run URL, when known.
+ * @returns {Verdict} The verdict.
+ */
+export function evaluate({
+  report, bootstrap = false, seeded = true, approved = false, reportUrl = '', runUrl = '',
+}) {
+  // `bootstrap` comes from a probe of `out.json`, which is a different source of
+  // truth from the comparison itself. A base build killed mid-publish can leave
+  // `actual/**` uploaded with no manifest: the probe then says "no baseline"
+  // while reg-suit fetches those actuals and produces a report with real
+  // differences. Trusting the probe alone would pass that build and overwrite
+  // the baseline with it, so a real comparison always wins.
+  // `deletedItems` counts too: an expected file that matched nothing still
+  // proves a baseline existed. Without it, a torn manifest plus a build that
+  // renames every screenshot slips through and seeds over the real records.
+  const compared = Boolean(report
+    && (report.failedItems.length || report.passedItems.length || report.deletedItems.length));
+
+  // Checked before `bootstrap`, not after. reg-suit exits 0 having globbed
+  // nothing when the config or the screenshots are missing, and that report has
+  // no failed and no passed items either -- so it looks exactly like a legitimate
+  // first build. Left later in the order, a broken first run would pass as
+  // "baseline created" and seed a blank manifest, after which the probe returns
+  // 200 forever and every later pull request compares against nothing.
+  // A null report is the credential-free path, which legitimately writes none.
+  if (report && report.failedItems.length + report.newItems.length
+    + report.deletedItems.length + report.passedItems.length === 0) {
+    return {
+      blocked: true,
+      summary: 'The comparison found no screenshots at all, so nothing was checked.',
+      comment: [
+        '## Visual tests — nothing was compared',
+        '',
+        'The report lists no passing, changed, new, or deleted screenshots. That means',
+        'the comparison never found them, not that they match.',
+        runUrl ? `\n[Workflow run](${runUrl})\n` : '',
+      ].join('\n'),
+    };
+  }
+
+  if (bootstrap && !compared) {
+    return seeded
+      ? {
+        blocked: false,
+        summary: 'No golden records existed for this base branch, so this build seeds them.',
+        comment: [
+          '## Visual tests — baseline created',
+          '',
+          'This branch had no golden records, so this build became the baseline.',
+          'There was nothing to compare against yet, and the next build of the base',
+          'branch replaces it with the authoritative render.',
+          '',
+        ].join('\n'),
+      }
+      : {
+        blocked: false,
+        summary: 'No golden records exist for this base branch, and this run cannot seed them.',
+        comment: [
+          '## Visual tests — nothing to compare',
+          '',
+          'This base branch has no golden records yet, and a fork or Dependabot run',
+          'cannot create them. Nothing was compared and nothing was seeded.',
+          '',
+          'A build from the main repository has to publish the baseline first; after',
+          'that this pull request is compared normally on its next run.',
+          '',
+        ].join('\n'),
+      };
+  }
+
+  if (!report) {
+    return {
+      blocked: true,
+      summary: 'The comparison step produced no report, so the visual state is unknown.',
+      comment: [
+        '## Visual tests — could not compare',
+        '',
+        'The comparison step produced no report, so the visual state is unknown.',
+        runUrl ? `\n[Workflow run](${runUrl})\n` : '',
+      ].join('\n'),
+    };
+  }
+
+  const changed = report.failedItems.length;
+  const added = report.newItems.length;
+  const deleted = report.deletedItems.length;
+  const passed = report.passedItems.length;
+  const table = [
+    '| 🔴 Changed | 🟡 New | ⚪ Deleted | 🔵 Passing |',
+    '| ---: | ---: | ---: | ---: |',
+    `| ${changed} | ${added} | ${deleted} | ${passed} |`,
+  ];
+
+  if (changed + added + deleted === 0) {
+    return {
+      blocked: false,
+      summary: `No visual changes. ${passed} screenshots match the golden records.`,
+      comment: [
+        '## Visual tests — no changes',
+        '',
+        `All ${passed} screenshots match the golden records.`,
+        '',
+      ].join('\n'),
+    };
+  }
+
+  const summary = `Visual changes detected: ${changed} changed, ${added} new, ${deleted} deleted.`;
+
+  if (approved) {
+    return {
+      blocked: false,
+      summary: `${summary} Accepted via the visual-approved label.`,
+      comment: [
+        '## Visual tests — changes approved',
+        '',
+        ...table,
+        '',
+        'The **`visual-approved`** label accepted these differences as intentional.',
+        reportUrl ? `\n[Review the report](${reportUrl}) if you want to double-check them.\n` : '',
+        'The label is removed on the next push, so a later change is compared again.',
+        '',
+      ].join('\n'),
+    };
+  }
+
+  return {
+    blocked: true,
+    summary,
+    comment: [
+      '## Visual tests — changes detected',
+      '',
+      ...table,
+      '',
+      reportUrl
+        ? `**[Open the visual report](${reportUrl})** — compare each screenshot side by side, `
+          + 'with slider, blend, and toggle views.'
+        : 'The report URL is unavailable; download the `visual-diff-report` artifact instead.',
+      '',
+      'If the report is unreachable, the `visual-diff-report` artifact on the '
+        + `${runUrl ? `[workflow run](${runUrl})` : 'workflow run'} holds the same thing.`,
+      '',
+      '### What to do next',
+      '',
+      '**If these differences are a regression** — push a commit that fixes them. The check',
+      'goes green on its own.',
+      '',
+      '**If these differences are intentional** — accept them as the new baseline:',
+      '',
+      '1. Add the **`visual-approved`** label to this pull request.',
+      '2. Re-run the **Visual / Compare** job.',
+      '',
+      'Approval is all-or-nothing: the label accepts every difference in this build at once,',
+      'so read the report before applying it.',
+      '',
+      '> The label is removed automatically on every push, so an approval only ever covers',
+      '> the screenshots someone actually looked at. If you push again, re-apply it.',
+      '',
+    ].join('\n'),
+  };
+}
diff --git a/visual-tests/package.json b/visual-tests/package.json
index bdab0a53a3..e4ee2e0d70 100644
--- a/visual-tests/package.json
+++ b/visual-tests/package.json
@@ -10,16 +10,16 @@
     "test": "node ./scripts/run-tests.mjs",
     "test:cross-browser": "npx playwright test --config playwright-cross-browser.config.ts --reporter=dot",
     "serve-example": "npm --prefix ../examples/next/visual-tests/js/demo run serve -- --port=8082",
-    "upload": "node ./scripts/upload.mjs",
+    "compare": "node ./scripts/compare.mjs",
+    "compare:fork": "node ./scripts/compare-fork.mjs",
     "install-system-dependencies": "npx --no playwright install --with-deps",
     "publish-package": "echo \"There is nothing to publish. Skipped.\"",
     "create-package": "echo \"There is nothing to create. Skipped.\"",
-    "clean": "rimraf ./screenshots ./playwright-report"
+    "clean": "rimraf ./screenshots ./playwright-report ./.reg"
   },
   "author": "Handsoncode <hello@handsontable.com>",
   "license": "CC BY 4.0",
   "devDependencies": {
-    "@argos-ci/core": "^5.1.1",
     "@playwright/test": "~1.60.0",
     "@typescript-eslint/eslint-plugin": "^5.48.1",
     "@typescript-eslint/parser": "^5.48.1",
@@ -27,6 +27,10 @@
     "execa": "^5.0.0",
     "fs-extra": "^4.0.2",
     "ps-tree": "^1.2.0",
+    "reg-cli": "^0.18.5",
+    "reg-publish-s3-plugin": "^0.14.4",
+    "reg-simple-keygen-plugin": "^0.14.2",
+    "reg-suit": "^0.14.5",
     "rimraf": "^4.1.2",
     "typescript": "5.1.3"
   }
diff --git a/visual-tests/regconfig.json b/visual-tests/regconfig.json
new file mode 100644
index 0000000000..3b20b159ab
--- /dev/null
+++ b/visual-tests/regconfig.json
@@ -0,0 +1,23 @@
+{
+  "core": {
+    "workingDir": ".reg",
+    "actualDir": "screenshots",
+    "enableAntialias": true,
+    "thresholdPixel": 150
+  },
+  "plugins": {
+    "reg-simple-keygen-plugin": {
+      "expectedKey": "${REG_EXPECTED_KEY}",
+      "actualKey": "${REG_ACTUAL_KEY}"
+    },
+    "reg-publish-s3-plugin": {
+      "bucketName": "${R2_BUCKET_NAME}",
+      "enableACL": false,
+      "customDomain": "${VISUAL_REPORT_DOMAIN}",
+      "sdkOptions": {
+        "region": "auto",
+        "endpoint": "${R2_ENDPOINT}"
+      }
+    }
+  }
+}
diff --git a/visual-tests/scripts/compare-fork.mjs b/visual-tests/scripts/compare-fork.mjs
new file mode 100644
index 0000000000..0b952284e2
--- /dev/null
+++ b/visual-tests/scripts/compare-fork.mjs
@@ -0,0 +1,216 @@
+/**
+ * Compares screenshots against the golden records without any credentials.
+ *
+ * Fork and Dependabot pull requests run on a downgraded token and receive no
+ * Actions secrets, so they cannot authenticate against R2. Guarding the
+ * comparison away would delete visual review for every external contributor,
+ * which the fork-guard rules in the root `AGENTS.md` forbid.
+ *
+ * The bucket is public-read — that is what makes the report URLs work — so a
+ * fork can read the golden records over plain HTTPS, diff locally, and publish
+ * nothing. It writes the same `.reg/out.json` and `.reg/index.html` that
+ * `reg-suit run` produces, so `visual-gate.mjs` gates both paths identically.
+ * What a fork does not get is the hosted report URL and the pull request
+ * comment, both of which need write access it does not have.
+ *
+ * Usage: node visual-tests/scripts/compare-fork.mjs [expected-key]
+ */
+
+import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
+import { dirname, join, resolve, sep } from 'node:path';
+import { spawn } from 'node:child_process';
+
+const CONCURRENCY = 16;
+const ROOT = join(import.meta.dirname, '..');
+const WORKING_DIR = join(ROOT, '.reg');
+const EXPECTED_DIR = join(WORKING_DIR, 'expected');
+const ACTUAL_DIR = join(WORKING_DIR, 'actual');
+
+// The workflow already exports the key, so read it rather than re-deriving the
+// scheme here; argv stays as a convenience for running this by hand.
+const expectedKey = process.env.REG_EXPECTED_KEY
+  || (process.argv[2] ? `base/${process.argv[2]}` : '');
+const domain = process.env.VISUAL_REPORT_DOMAIN;
+
+// Golden PNGs are served with a 4-hour max-age and the keys are rewritten in
+// place, so a fresh manifest can otherwise be paired with stale cached images.
+const cacheBuster = process.env.GITHUB_RUN_ID || String(Date.now());
+
+/**
+ * Read the comparison tolerances from `regconfig.json` so both comparison paths
+ * apply the same ones. Hard-coding them here would let the fork path drift into
+ * failing on antialiasing noise that a same-repo run tolerates.
+ *
+ * @returns {Promise<string[]>} `reg-cli` flags.
+ */
+async function toleranceFlags() {
+  let config;
+
+  try {
+    config = JSON.parse(await readFile(join(ROOT, 'regconfig.json'), 'utf-8'));
+  } catch (error) {
+    // Comparing at different tolerances than the same-repo path would produce a
+    // verdict nobody can reproduce, so stop rather than silently use defaults.
+    throw new Error(`Could not read regconfig.json for comparison tolerances: ${error.message}`);
+  }
+
+  const core = config.core ?? {};
+  const flags = [];
+
+  if (core.enableAntialias) {
+    flags.push('-A');
+  }
+
+  if (core.thresholdPixel !== undefined) {
+    flags.push('-S', String(core.thresholdPixel));
+  }
+
+  if (core.thresholdRate !== undefined) {
+    flags.push('-T', String(core.thresholdRate));
+  }
+
+  if (core.matchingThreshold !== undefined) {
+    flags.push('-M', String(core.matchingThreshold));
+  }
+
+  return flags;
+}
+
+/**
+ * Resolve a manifest entry inside the expected directory.
+ *
+ * The manifest is remote input, so a `../`-style entry would otherwise write
+ * outside the working directory.
+ *
+ * @param {string} item Path from the manifest.
+ * @returns {string|null} Absolute path, or `null` when it escapes.
+ */
+function safeTarget(item) {
+  const target = resolve(EXPECTED_DIR, item);
+
+  return target.startsWith(EXPECTED_DIR + sep) ? target : null;
+}
+
+if (!expectedKey || !domain) {
+  console.error('Usage: node visual-tests/scripts/compare-fork.mjs [expected-key]');
+  console.error('REG_EXPECTED_KEY (or an argument) and VISUAL_REPORT_DOMAIN must be set.');
+  process.exitCode = 1;
+} else {
+  const goldenUrl = `https://${domain}/${expectedKey}`;
+  let manifestResponse;
+
+  try {
+    manifestResponse = await fetch(`${goldenUrl}/out.json`);
+  } catch (error) {
+    console.error(`Could not reach ${goldenUrl}/out.json: ${error.message}`);
+    process.exitCode = 1;
+  }
+
+  if (manifestResponse && manifestResponse.status === 404) {
+    // Seeding needs write credentials this run does not have, so leave the
+    // baseline to a same-repo build and let the pull request through rather
+    // than blocking an external contributor on missing infrastructure.
+    console.log(`No golden records for "${expectedKey}" at ${goldenUrl}/out.json.`);
+    console.log('Skipping the comparison: a same-repo build has to seed the baseline first.');
+  } else if (manifestResponse && !manifestResponse.ok) {
+    // A 403/429/5xx is not "no baseline yet". Reporting it as one would send
+    // someone debugging a red check off after a baseline that already exists.
+    console.error('Unexpected response fetching the golden records manifest: '
+      + `HTTP ${manifestResponse.status} from ${goldenUrl}/out.json.`);
+    process.exitCode = 1;
+  } else if (manifestResponse) {
+    let manifest;
+
+    try {
+      manifest = await manifestResponse.json();
+    } catch (error) {
+      console.error(
+        `The golden records manifest at ${goldenUrl}/out.json is not valid JSON: ${error.message}`
+      );
+      process.exitCode = 1;
+    }
+
+    const items = Array.isArray(manifest?.actualItems) ? manifest.actualItems : null;
+
+    if (manifest && (items === null || items.length === 0)) {
+      // Treating this as an empty baseline would report every screenshot as new
+      // and block with a verdict indistinguishable from a real regression — on
+      // the one path that gets no pull request comment to explain it.
+      console.error(`The golden records manifest at ${goldenUrl}/out.json lists no screenshots.`);
+      console.error('Refusing to compare against an empty baseline; it is more likely truncated '
+        + 'or malformed than genuinely empty.');
+      process.exitCode = 1;
+    } else if (items) {
+      console.log(`Downloading ${items.length} golden records from ${goldenUrl} …`);
+
+      const queue = [...items];
+      const failures = [];
+
+      const worker = async() => {
+        while (queue.length > 0) {
+          const item = queue.pop();
+          const target = safeTarget(item);
+
+          if (!target) {
+            failures.push(`${item} (path escapes the expected directory)`);
+            continue;
+          }
+
+          let response;
+
+          try {
+            response = await fetch(`${goldenUrl}/actual/${item}?v=${cacheBuster}`);
+          } catch (error) {
+            failures.push(`${item} (${error.message})`);
+            continue;
+          }
+
+          if (!response.ok) {
+            failures.push(`${item} (HTTP ${response.status})`);
+            continue;
+          }
+
+          await mkdir(dirname(target), { recursive: true });
+          await writeFile(target, Buffer.from(await response.arrayBuffer()));
+        }
+      };
+
+      await Promise.all(Array.from({ length: CONCURRENCY }, worker));
+
+      if (failures.length > 0) {
+        console.error(`Could not download ${failures.length} golden records, for example:`);
+        failures.slice(0, 5).forEach(f => console.error(`  ${f}`));
+        process.exitCode = 1;
+      } else {
+        // reg-cli embeds image paths relative to the report, so pointing it at
+        // `../screenshots` would leave every "actual" pane broken in the
+        // uploaded artifact — the only report this path produces. reg-suit
+        // copies into `.reg/actual` for the same reason; match its layout.
+        await cp(join(ROOT, 'screenshots'), ACTUAL_DIR, { recursive: true });
+
+        const flags = await toleranceFlags();
+
+        console.log(`Comparing with tolerances: ${flags.join(' ') || '(none configured)'}`);
+
+        const exitCode = await new Promise((resolve_) => {
+          spawn('npx', [
+            '--no', 'reg-cli',
+            ACTUAL_DIR,
+            EXPECTED_DIR,
+            join(WORKING_DIR, 'diff'),
+            '-R', join(WORKING_DIR, 'index.html'),
+            '-J', join(WORKING_DIR, 'out.json'),
+            '-I', // never fail here; visual-gate.mjs owns the verdict
+            ...flags,
+          ], { cwd: ROOT, stdio: 'inherit', shell: process.platform === 'win32' })
+            .on('close', resolve_);
+        });
+
+        if (exitCode !== 0) {
+          console.error(`reg-cli exited with ${exitCode}.`);
+          process.exitCode = 1;
+        }
+      }
+    }
+  }
+}
diff --git a/visual-tests/scripts/compare.mjs b/visual-tests/scripts/compare.mjs
new file mode 100644
index 0000000000..0b5915a9db
--- /dev/null
+++ b/visual-tests/scripts/compare.mjs
@@ -0,0 +1,61 @@
+/**
+ * Runs the comparison, refusing to overwrite golden records from a local run.
+ *
+ * The deleted `upload.mjs` threw when a reference-branch upload was attempted
+ * outside CI. `reg-suit run` has no equivalent: a developer holding R2
+ * credentials for debugging, with `REG_ACTUAL_KEY` pointing at a `base/` key,
+ * would silently replace the baseline every pull request is compared against.
+ *
+ * Usage: node visual-tests/scripts/compare.mjs
+ */
+
+import { spawn } from 'node:child_process';
+import { join } from 'node:path';
+
+// Nothing else loads it: dotenv is not a dependency and both Playwright configs
+// have their `require('dotenv').config()` commented out. The README tells people
+// to put their R2 credentials here, so honour that rather than silently running
+// with the keys unset.
+const envFile = join(import.meta.dirname, '..', '.env');
+
+try {
+  // Node's own `--env-file` semantics: existing environment wins, quoting and
+  // `export` prefixes handled. Nothing else loads this file -- dotenv is not a
+  // dependency and both Playwright configs have their loader commented out.
+  process.loadEnvFile(envFile);
+} catch {
+  // Absent or unreadable .env is the normal case in CI.
+}
+
+const actualKey = process.env.REG_ACTUAL_KEY ?? '';
+const expectedKey = process.env.REG_EXPECTED_KEY ?? '';
+
+if (!actualKey || !expectedKey) {
+  // reg-suit expands an unset `${REG_ACTUAL_KEY}` to the literal string
+  // "undefined", which slips past the `base/` guard below and publishes the
+  // whole tree to `s3://<bucket>/undefined/`. Fail before spawning instead.
+  console.error('REG_EXPECTED_KEY and REG_ACTUAL_KEY must both be set.');
+  console.error('reg-suit expands an unset key to the literal string "undefined" and publishes there.');
+  process.exitCode = 1;
+} else if (process.env.CI !== 'true' && actualKey.startsWith('base/')) {
+  console.error(`Refusing to publish to "${actualKey}" outside CI.`);
+  console.error('Keys under `base/` are the golden records every pull request is compared against;');
+  console.error('only a CI build of that branch may write them. Use a `local/...` key to experiment.');
+  process.exitCode = 1;
+} else {
+  const exitCode = await new Promise((resolve) => {
+    spawn('npx', ['--no', 'reg-suit', 'run'], {
+      // reg-suit resolves regconfig.json by walking up from cwd to the nearest
+      // package.json. Unpinned, running this file from the repo root finds the
+      // monorepo manifest, loads no config, and compares nothing while exiting 0.
+      cwd: join(import.meta.dirname, '..'),
+      stdio: 'inherit',
+      shell: process.platform === 'win32',
+    }).on('close', resolve);
+  });
+
+  // `close` passes null when the child was killed by a signal (OOM,
+  // cancellation). Treating that as success would report a green comparison
+  // that never finished.
+  process.exitCode = exitCode ?? 1;
+}
diff --git a/visual-tests/scripts/run-tests.mjs b/visual-tests/scripts/run-tests.mjs
index dfb4773f17..125d2be6df 100644
--- a/visual-tests/scripts/run-tests.mjs
+++ b/visual-tests/scripts/run-tests.mjs
@@ -2,7 +2,7 @@
  * This script:
  * - Runs a background `http-server` for each framework example.
  * - Runs Handsontable's visual tests.
- * - Takes screenshots and prepares them for upload to an external service (Argos).
+ * - Takes screenshots and prepares them for comparison against the golden records.
  */
 import path from 'path';
 import execa from 'execa';
@@ -114,7 +114,7 @@ if (isReferenceBranch()) {
     throw new Error(`Directory \`${dirs.screenshots}/${REFERENCE_FRAMEWORK}\` doesn't exist.`);
   }

-  // Argos compares screenshot files of the same name and path,
+  // The comparison matches screenshot files by the same name and path,
   // so we need to make sure the paths are the same
   for (let i = 0; i < WRAPPERS.length; ++i) {
     fse.copySync(
diff --git a/visual-tests/scripts/upload.mjs b/visual-tests/scripts/upload.mjs
deleted file mode 100644
index a11667ab8d..0000000000
--- a/visual-tests/scripts/upload.mjs
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * This script uploads the screenshots package to an external service (Argos).
- */
-import { upload } from '@argos-ci/core';
-import { isReferenceBranch } from './utils/utils.mjs';
-
-console.log('Upload to Argos');
-
-if (isReferenceBranch() && !process.env.CI) {
-  throw new Error('Screenshots from base branch can be uploaded only from Github');
-} else {
-  await upload({ root: './screenshots' });
-}
diff --git a/visual-tests/scripts/visual-gate.mjs b/visual-tests/scripts/visual-gate.mjs
new file mode 100644
index 0000000000..24f0a092e2
--- /dev/null
+++ b/visual-tests/scripts/visual-gate.mjs
@@ -0,0 +1,69 @@
+/**
+ * Turns the visual comparison into a pass/fail verdict and a pull request comment.
+ *
+ * `reg-suit run` exits 0 whatever it finds — it rejects only on notifier and
+ * credential errors — so this script is what actually turns the check red.
+ *
+ * All branching lives in `../lib/visual-gate.mjs`, which is pure and unit-tested;
+ * this wrapper only reads `.reg/out.json`, writes `.reg/comment.md`, and sets the
+ * exit code. The comment is always written so the sticky comment in `visual.yml`
+ * is refreshed rather than left showing a verdict that no longer holds.
+ *
+ * Usage: node visual-tests/scripts/visual-gate.mjs
+ */
+
+import { readFile, writeFile, mkdir } from 'node:fs/promises';
+import { join } from 'node:path';
+import { evaluate } from '../lib/visual-gate.mjs';
+
+const WORKING_DIR = join(import.meta.dirname, '..', '.reg');
+const domain = process.env.VISUAL_REPORT_DOMAIN;
+const actualKey = process.env.REG_ACTUAL_KEY;
+
+let report = null;
+
+try {
+  report = JSON.parse(await readFile(join(WORKING_DIR, 'out.json'), 'utf-8'));
+} catch (error) {
+  // Absent on the bootstrap path, where there is nothing to compare against.
+  console.log(`No comparison result read: ${error.message}`);
+}
+
+// A fork run publishes nothing, so linking `pr-<n>/<sha>/index.html` would send
+// the one audience with no PR comment to a 404. Falling back to the artifact
+// wording is the point of that branch in the evaluator.
+const published = process.env.VISUAL_PUBLISHED !== 'false';
+
+const verdict = evaluate({
+  report,
+  bootstrap: process.env.VISUAL_BOOTSTRAP === 'true',
+  seeded: process.env.VISUAL_SEEDED !== 'false',
+  approved: process.env.VISUAL_APPROVED === 'true',
+  reportUrl: published && domain && actualKey ? `https://${domain}/${actualKey}/index.html` : '',
+  runUrl: process.env.VISUAL_RUN_URL ?? '',
+});
+
+await mkdir(WORKING_DIR, { recursive: true });
+await writeFile(join(WORKING_DIR, 'comment.md'), verdict.comment, 'utf-8');
+
+if (verdict.blocked) {
+  console.error(verdict.summary);
+
+  // Only offer remedies that apply. When no report was produced there is
+  // nothing to review and nothing to approve — the comparison itself failed.
+  if (report) {
+    console.error('');
+    console.error('Open the report linked in the pull request comment, or download the');
+    console.error('`visual-diff-report` artifact from this run. Then either:');
+    console.error('  - push a commit that removes the differences, or');
+    console.error('  - add the `visual-approved` label to accept them as the new baseline.');
+  } else {
+    console.error('');
+    console.error('This is a comparison failure, not a visual difference. Check the');
+    console.error('`Compare against the golden records` step above for the cause.');
+  }
+
+  process.exitCode = 1;
+} else {
+  console.log(verdict.summary);
+}