Latest Releases

These releases come straight from our repositories. Latest releases may not be published yet.

swissup / core

1.14.0 Sep

swissup / firecheckout

1.36.0 Sep
  • 1.36.0

    Report (rev.1)

    Package was generated and uploaded to GitHub on with the following dependencies:

    swissup/module-address-autocomplete:1.8.4

    swissup/module-address-field-manager:1.6.21

    swissup/module-address-validation:1.0.4

    swissup/module-bot-protection:1.0.2

    swissup/module-checkout-cart:1.5.10

    swissup/module-checkout-consumer-rights:1.1.0

    swissup/module-checkout-fields:1.6.17

    swissup/module-checkout-registration:1.2.3

    swissup/module-checkout-success:2.0.12

    swissup/module-customer-field-manager:1.2.11

    swissup/module-delivery-date:1.9.0

    swissup/module-firecheckout:1.36.0

    swissup/module-geoip:1.6.2

    swissup/module-orderattachment:1.5.3

    swissup/module-subscribe-at-checkout:1.3.6

    swissup/module-taxvat:1.4.9

    swissup/module-checkout:1.5.4

    swissup/module-core:1.13.1

    swissup/module-field-manager:1.3.7

    swissup/module-firecheckout-integrations:1.4.10

    Changelog (autogenerated)

    swissup/module-address-autocomplete — 1.8.4 (was 1.8.3)
    • Version 1.8.4 d5c54d

    • Fixed proper city detection for some addresses

      ```
      Not Working: Via Spinazzola, 24, Rovolon, PD, Italia
      Working: Via Spinazzola, 24, Roma
      ```

      https://developers.google.com/maps/documentation/places/web-service/legacy/supported_types:

      ```
      The (cities) type collection instructs the Places service to return results that match locality or administrative_area_level_3.
      ``` 7578e9

    swissup/module-checkout-consumer-rights — 1.1.0 (was 1.0.0)
    • Version 1.1.0 02da05

    • Allow to withdraw order from customer account page 58b399

    • Better naming for emails templates 009a6e

    • Feature: send notification email to customer 816777

    • Add order Grid items variable 3f1522

    • Order items block expects DB order_id b64f33

    • Fixed not working custom template 6f1f41

    • Version 1.0.2 0c6a57

    • Add translation to 'Read your legal guarantee rights' phrase af71dd

    • Version 1.0.1 61551f

    • Added missing Breeze integration e2467d

    swissup/module-checkout-fields — 1.6.17 (was 1.6.16)
    • Version 1.6.17 61f0d4

    • Fixed double-escape in email template c41827

    • Mage-OS 3.5.0 compatibility: moved block used in email from Adminhtml f636e5

    swissup/module-checkout-success — 2.0.12 (was 2.0.11)
    swissup/module-delivery-date — 1.9.0 (was 1.8.1)
    • Version 1.9.0 1ed62b

    • Delivery date on PaypalExpress checkout review page #17 d2f2eb

    swissup/module-firecheckout — 1.36.0 (was 1.35.0)
    • Version 1.36.0 c6b511

    • Fix readonly env: deploy jsbuild during static content deployment f29be0

    • Fix "Maximum call stack size exceeded" in jsbuild

      mage/requirejs/static.js patches the global require.load and parks the
      previous function on require._load. That patch is not idempotent, and its
      modules are reachable two ways on a FireCheckout page: as the plain
      <script> we inject after require.js, and via the bundles entry below.
      requirejs keeps `defined` per context, so with a second context on the
      page (e.g. "$" next to "_") the jsbuild factory runs twice: require._load
      then holds the first wrapper, whose own fallback is itself, and
      require.load recurses forever. Symptoms are the RangeError plus
      "Load timeout for modules: mage/requirejs/text".

      Pin require._load to the real loader before static.js can install the
      wrapper, so any number of jsbuild evaluations stay harmless.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bed9ef

    • Replace curl-powered deployment with Asset\Publisher
      This commit should improve speed of jsbuild in development mode a13472

    • Replace curl-powered deployment with Asset\Publisher
      This commit should improve speed of jsbuild in development mode 46c4d4

    swissup/module-core — 1.13.1 (was 1.12.27)
    • Version 1.13.1 5b7b24

    • Fixed invisible update instructions when Mollie Payments is installed 72aab6

    • Version 1.13.0 f34c7b

    • Fill the links after ajax refresh b6a316

    • Show loading state on the "Check for Updates" button 1ab16e

    • Use optimized repo to fetch latest releases 1aa8fb

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2

    • Mark core section with badge when outdated modules found 9a3764

    • Outdated modules badge and Modules config group (#25) 7df026

    • MCS fix 3b9121

    • Update instructions 0e686f

    • Read links info from marketplace meta 21e95b

    • Keep the last check time when a refresh fails

      Removing the stored time was what made the next load re-check the feed.
      An unreachable feed left no time at all: the config page had nothing to
      display, and every page load kept re-checking until one request made it
      through.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9

    • Post the modules refresh to an action of its own

      The refresh drops the version check throttle and re-reads the remote
      source, and a get carries no form key validation - Magento checks it for
      post requests only, and the url secret key is optional. Reading the list
      stays a get, so the two live in separate actions now.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9

    • Keep the module links out of the response

      The links cell is rendered by the server and never touched by js.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db

    • Send only the module metadata the browser renders

      The merged Loader record carries the local filesystem path and the
      download, license and purchase-code fields of the remote feed. None of
      them is rendered by the config page, so keep them on the server.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425

    • Too much red 2e307e

    • Use the same `outdated` class name everywhere

      The version span was already renamed, while the row and the js still said
      `_outdated` - a row that stopped being outdated kept its red version pill.

      Since the row now wears the very same class, the pill rule had to be
      scoped to the version cell, or the whole row would turn red.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086

    • Report the check time with the modules, and keep outdated rows first

      Two things the clients were guessing at:

      The `Check for Updates` handler stamped the label with the current time,
      but the request may have re-used the stored packages after a failed feed
      request - `just now` was not always true. And an automatic load did check
      the remote source, yet nothing updated the label at all. The endpoint now
      returns `['items' => ..., 'last_check' => ...]`, and the label follows the
      time the server reports for every load.

      The table was re-rendered cell by cell, so a module that became outdated
      kept its old position while the counter above it grew. Mark the rows and
      move the outdated ones back to the top - both groups stay sorted by name.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271

    • Keep the last check time once the check is due

      The timestamp was stored with a one hour lifetime and the throttle was
      `is the entry still there?`. Handy, but it also meant the config page
      could no longer tell how old the data was as soon as the check was due -
      exactly when that is worth showing next to `Check for Updates`.

      Store it without a lifetime and compare the age instead. Same hour
      boundary, and the label now reads `Last checked 2 h ago` instead of
      disappearing.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a

    • Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66

    • Hovered color for outdated badge 0a0016

    • Show outdated modules count in config and add Modules group

      The admin had no hint that installed Swissup modules have updates
      available - one had to open Module Manager to find out.

      Add a counter badge to the Swissup tab of the config navigation, and a
      Modules group in Swissup > Core listing every installed module with its
      installed and latest version, outdated ones first.

      Neither of them may slow down the config page, so both render from the
      data stored in var/swissup/core (Loader::setOfflineMode()), and the
      versions, the counters and the badge itself are then refreshed by a
      single ajax request shared by all the consumers. `Check for Updates`
      re-runs that request with refresh=1, which drops the version check
      throttle and re-reads the remote source.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6

    • Move core config to the top as it will display outdated modules 115f37

    • Added ability to immidiately re-check remote source for updates `-r` 357d33

    • Keep the remote packages data in files instead of the cache (#24) cbeecc

    • Trim the comments down to what is not in the code

      The two lock docblocks carried the same paragraph, and save() explained
      the reasoning that belongs in the commit that introduced it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9

    • Serialize the writes with a lock taken before the entry is opened

      openFile() truncates whatever it opens, so a lock taken afterwards keeps
      the writes apart but not the truncations. Two saves could interleave into
      one file: the second one truncates, the first one writes a whole entry,
      and the second one then overwrites its beginning - leaving the head of
      one entry on the tail of another. The length line does not catch that
      when both entries encode to the same size, as fixed width values like a
      version hash always do.

      Take the lock before opening, on a file of its own - the entry cannot be
      opened before the lock is held, and the file lock() uses is likely held
      by the caller already, which flock would deadlock against.

      Failing to open a lock file is no longer reported as a lock held by
      somebody else. A lock left behind by another user cannot be opened at
      all, and reporting that as contention kept the caller away from the
      entry for good - silently serving a list that could never be refreshed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28

    • Don't download the list that another process has just stored

      The version is fetched before the download lock is taken, so a process
      that was still asking for it when the lock was released would go on to
      download the very list the lock holder had just stored. Look at the
      storage once more before downloading.

      Also correct what the save() lock claims to do: it serializes the
      writes, but the truncation happens in openFile(), outside of it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0

    • Reject an entry whose expiration time is not a number

      A corrupt header such as `expires:invalid` was cast to zero, which reads
      as "never expires" - so an entry that should have gone stale could be
      served indefinitely instead of being treated as missing. Validate the
      field the same way the length line is validated.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16

    • Keep the remote packages data in files instead of the cache

      The packages list was kept in the Magento cache, so every cache flush
      threw it away and the next admin request had to download it again. It is
      now stored under var/swissup/core, where it outlives the flush.

      FileStorage writes the expiration time and the data length ahead of the
      contents, so an entry knows itself when it goes stale, and a reader can
      tell a half-written entry from a complete one - openFile() truncates the
      file before the write lock is taken, so a reader can catch the entry
      mid-save. An incomplete entry is reported as missing and refetched,
      rather than served as if it were whole.

      Remote revalidates against the version hash from packages.json, at most
      once an hour, and only downloads the full list when that hash moves. The
      download itself is guarded by a non-blocking lock, so several admins
      hitting the page at once produce one request instead of one each - the
      others serve the stored copy, or wait for the download when there is
      nothing stored yet. fetch() now reports connection errors and 4xx/5xx
      responses by returning an empty body, letting the caller fall back to
      the stored data instead of decoding a failure page.

      The admin notification feed keeps its last-update timestamp in the same
      storage, replacing its own copy of the file handling.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd

    • Small fixes f7eef6

    • Single source of truth for is_outdated, is_installed 37ad01

    • Fixed `Undefined array key "type"` warning b2a9c1

    • Don't show non installed modules when asking for outdated bf5fb1

    swissup/module-firecheckout-integrations — 1.4.10 (was 1.4.8)

swissup / delivery-date

1.9.0 Sep
  • 1.9.0

    Report (rev.1)

    Package was generated and uploaded to GitHub on with the following dependencies:

    swissup/module-delivery-date:1.9.0

    swissup/module-checkout:1.5.4

    swissup/module-core:1.13.1

    Changelog (autogenerated)

    swissup/module-delivery-date — 1.9.0 (was 1.7.11)
    • Version 1.9.0 1ed62b

    • Delivery date on PaypalExpress checkout review page #17 d2f2eb

    • Version 1.8.1 7f2f72

    • Fixed missing holiday when offset is empty string 90c827

    • Remove private repo from readme, because users add it too 5caa73

    • Version 1.8.0 47e33b

    • Improve naming 413c70

    • Added option that allows to exclude weekends and holidays from offset
      Closes #18 850b50

    • Remove old mixin (Magento < 2.2.2) 21dca6

    • Version 1.7.12 8a52a1

    • Fixed unescaped output, closes #16 d6c289

    swissup/module-checkout — 1.5.4 (was 1.5.2)
    • Version 1.5.4 e06d43

    • checkout-consumer-rights icon 027810

    • Version 1.5.3 d22b9f

    • Breeze: update integration 880a83

    • Breeze: exclude requirejs-config files 019ad4

    • Revert "Icon for security module"

      This reverts commit 088ee1bf78be5f00695d2a469937f090d865688f. c1f27c

    • Icon for security module 088ee1

    swissup/module-core — 1.13.1 (was 1.12.22)
    • Version 1.13.1 5b7b24

    • Fixed invisible update instructions when Mollie Payments is installed 72aab6

    • Version 1.13.0 f34c7b

    • Fill the links after ajax refresh b6a316

    • Show loading state on the "Check for Updates" button 1ab16e

    • Use optimized repo to fetch latest releases 1aa8fb

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2

    • Mark core section with badge when outdated modules found 9a3764

    • Outdated modules badge and Modules config group (#25) 7df026

    • MCS fix 3b9121

    • Update instructions 0e686f

    • Read links info from marketplace meta 21e95b

    • Keep the last check time when a refresh fails

      Removing the stored time was what made the next load re-check the feed.
      An unreachable feed left no time at all: the config page had nothing to
      display, and every page load kept re-checking until one request made it
      through.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9

    • Post the modules refresh to an action of its own

      The refresh drops the version check throttle and re-reads the remote
      source, and a get carries no form key validation - Magento checks it for
      post requests only, and the url secret key is optional. Reading the list
      stays a get, so the two live in separate actions now.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9

    • Keep the module links out of the response

      The links cell is rendered by the server and never touched by js.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db

    • Send only the module metadata the browser renders

      The merged Loader record carries the local filesystem path and the
      download, license and purchase-code fields of the remote feed. None of
      them is rendered by the config page, so keep them on the server.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425

    • Too much red 2e307e

    • Use the same `outdated` class name everywhere

      The version span was already renamed, while the row and the js still said
      `_outdated` - a row that stopped being outdated kept its red version pill.

      Since the row now wears the very same class, the pill rule had to be
      scoped to the version cell, or the whole row would turn red.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086

    • Report the check time with the modules, and keep outdated rows first

      Two things the clients were guessing at:

      The `Check for Updates` handler stamped the label with the current time,
      but the request may have re-used the stored packages after a failed feed
      request - `just now` was not always true. And an automatic load did check
      the remote source, yet nothing updated the label at all. The endpoint now
      returns `['items' => ..., 'last_check' => ...]`, and the label follows the
      time the server reports for every load.

      The table was re-rendered cell by cell, so a module that became outdated
      kept its old position while the counter above it grew. Mark the rows and
      move the outdated ones back to the top - both groups stay sorted by name.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271

    • Keep the last check time once the check is due

      The timestamp was stored with a one hour lifetime and the throttle was
      `is the entry still there?`. Handy, but it also meant the config page
      could no longer tell how old the data was as soon as the check was due -
      exactly when that is worth showing next to `Check for Updates`.

      Store it without a lifetime and compare the age instead. Same hour
      boundary, and the label now reads `Last checked 2 h ago` instead of
      disappearing.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a

    • Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66

    • Hovered color for outdated badge 0a0016

    • Show outdated modules count in config and add Modules group

      The admin had no hint that installed Swissup modules have updates
      available - one had to open Module Manager to find out.

      Add a counter badge to the Swissup tab of the config navigation, and a
      Modules group in Swissup > Core listing every installed module with its
      installed and latest version, outdated ones first.

      Neither of them may slow down the config page, so both render from the
      data stored in var/swissup/core (Loader::setOfflineMode()), and the
      versions, the counters and the badge itself are then refreshed by a
      single ajax request shared by all the consumers. `Check for Updates`
      re-runs that request with refresh=1, which drops the version check
      throttle and re-reads the remote source.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6

    • Move core config to the top as it will display outdated modules 115f37

    • Added ability to immidiately re-check remote source for updates `-r` 357d33

    • Keep the remote packages data in files instead of the cache (#24) cbeecc

    • Trim the comments down to what is not in the code

      The two lock docblocks carried the same paragraph, and save() explained
      the reasoning that belongs in the commit that introduced it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9

    • Serialize the writes with a lock taken before the entry is opened

      openFile() truncates whatever it opens, so a lock taken afterwards keeps
      the writes apart but not the truncations. Two saves could interleave into
      one file: the second one truncates, the first one writes a whole entry,
      and the second one then overwrites its beginning - leaving the head of
      one entry on the tail of another. The length line does not catch that
      when both entries encode to the same size, as fixed width values like a
      version hash always do.

      Take the lock before opening, on a file of its own - the entry cannot be
      opened before the lock is held, and the file lock() uses is likely held
      by the caller already, which flock would deadlock against.

      Failing to open a lock file is no longer reported as a lock held by
      somebody else. A lock left behind by another user cannot be opened at
      all, and reporting that as contention kept the caller away from the
      entry for good - silently serving a list that could never be refreshed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28

    • Don't download the list that another process has just stored

      The version is fetched before the download lock is taken, so a process
      that was still asking for it when the lock was released would go on to
      download the very list the lock holder had just stored. Look at the
      storage once more before downloading.

      Also correct what the save() lock claims to do: it serializes the
      writes, but the truncation happens in openFile(), outside of it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0

    • Reject an entry whose expiration time is not a number

      A corrupt header such as `expires:invalid` was cast to zero, which reads
      as "never expires" - so an entry that should have gone stale could be
      served indefinitely instead of being treated as missing. Validate the
      field the same way the length line is validated.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16

    • Keep the remote packages data in files instead of the cache

      The packages list was kept in the Magento cache, so every cache flush
      threw it away and the next admin request had to download it again. It is
      now stored under var/swissup/core, where it outlives the flush.

      FileStorage writes the expiration time and the data length ahead of the
      contents, so an entry knows itself when it goes stale, and a reader can
      tell a half-written entry from a complete one - openFile() truncates the
      file before the write lock is taken, so a reader can catch the entry
      mid-save. An incomplete entry is reported as missing and refetched,
      rather than served as if it were whole.

      Remote revalidates against the version hash from packages.json, at most
      once an hour, and only downloads the full list when that hash moves. The
      download itself is guarded by a non-blocking lock, so several admins
      hitting the page at once produce one request instead of one each - the
      others serve the stored copy, or wait for the download when there is
      nothing stored yet. fetch() now reports connection errors and 4xx/5xx
      responses by returning an empty body, letting the caller fall back to
      the stored data instead of decoding a failure page.

      The admin notification feed keeps its last-update timestamp in the same
      storage, replacing its own copy of the file handling.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd

    • Small fixes f7eef6

    • Single source of truth for is_outdated, is_installed 37ad01

    • Fixed `Undefined array key "type"` warning b2a9c1

    • Don't show non installed modules when asking for outdated bf5fb1

    • Version 1.12.27 ac70c7

    • Magento 2.4.9 fix (CLI execute command) d224be

    • Prevent news retrieval after each cache flush ac96b7

    • Version 1.12.26 74dff6

    • Improve the libxml fix to include `data-post` and `data-config` attrs 3fee5e

    • Version 1.12.25 2186f6

    • Fixed broken markup when using newer libxml version (2.15.1) (#23) 61ceab

    • Use same quotes c33f29

    • Update Plugin/FixHtmlMarkup.php

      Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> e056e2

    • Fixed broken markup when using newer libxml version (2.15.1) 7ff0db

    • Version 1.12.24 4ac8cb

    • Fixed unescaped output, closes #22 67c9f1

    • Version 1.12.23 13d1c7

    • Fixed integrity constraint violation: Duplicate entry for key primary b9d95d

swissup / checkout-fields

1.6.17 Sep

breezefront / breeze-content-builder

1.2.2 Sep

breezefront / breeze-blank

3.2.2 Sep

swissup / argento-breeze

3.2.0 Aug
  • 3.2.0

    Report (rev.1)

    Package was generated and uploaded to GitHub on with the following dependencies:

    swissup/module-ajaxpro:1.7.39

    swissup/module-ajaxsearch:1.14.11

    swissup/module-amp:1.6.0

    swissup/module-askit:1.14.27

    swissup/module-attributepages:1.8.6

    swissup/module-breeze-ai:1.4.0

    swissup/module-breeze-content-builder-migration:1.0.1

    swissup/module-breeze-layout-builder:1.2.0

    swissup/module-chat-gpt-assistant:1.1.4

    swissup/module-compare:1.2.8

    swissup/module-easy-slide:1.8.18

    swissup/module-easybanner:1.9.24

    swissup/module-easycatalogimg:1.5.15

    swissup/module-easytabs:1.13.4

    swissup/module-fblike:1.3.9

    swissup/module-featured-attributes:1.1.9

    swissup/module-gdpr:1.9.2

    swissup/module-gdpr-askit:1.1.3

    swissup/module-gdpr-reviewreminder:1.0.0

    swissup/module-gdpr-testimonials:1.1.1

    swissup/module-highlight:1.11.9

    swissup/module-hover-gallery:1.3.15

    swissup/module-hreflang:1.6.12

    swissup/module-hreflang-import-export:1.0.1

    swissup/module-navigationpro:1.19.3

    swissup/module-pagespeed:1.19.0

    swissup/module-pro-labels:1.10.1

    swissup/module-pro-labels-configurable-product:1.0.9

    swissup/module-quantity-switcher:1.2.4

    swissup/module-reviewreminder:1.1.19

    swissup/module-rich-snippets:1.8.5

    swissup/module-seo-canonical:1.11.10

    swissup/module-seo-core:1.3.10

    swissup/module-seo-cross-links:1.2.0

    swissup/module-seo-html-sitemap:1.1.23

    swissup/module-seo-images:1.3.21

    swissup/module-seo-pager:1.4.0

    swissup/module-seo-templates:1.7.12

    swissup/module-seo-urls:1.5.55

    swissup/module-seo-xml-sitemap:1.2.0

    swissup/module-sold-together:1.11.4

    swissup/module-sold-together-email:1.0.0

    swissup/module-sold-together-import-export:1.0.2

    swissup/module-testimonials:1.4.2

    swissup/module-theme-editor-argentobreeze-business:1.0.2

    swissup/module-theme-editor-argentobreeze-chic:1.0.3

    swissup/module-theme-editor-argentobreeze-force:1.1.1

    swissup/module-theme-editor-argentobreeze-stripes:1.1.2

    swissup/theme-frontend-argentobreeze-blank:3.1.0

    swissup/theme-frontend-argentobreeze-business:3.0.1

    swissup/theme-frontend-argentobreeze-chic:3.0.0

    swissup/theme-frontend-argentobreeze-force:3.0.1

    swissup/theme-frontend-argentobreeze-stripes:3.1.3

    swissup/module-core:1.13.1

    swissup/module-suggestpage:1.2.14

    swissup/module-breeze-content-builder:1.2.0

    swissup/module-image-optimizer:1.0.12

    swissup/module-theme-editor:1.12.0

    Changelog (autogenerated)

    swissup/module-ajaxpro — 1.7.39 (was 1.7.36)
    • Version 1.7.39 650093

    • fix: guard productInfoResolver call in ajaxSubmit (#57 follow-up)

      Found while verifying the YQE-14065 fix on the customer's dev store:
      their theme (Webspaceconfig/Webspace) ships its own outdated copy of
      Magento_Catalog/js/catalog-add-to-cart.js that predates Magento core
      adding productInfoResolver support. Our widget extends that file as
      its base ($.mage.catalogAddToCart) and called
      self.options.productInfoResolver(form) unconditionally, so on that
      theme every ajax add-to-cart submit threw
      "productInfoResolver is not a function" before the request was even
      sent — configurable/size options couldn't be added to cart from
      category listing pages.

      Root cause is the theme's stale file, not this module — reported
      separately. This guard just keeps ajaxpro working regardless of
      whether the base widget a theme provides happens to support this
      option.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 320f71

    • Version 1.7.38 3b2284

    • Merge pull request #58 from swissup/fix/gift-card-options-data-post-57

      fix: stop stripping data-post so third-party widgets can still read it (#57) eb4490

    • fix: pass bind:false when reusing ajaxcian-data-post as an _ajax() helper (#57)

      Copilot review on #58: _confirm()'s replacement click handler runs on
      every confirmed compare remove/clear click and constructs
      AjaxproAjaxcianDataPost({}, element) to reuse its _ajax() method. With
      the default options ({} means options.bind stays true), _create()
      also calls _bind(), adding a brand new click listener on the same
      element — and since data-post is no longer stripped (YQE-14065), that
      _bind() call succeeds every time instead of bailing out on a missing
      attribute like it used to.

      None of those listeners are ever removed, so click N fires N ajax
      requests: click 1 adds listener #1 (plus its own direct call), click 2
      runs the _confirm handler directly again AND listener #1, click 3 runs
      the _confirm handler, listener #1, and listener #2, etc.

      Fixed by passing { bind: false } — this call only ever wanted _ajax()
      as a helper method. Verified by reverting locally and re-running the
      new test: 2nd click produces 3 ajax calls instead of 2 without the fix.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> c5bd0f

    • fix: stop propagation in compare.js rebind, close double-submit (#57)

      Copilot review on #58: Swissup_Ajaxpro/js/compare's _confirm() rebinds
      the click handler on compare remove/clear-all links on every
      compare-products section update:

      $(selector).off('click').on('click', function (e) { ... });

      .off('click') strips ANY click handler already on the element,
      including ajaxcian-data-post's own (which stopPropagation()s). The
      replacement handler here called _ajax() directly without stopping
      propagation of its own. Since ajaxcian-data-post no longer strips
      data-post, a click after this rebind reached both this handler's ajax
      call and the native document-delegated mage.dataPost handler — a real
      double submit for compare remove/clear links specifically. Previously
      this was masked because data-post was already gone by the time any
      rebind happened.

      Added e.stopPropagation() to this handler too, plus a regression test
      that reproduces the exact rebind sequence and asserts exactly one ajax
      call / zero native-handler calls (and fails without the fix, verified
      locally). Needed two additions to the jest $.widget shim along the
      way: this._super() support (compare.js's _create calls it) and calling
      the widget constructor without `new` (compare.js does
      AjaxproAjaxcianDataPost({}, element) directly, as real jQuery UI
      widget constructors support).

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 727bd4

    • fix: bind click handler synchronously, drop startup delay (#57)

      Copilot review on #58: the widget still delayed binding its own click
      handler by 500ms. That delay used to be harmless because the old
      buggy code removed data-post synchronously in _bind(), so the native
      mage.dataPost document-delegated handler couldn't match during the
      window either. Now that data-post stays on the element permanently,
      a click within that window reached mage.dataPost alone and triggered
      a full page navigation instead of Ajaxpro's ajax request.

      No commit in this file's history documents a reason for the delay.
      Binding synchronously closes the window; our handler is on the
      element itself so it still runs (and stopPropagation()s) before the
      event can reach the document-level native handler regardless of when
      it was bound, as long as it's bound before the click happens.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> cc70c1

    • fix: clone cached data-post before mutating it (#57)

      Copilot review on #58: element.data('post') returns jQuery's cached
      object for the attribute (same reference on every call). _ajax() was
      mutating that object's .data (adding form_key) in place, so a later
      $(el).data('post') read — by this widget on a second click, or by a
      third party like Mageplaza_GiftCard/js/view/information.js — would see
      the leaked form_key instead of the original payload.

      Also documents the stopPropagation() trade-off raised in the same
      review: it can't be scoped to only the native mage.dataPost handler
      (jQuery event namespaces don't affect propagation, and mage/dataPost.js
      is a core file we don't patch) — no other ancestor-delegated click
      listener touches these links today, so accepted as-is.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 75a2b4

    • fix: stop stripping data-post so third-party widgets can still read it (#57)

      ajaxcian-data-post.js renamed data-post to data-post-ajax and removed the
      original attribute when hijacking a link for ajax submit. Any other script
      reading $(el).data('post') from the same link after that point (e.g.
      Mageplaza_GiftCard/js/view/information.js, on the same .tocompare /
      .towishlist links used to build Gift Card add-to-wishlist/compare payloads)
      got undefined and threw, aborting its own init before it could render the
      Gift Card options form.

      Keep data-post intact and use stopPropagation() instead to stop the native
      mage.dataPost document-delegated click handler from double-submitting.

      Verified live on the customer's dev store: Gift Card options render
      correctly with the patch, no duplicate ajax request on click.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 508331

    • Breeze: added missing imports 115dd9

    • Version 1.7.37 264578

    • fix: quick view popup silenced by the 1.7.35 gating guard

      is-ajaxpro-request's "trust a section load only after a recent ajaxpro
      POST" guard (048361e, 1.7.35) only recognized add-to-cart as a plain
      GET customer/section/load preceded by a POST. Quick view has no such
      POST — it fetches ajaxpro-product directly — so ModalManager never
      opened the popup: the request succeeded, the section arrived, nothing
      rendered, no error.

      Have quick-view declare the request explicitly via the new
      isAjaxproRequest.mark() instead of relying on inference.

      Reported on 420shop.nl (customer-facing, all listing pages).

      Closes #57 30e663

    • fix: ajaxpro-cart/reinit/product section render must not 400 section/load

      Magento\Customer\Controller\Section\Load turns any exception thrown by
      a section source into an HTTP 400 for the whole request. Cart::getSectionData()
      renders the full cart layout server-side even for an empty/absent quote,
      and on some environments (Magento 2.4.9 + Breeze, per report) that render
      throws, aborting customer/section/load for every guest with an empty cart.

      Wrap the render logic in Cart, Init and Product CustomerData classes in
      try/catch: on failure, log via the newly injected Psr\Log\LoggerInterface
      and return [] for that section instead of letting the exception bubble up.
      Core sections (cart, customer, ...) keep loading either way.

      Closes #56 5f061c

    • docs: add manual QA scenarios ba69b1

    swissup/module-ajaxsearch — 1.14.11 (was 1.14.10)
    • Version 1.14.11 0db900

    • Fixed layout shift when result list is long 8715cc

    • Fixed overflowed content in Firefox (Apollo theme) de660b

    • Merge pull request #57 from swissup/phpunit12-test-api-56

      Migrate Test/Unit off removed PHPUnit APIs (PHPUnit 12 / PHP 8.5) 095093

    • test: migrate removed test APIs for PHPUnit 12 / PHP 8.5

      The CI runner targets PHP 8.5, which requires PHPUnit 12.x. Migrate the
      unit tests off APIs removed in PHPUnit 10-12:

      - setMethods() (removed v10) -> onlyMethods(); all mocked methods verified
      to exist on the target class via reflection. Dropped dead duplicate mock
      builders (Magento CollectionFactory immediately overwritten by the
      Swissup one).
      - getMockForAbstractClass() (removed v12) -> createMock().
      - will($this->returnValue()/returnSelf()/onConsecutiveCalls()) (removed
      v12) -> willReturn()/willReturnSelf()/willReturnOnConsecutiveCalls().
      - Non-static data providers (hard error v12) -> public static function.

      Also fixed two removed APIs not listed in the issue but caught while
      running the suite:

      - $this->at() (removed v10) in DataTest and ProductTest -> willReturnMap()
      / atLeastOnce() / consecutive-call stubs.

      InitTest was stale independently of PHPUnit: it exercised a removed
      getClassNames() method and the old constructor/getSettings signature.
      Realigned it to the current Init block and dropped the redundant
      getClassNames test (already covered by Helper\DataTest).

      Verified: 41 tests, 99 assertions, 0 errors, 0 deprecations on
      PHPUnit 10.5 / PHP 8.3 (`bin/test`).

      Refs swissup/module-ajaxsearch#56

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 20854c

    • test: add module-local PHPUnit runner

      Add bin/test and phpunit.xml.dist so the unit suite can be run in
      isolation via `bin/test` (runs vendor/bin/phpunit inside the container),
      mirroring the setup already present in module-askit. .gitignore keeps the
      PHPUnit result cache out of the repo.

      Refs swissup/module-ajaxsearch#56

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> a4e899

    swissup/module-askit — 1.14.27 (was 1.14.24)
    • Version 1.14.27 a71b71

    • Fixed answer(s) Dutch translation d2124a

    • Version 1.14.26 8374b0

    • Merge pull request #74 from swissup/fix/issue-73-i18n-dictionary

      Regenerate i18n dictionary, fix uncollectable and stale phrases ef2944

    • Regenerate i18n dictionary, fix uncollectable and stale phrases

      `en_US.csv` had drifted well behind the source: 106 rows against the 196
      phrases `i18n:collect-phrases` finds in the module. Regenerated it and
      brought every locale file back in line with it.

      Source fixes that had to come first:

      - `listing.phtml` built the answer counter label as
      `__($collection->getSize() == 1 ? 'answer' : 'answers')`. The phrase
      collector cannot resolve a ternary inside `__()`, so it emitted the
      literal `answer' 'answers` as a key — which every locale file then
      faithfully translated, while the real `answer` and `answers` strings
      stayed untranslatable. Moved the ternary outside the `__()` calls.
      - `"id" is incorect` -> `"id" is incorrect` (CreateVote.php).
      - `MassAssign.php` used `Assign to...` where the UI component label says
      `Assign to ...`, producing two dictionary keys for one label. Unified
      on `Assign to ...`.

      Dictionary changes:

      - `en_US.csv` regenerated from `i18n:collect-phrases`, 106 -> 196 rows.
      This also drops the stray quoting on `It is helpful` and `Not much
      help`, whose values were ` "It is helpful"` and ` "Not much help"` —
      rendered on the frontend complete with the quotes.
      - Removed 13 dead keys from every locale (`Hint`, `Askit Items`,
      `New Askit Question`, `Answer Information`, `You saved.`, the
      `${ $.$data.text }` UI-component leftovers, the misspelled
      `We can't find a answer to delete.`, and the mangled
      `answer' 'answers`).
      - `fa_IR.csv`: removed a duplicate `Not much help` row whose value was
      double-quoted, and folded the stray ` answer` key into `answer`.
      - Normalised the leading space on the `asks` / `about` / `answer` /
      `answers` values — the templates already prepend it.
      - All files rewritten fully quoted.

      Every locale now parses to exactly two columns per row, carries no
      duplicate keys, and contains no key absent from `en_US.csv`.

      Translating the newly exposed phrases is left to the translators;
      coverage per locale is currently 44-49%.

      Refs #73

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0c1b32

    • Merge pull request #72 from swissup/fix/issue-71-locale-csv

      Fix malformed "Answer" row in locale files, add cs/sk/hu/ro 8b3f84

    • Fix malformed "Answer" row in locale files, add cs/sk/hu/ro

      The "Answer" entry had an unclosed quote in 18 of 19 locale files:

      "Answer,Answer

      fgetcsv merged that line with the next one, so both "Answer" and
      "Entity Type" were lost in every affected locale. Closing the quotes
      restores them.

      Also:
      - add cs_CZ, sk_SK, hu_HU and ro_RO translations
      - add the missing "Plain text only. HTML is not allowed.",
      "Submit Question" and "Name" keys to en_US, de_DE, it_IT and nl_NL

      Translations contributed by @dimdimich in #71. Over-escaped quotes
      ("""" instead of "") in the submitted hu_HU and ro_RO files were
      corrected before merging. fa_IR was already well-formed and is
      left untouched.

      Fixes #71
      Closes #70

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0381cd

    • Version 1.14.25 d0112b

    • Merge pull request #69 from swissup/fix/issue-68-null-array-offset

      Fix null array offset deprecation on new product form (#68) 1a7529

    • Fix null array offset deprecation on new product form

      modifyData() wrote $data[$productId] where $productId is null for a
      new (unsaved) product, triggering on PHP 8.5:

      Deprecated Functionality: Using null as an array offset is
      deprecated, use an empty string instead

      Guard against a null product id and return $data unchanged, mirroring
      the existing guard in modifyMeta(). current_product_id is meaningless
      for a product that does not exist yet, so skipping the write is safe;
      editing an existing product is unaffected.

      Fixes #68

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 0fd6d0

    swissup/module-breeze-ai — 1.4.0 (was 1.0.7)
    • chore(release): 1.4.0

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0bcf86

    • Merge pull request #89 from breezefront/claude/relaxed-shirley-d523bc

      feat(backup): restore values overwritten by an AI run (#8) 1593f6

    • test(backup): cover the selection path and grid columns; honest run date (#8) 551936

    • Merge pull request #90 from breezefront/fix/composer-constraints

      fix(composer): tighten dependency constraints (breezefront/breeze-ai#1) 976098

    • fix(openai): stop advising an upgrade PHP 8.1 cannot install

      The TypeError branch told the user to update openai-php/client to ^0.19.
      Only 0.10.x reaches it, and 0.10.x is also the only branch PHP 8.1 can
      install: 0.11 and up require PHP 8.2. On the stores that actually hit
      this, the advice was impossible to follow.

      Name the PHP requirement instead of a client version.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e3f75f

    • fix(composer): tighten dependency constraints (breezefront/breeze-ai#1)

      - php: "^7.4|^8.0|^8.1|^8.2" collapsed to ">=7.4 <9" anyway, since ^8.0
      already covers 8.1-8.4. It also claimed support for PHP 7.4/8.0, which
      cannot work: openai-php/client 0.10.x requires PHP ^8.1 and 0.19.x
      requires ^8.2, so no version resolves below 8.1. Now "^8.1", matching
      swissup/module-breeze.
      - magento/framework: "*" guarded nothing. Dropped, as in module-breeze
      and module-breeze-integrations.

      swissup/module-core stays "*" — it is pulled in transitively and no
      specific core version is required.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b725ac

    • fix(backup): declare restore isolation, strict capture, scope-true wording (#8) ad050c

    • fix(backup): re-check selections, capture and write together (#8) d54dbe

    • fix(backup): row action bypasses the mass-action filter, keep uncaptured video columns (#8)

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12ba05

    • fix(backup): keep restores retriable, recorded and correctly scoped (#8)

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0fb862

    • fix(backup): one capture per field, per-commit counts, gated mass action (#8)

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7ed888

    • feat(backup): restore values overwritten by an AI run (#8)

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3ec08b

    • Merge pull request #88 from breezefront/claude/beautiful-liskov-50c01d

      feat(usage): log tokens, cost and duration per AI call (#86) 6aba08

    • fix(usage): say when the ledger stops recording, distrust stored rates (#86) 784abb

    • fix(usage): escape separators so a value cannot forge a field (#86) 085672

    • fix(usage): price the model that answered, keep one line per call (#86) 4ac0c0

    • fix(usage): count reasoning tokens, keep sub-cent charges (#86) 7549fb

    • fix(usage): never infer a rate the ledger cannot stand behind (#86) a856a8

    • test(usage): cover the log wiring, not just the writer (#86) 0117c1

    • feat(usage): log tokens, cost and duration per AI call (#86) 128d10

    • Version 1.3.0 050f99

    • Merge pull request #83 from breezefront/fix/ai-buttons-overlap-name-82

      fix(admin): stop AI buttons overlapping long field values (#82) 3348b6

    • fix(admin): lay AI actions beside field, not over it (#82) 3eb55f

    • fix(admin): stop AI buttons overlapping long field values (#82) 1a4854

    • Merge pull request #81 from breezefront/feat/editor-chat-panel

      feat(content-builder): chat panel in the editor for building and refining a page (#64) 0932fb

    • fix(content-builder): give the message textarea an accessible name b48efb

    • fix(content-builder): ground every request in the live working copy

      pageContext() only supplies the page's title and id, so a first chat
      message had no representation of the page's actual content, and manual
      edits made between AI replies were silently overwritten by the last
      frozen AI document. Every request now seeds or refreshes its document
      turn from contentBuilder.getComponents() instead. Also drops historical
      image attachments from the request — maxBytes is sized for one
      screenshot under post_max_size, and replaying every prior attachment on
      each follow-up can push the aggregate body past that limit. 9ca7e3

    • fix(content-builder): drop a stale AI response after a page switch (#64) 371c91

    • refactor(content-builder): share the chat loop between the modal and the editor panel (#64)

      Extracts turn storage, chat-bubble rendering, and copy-to-clipboard into
      chat-core.js, used by both chat.js's field-targeted modal and
      bcb-chat-panel.js's docked panel. The request/response cycle stays local
      to each caller — they talk to different skills with different payload
      and response shapes, and forcing that through one function would trade
      duplication for a worse, branchier abstraction.

      Fixes two bugs the duplication had introduced in bcb-chat-panel.js: the
      Copy button was never bound (only chat.js's copy of the handler existed)
      and a stale "type a message" error could survive a valid follow-up
      submission. 1ab342

    • fix(content-builder): keep the generated document in follow-up history, guard double-submit a48469

    • feat(content-builder): chat panel in the editor for building and refining a page (#64)

      Docks a conversational panel into the Content Builder editor's tree panel
      via a mixin on bcb-panel.js. Every reply is applied straight into the
      working copy through content-builder.js's applyAiComponents(), and every
      request is sent persist:false so nothing reaches the draft until the
      admin uses the editor's own Save/Publish. 9e66ae

    • feat(content-builder): add non-persisting build endpoint and editor chat config (#64)

      Adds options.persist=false to Controller/Adminhtml/Page/Build so a request
      can return generated components without writing the Content Builder draft.
      Extracts vision-model selection out of BuildFromScreenshot into a shared
      VisionModelProvider, and adds Block/Adminhtml/Editor/AiChatConfig plus its
      layout/template to seed the editor route with the same config. 6eca5e

    • Merge pull request #80 from breezefront/claude/focused-payne-b60751

      fix(content-builder): reproduce every band the screenshot shows (#74) a84559

    • fix(content-builder): word the band warnings for the request that was sent (#74)

      Four defects found reviewing the review fixes.

      The count-mismatch warnings hardcoded "The reference design was read as ...",
      but the mismatch branch runs whether or not a screenshot was attached — the
      prompt asks for a band count on every build, not only on the image path. A
      text-only brief that came back over- or under-segmented therefore told the
      merchant about a reference design they never sent, on the build note and in
      the Content Builder editor. The flag that decides whether to warn now decides
      what the warning calls the thing the page was counted against.

      The band-wrapper warnings borrowed ComponentValidator's "root[N]" label while
      numbering a different list. The validator indexes the raw model output, where
      a dropped node still consumes its index; the audit sees the list after
      validation. One dropped band was enough to put two warnings saying "root[0]"
      in one list, pointing at two different bands. They are numbered as bands now.

      readScreenshot() read and base64-encoded the file before ImageValidator got to
      apply MAX_IMAGE_BYTES, so an oversized design export cost about 2.3x its size
      in memory to reach a verdict available from filesize(). The admin path is
      unaffected: the browser posts base64 and no file is read.

      FiveBandMock::drawProductRow() took $bottom and never used it, so that band
      alone would not follow a change to the band split in draw() — in the one file
      whose docblock claims the band boundaries are readable in source. 685502

    • fix(content-builder): keep the band count when the reply carries prose (#74)

      Three defects found reviewing the band-audit change.

      The prompt says "no prose" and models add it anyway, which is why
      jsonCandidates() exists at all. It tries the widest "[...]" run before
      "{...}", correct while a bare list was the asked-for shape. With an envelope
      whose "unsupported" is omitted and "components" last, that run is exactly the
      components array: it parses, wins, and the band count is thrown away. The
      audit then reported "the AI did not report how many bands it saw" for a reply
      that did report it. A list is now held as a fallback and the remaining
      candidates are still tried for an envelope, so a reply that really is a bare
      list still resolves to the first list.

      spreadStackedSlots() decided a band was stacked from the count of the first
      slot alone. "children" written as a flat list of nodes rather than a list of
      slots lands there with the node itself as the first slot, and its own keys are
      counted as sibling blocks — {"type": ..., "props": ...} counts 2 in a
      two-column band and was shredded across the slots, replacing the accurate
      short-slot warning with a repair that never happened. Every entry now has to
      look like a node.

      render.php ignored the file_put_contents result and printed "(0 bytes)" with
      status 0 on an unwritable path, sending whoever reproduces a measurement to
      look at the build command instead. ee671e

    • fix(content-builder): reproduce every band the screenshot shows (#74)

      Shown a five-band reference design, gpt-4o-mini returned a document whose
      columns bands were fakes: the container had the right type and the right
      column count, but every card sat in its first slot, so the band rendered as
      one column with the rest empty. Measured over ten runs of the committed mock,
      that happened in nine of them.

      Three changes, in order of how much they hold:

      - ComponentValidator spreads a container whose whole band was written into
      its first slot, when the arithmetic leaves no other reading. Prompting
      against this only half worked; the repair is unambiguous.
      - The prompt asks for {"bands", "unsupported", "components"} instead of a
      bare array, spells out the slot shape, and names the columns-versus-
      product_grid choice. A bare array is still accepted.
      - BandAudit compares the band count the model reports to what it emitted and
      reports the difference, plus any band the catalog cannot express. It
      reports and does not retry: a second round trip would double the cost of
      every build to re-roll an answer a small model is as likely to get wrong
      again, and the missing band cannot be synthesised locally.

      Measured with the mock and expected shape now under Test/Fixture, driven
      through the new --screenshot option. Ten runs each, gpt-4o-mini:

      before: 5 bands x5, 6 bands x4, 1 hard failure; 9/9 mis-slotted
      after: 5 bands x10, 0 mis-slotted, 8/10 with both columns containers

      gpt-4o gets the slots right with either prompt, so the mis-slotting was
      model capacity and the prompt closes the gap on the small model. 0e5f35

    • Merge pull request #79 from breezefront/fix/build-note-page-id-type

      fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7faf89

    • fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7f6aaa

    • Merge pull request #78 from breezefront/claude/priceless-turing-a344ac

      fix(config): offer model names the providers still serve (#73) 21eee6

    • Merge pull request #77 from breezefront/claude/jovial-chatelet-3447d7

      feat(content-builder): report what the validator repaired (#72) d23f45

    • feat(content-builder): report what the validator repaired (#72) 4b47e4

    • fix(content-builder): drop a corrupt build note instead of reporting a clean build (#72) e3994a

    • fix(gemini): redact api key from error, notice for retired models (#73) 922d51

    • fix(config): offer model names the providers still serve (#73) b1963c

    • Merge pull request #76 from breezefront/claude/lucid-kepler-a7ce73

      fix(content-builder): keep authoring placeholders out of generated copy (#71) 6a5b87

    • fix(content-builder): keep required sample copy on the malformed-value path (#71)

      fallbackValue() blanked any authoring placeholder, so a required copy prop the
      model sent as an array — quote.text, in the fixture — came out empty, which is
      the broken component the omitted-prop path deliberately avoids. The rule now
      lives in one predicate used by both paths.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8a6461

    • fix(content-builder): keep authoring placeholders out of generated copy (#71)

      The catalog's "default" attribute does two jobs: the value a renderer needs
      when a prop is unset, and the sample text the editor drops into a fresh
      component. The validator filled both, so "Discover Something New" shipped as
      the heading of three FAQ answers. Copy-typed props whose default reads as
      prose are now stored blank — blank, not absent, because the templates read
      `$props['heading'] ?? '<sample>'` — and the prompt marks them VISIBLE-TEXT
      instead of quoting the sample text that taught the model to echo it.

      A list-valued prop is blanked as an empty list, so an unusable faq_items no
      longer falls back to the two sample questions. Tabs keeps its declared titles:
      the container counts its slots from them.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 00e837

    • Merge pull request #75 from breezefront/claude/amazing-lichterman-a86181

      fix(content-builder): keep the list props the model wrote (#70) 819b38

    • fix(content-builder): keep every tab title paired with its slot (#70)

      Dropping an unusable title compacted the list while normalizeChildren()
      truncates slots from the end, so ["Shipping", "", "Returns"] over three
      populated slots rendered "Returns" above the middle slot's content and threw
      the last one away.

      A list of strings is positional — its container pairs slot N with entry N —
      so an unusable entry is now replaced where it stands, taking the declared
      default's title for that position ("Tab 2"), which is what the container's
      renderer pads a short title list with anyway. Entries that carry their own
      content, like the FAQ items, have no slot to stay paired with and are still
      dropped.

      An empty list now says so rather than reporting that none of its zero
      entries was usable. efb774

    • fix(content-builder): read a keyed object as one list entry (#70)

      decodeList() accepted any array, so a keyed object counted as a list: an FAQ
      item the model sent unwrapped — {"q": ..., "a": ...} instead of [{...}] —
      was iterated field by field, turning the question and the answer into two
      bogus items, each with the other's text as its question.

      Only a real list is a list now. A keyed object carrying at least one declared
      field is wrapped as the single entry it is, so the question the model wrote
      survives; one carrying none falls back to the default with a warning naming
      the fields that were expected. 71d179

    • fix(content-builder): keep the list props the model wrote (#70)

      The catalog asks the model for the Tabs titles and the FAQ items as JSON
      arrays, then ComponentValidator coerced every non-scalar prop back to its
      default — so a generated FAQ arrived with its questions replaced by "Tab 1"
      / "Tab 2" / "Tab 3", and the Tabs slot count fell back to the default three,
      dropping any further answer with it.

      A prop whose declared default is a JSON array is now validated as a list
      instead of discarded: entries are cleaned one by one and written back in the
      JSON-string shape the editor stores and the renderers decode, so the slot
      count follows the titles the model actually wrote. The default is only
      reached when nothing usable is left, and the warning then names the reason.

      Recognised by the shape of the declared default rather than by prop name, so
      the FAQ accordion's question/answer items are covered by the same path. a4765b

    • Merge pull request #69 from breezefront/claude/stoic-yonath-58797a

      fix(content-builder): drop fake "spacing" prop from catalog prompt 7db871

    • fix(content-builder): drop fake "spacing" prop from catalog prompt

      The margin/padding summary line started with the group label instead of
      prop names, so the model read "spacing" as a prop and emitted it on every
      component that had one. Render one line per value range, leading with the
      real names, which also stops claiming -200 as the minimum for paddings.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> beb335

    • chore: drop stray .b64 scratch files from screenshots dir

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> e5e228

    • docs: add screenshots to test guide, correct scenarios after live testing

      Vision-model dropdown pre-filters server-side (10.6 unreachable via UI as
      written), Remove+re-attach confirmed clean (10.9). Found and filed a real
      draft-persistence bug in module-breeze-content-builder (#31) — re-run builds
      report success but don't survive a fresh page reload.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8062ec

    • docs: add test scenarios and how-to-test guide for Content Builder screenshot import

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8e6e8d

    • Merge pull request #67 from breezefront/feat/value-backup

      feat(backup): record values before AI writes 70f7cc

    • fix(backup): skip ambiguous staging rows 27e298

    • fix(backup): guard capture reads, batch pruning 19f666

    • feat(backup): record values before AI writes 21d8f6

    • Merge pull request #66 from breezefront/test/bulk-selection-regression

      test(bulk): pin the scope a run resolves to b69352

    • test(bulk): seed the products the scope cases run against

      Copilot was right on both counts: the cases borrowed whatever the
      catalogue happened to hold, and the exclusion case skipped itself whenever
      no two SKUs shared a five-character prefix. A regression check that goes
      quiet when the surrounding data does not suit it is not a guard.

      Three products with a prefix unique to the run are created in setUp and
      removed in tearDown, so every case is deterministic and none can skip. The
      prefix also gets its own case, since the two filtered cases rest on it
      selecting this test's products and nothing else.

      Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour now turns five
      of the six red, up from four. The unfiltered select-all stays green, which
      is correct - that path is the legitimate one and behaved the same before.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e82c74

    • test(bulk): pin the scope a run resolves to

      The selection fault has landed three times. 15c1e7c introduced an
      all_selected flag driven by allSelected(), #31 closed it in June, and
      c13907e swapped in excludeMode() and brought it straight back - Magento
      raises either flag by itself once the checked rows cover the rows the grid
      is showing. Both attempts only ever argued about the client-side flag,
      while the resolver went on loading the collection with no filters at all.

      The E2E spec covers the request the wizard posts. Nothing covered what
      that request then resolves to, which is the number that decides how many
      products get rewritten. These cases do, against the live catalogue: an
      explicit selection, a genuine select-all, a select-all narrowed by a
      filter, exclusions inside a filtered set, and an empty selection.

      Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour turns four of
      the five red, the exception being the unfiltered select-all - which is
      correct, since that case is the legitimate path and behaved the same
      before.

      Refs #61

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8e8b71

    • Version 1.2.0 634a46

    • Merge pull request #63 from breezefront/feat/build-page-from-screenshot

      Build a Content Builder page from a screenshot 680ede

    • fix(vision): close the second round of review points on #63

      The one that mattered: `options['brief']` skipped the fix from the previous
      commit entirely. It leaves the history untouched by design, so an
      alternating conversation that ended on a user turn got our prompt appended
      after it — the exact pair Anthropic answers 400 to, reachable from REST
      where the caller supplies both a brief and a history. The prompt is now
      folded into a trailing user turn instead of following it, which keeps that
      turn's words rather than dropping them. The class docblock claimed "a
      history that already alternates keeps alternating"; it does now.

      `testSendsTheHistoryFollowedByTheGeneratedPrompt` was pinning the broken
      shape — two user turns, asserted as correct — so its history now ends on
      an assistant turn and the folding case has tests of its own.

      The block hid itself for every reason a build could fail except the two
      the controller actually enforces. A role holding `Magento_Cms::page` but
      not `Swissup_BreezeAi::index` or `Magento_Cms::save` saw a working-looking
      button whose every click came back as the login page, which the modal can
      only report as an expired session.

      Also from the review: the modal kept the previous brief when reopened, so
      it would have ridden the next screenshot unseen; an in-flight FileReader
      could re-arm a screenshot the admin had already removed, now invalidated
      by a token that `clearScreenshot()` moves on; the drop zone gained
      `role="button"` and the brief and model controls gained real labels, since
      a placeholder is not one and the select had no accessible name at all.

      A warning-free build no longer redirects on its own. The draft is saved by
      then but the CMS form behind the modal is not — this request never touched
      it — so a title or URL key typed before opening the modal was silently
      lost. The editor is offered as a link in both outcomes.

      635 unit, 18 integration, 30 e2e green.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3072d9

    • fix(vision): answer the three points Copilot raised on #63

      The docs described a cap the code stopped using. `BuildFromScreenshot`
      reads `post_max_size`, and its own comment explains why `getMaxFileSize()`
      is the wrong ceiling for a base64 string in a text field — the doc still
      named `getMaxFileSize()`, which is exactly the sort of mismatch someone
      debugging a 413 would be misled by.

      The drop zone carries `tabindex="0"` but only answered to a click, so it
      could be tabbed to and not opened. Enter and Space now trigger the picker,
      with the same guard the click handler needs: the remove control sits
      inside the zone and answers to both keys, and swallowing them there would
      leave it dead to the keyboard.

      `getVisionModels()` is asked for four times while one CMS page form
      renders — `canShow`, `getConfigJson`, and `getSelectedModelId` from inside
      it — and each ask loads every model to read its metadata. Memoized for the
      life of the block, which is one request.

      631 tests green; 29 e2e, the new keyboard case among them.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2e6454

    • test(e2e): assert the selection contract the wizard actually posts

      33d5e97 replaced the all_selected flag with Magento's own mass-action
      contract — one of selected or excluded, plus the grid namespace and the
      filters that were on screen — and updated SaveTest, but the Playwright spec
      kept asserting the flag that no longer exists. Red on master since then.

      The assertion now names what the fix was for: the filters travel with the
      selection, which is what stops an exclude-mode Select All from widening past
      the rows the grid was showing. f34845

    • feat(vision): build a Content Builder page from a screenshot

      PR #55 landed the whole image path — MessageInterface::getImages(),
      ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
      nothing used it. Every caller of build_page passed an empty history, and no
      UI produced an images list. This gives the admin a way in.

      A button in the Content Builder fieldset of the CMS page form opens a modal:
      attach a screenshot, optionally type a brief, pick a vision-capable model.
      The file is read with FileReader and posted as a data URI inside
      history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
      runs the skill, saves the result through DraftWriter, and answers with the URL
      of the editor the admin reviews it in.

      Synchronous rather than an option on the bulk wizard: a reference screenshot
      is a per-page input, and one screenshot over N pages produces N near-identical
      layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
      all three providers are used exactly as PR #55 built them, unchanged.

      BuildPage now lifts images off the user turns they arrived on and puts them on
      the prompt turn it builds, consuming the last user turn as the brief. That
      fixes an existing fault: Model\Provider\Claude passes the message list through
      unmerged and Anthropic refuses two user turns in a row, so the old
      array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
      fallback was the thing being used.

      PromptBuilder gains a conditional Reference design block. Two of its five
      rules carry the weight: without the band-per-row line the model answers with a
      flat component list and the row-wrapping rule loses to the picture, and
      without the ignore-brand-and-colours line it copies the screenshot's hex
      values into every colour prop and the draft clashes with the merchant's theme.

      Verified end to end against gpt-4o-mini on a real storefront screenshot:
      six row-wrapped bands, no validator repairs, draft readable in the editor.
      With a brief added, the structure holds and the copy follows the brief rather
      than transcribing the screenshot.

      540 -> 621 unit tests, all green. 9d8854

    • test(mcp): list build_page among the tools the server exposes

      McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
      with four tools. build_page joined the skill pool in 1bf01f6, and every skill
      in the pool is exposed as a tool, so the integration suite has been red since
      then. Nothing about the server changed — only what the test expects. f8588c

    • test(e2e): cover the build-from-screenshot modal

      The one surface unit tests cannot reach. It exists because of a bug that got
      all the way to review: the file input is a child of the drop zone, so the
      synthetic click jQuery sends it bubbles back into the zone's own handler and
      recurses until the stack blows — the picker never opens and only drag and drop
      works. "clicking the drop zone opens the file picker" is the guard for that.

      Also covers the pre-flight refusal of a non-image, that the refusal sends no
      request, the preview and remove cycle, and that a reopened modal carries none
      of the previous run's state.

      Stops short of pressing Build draft: that would spend a real provider call.
      Skips itself where the control legitimately hides — no Content Builder, or no
      configured model that reads images. 10934b

    • test(mcp): list build_page among the tools the server exposes

      McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
      with four tools. build_page joined the skill pool in 1bf01f6, and every skill
      in the pool is exposed as a tool, so the integration suite has been red since
      then. Nothing about the server changed — only what the test expects. 60d8c7

    • feat(vision): build a Content Builder page from a screenshot

      PR #55 landed the whole image path — MessageInterface::getImages(),
      ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
      nothing used it. Every caller of build_page passed an empty history, and no
      UI produced an images list. This gives the admin a way in.

      A button in the Content Builder fieldset of the CMS page form opens a modal:
      attach a screenshot, optionally type a brief, pick a vision-capable model.
      The file is read with FileReader and posted as a data URI inside
      history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
      runs the skill, saves the result through DraftWriter, and answers with the URL
      of the editor the admin reviews it in.

      Synchronous rather than an option on the bulk wizard: a reference screenshot
      is a per-page input, and one screenshot over N pages produces N near-identical
      layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
      all three providers are used exactly as PR #55 built them, unchanged.

      BuildPage now lifts images off the user turns they arrived on and puts them on
      the prompt turn it builds, consuming the last user turn as the brief. That
      fixes an existing fault: Model\Provider\Claude passes the message list through
      unmerged and Anthropic refuses two user turns in a row, so the old
      array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
      fallback was the thing being used.

      PromptBuilder gains a conditional Reference design block. Two of its five
      rules carry the weight: without the band-per-row line the model answers with a
      flat component list and the row-wrapping rule loses to the picture, and
      without the ignore-brand-and-colours line it copies the screenshot's hex
      values into every colour prop and the draft clashes with the merchant's theme.

      Verified end to end against gpt-4o-mini on a real storefront screenshot:
      six row-wrapped bands, no validator repairs, draft readable in the editor.
      With a brief added, the structure holds and the copy follows the brief rather
      than transcribing the screenshot.

      540 -> 621 unit tests, all green. c53c71

    • Version 1.1.2

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3bc1cd

    • Merge pull request #60 from breezefront/fix/bulk-selection-scope

      fix(bulk): scope a run to what the grid was actually showing 49b5f5

    • fix(bulk): scope a run to what the grid was actually showing

      A client selected one product and the wizard queued a run over all 714.
      The submit path decided "everything is selected" from the grid
      excludeMode flag, and core turns that flag on by itself as soon as the
      checked rows cover the rows the grid currently holds - filtering down to
      a single product and ticking it is indistinguishable from pressing Select
      All. The server then made it worse: the all_selected branch loaded the
      entity collection with no filters at all, so even a deliberate select-all
      meant the whole catalogue rather than the filtered set. Either fault
      alone produces the wrong count.

      Both go away by speaking Magento's own mass-action contract. The wizard
      now posts selected or excluded alongside the grid namespace, filters and
      search, exactly as Magento_Ui/js/grid/massactions does, and
      BulkEntityIdResolver runs the collection through MassAction\Filter. An
      exclude-mode selection resolves against the filters that were on screen,
      so the accidental flag can no longer widen the scope.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 33d5e9

    • Version 1.1.1

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f29385

    • Merge pull request #59 from breezefront/fix/model-form-provider-options

      fix(admin): serve the model form's options from the option sources 72aff2

    • fix(admin): serve the model form's options from the option sources

      The model select is repopulated in the browser whenever the provider
      changes, and the map it read from was a hand-maintained copy inside
      swissup_breezeai_model_form.xml. Adding GPT-5.6 updated OpenAiModel and
      the switcher rules but not that copy, so Sol, Terra and Luna could not be
      picked at all - and the run wizard's gpt-5.6-terra default could never
      match a saved row.

      ProviderModels builds the map from the same option sources the form
      already declares, injected through FormDataProvider::getMeta(). One list
      per provider, and registering a provider in di.xml is enough for its
      models to appear.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 936696

    • Merge pull request #58 from breezefront/fix/temperature-zero

      Stop dropping a temperature of 0 d79542

    • fix(providers): stop dropping a temperature of 0

      Claude and Gemini read the value with an assignment inside the condition:

      if ($temperature = $config->getExtra('temperature')) {

      which is a truthiness test, and `(bool) 0` is false. An admin setting 0 — the
      value you pick precisely to make output deterministic — had it silently discarded
      and got the provider's own default instead, 1.0 on Claude. No error, no warning,
      just different output than asked for.

      Compared against null and '' now, so 0 reaches the request and an unset field
      still sends nothing. OpenAI was already correct via `?? DEFAULT_TEMPERATURE`.

      Found by exercising the admin form rather than by reading: the field only became
      settable when the extra_config registry landed, and both existing provider tests
      covered temperature at 0.5 and 0.7, never at 0. Tests now cover 0 and unset for
      both providers, and fail on the old code.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> da9be6

    • Merge pull request #57 from breezefront/refactor/extra-config-registry

      One registry for the extra_config fields 40cddc

    • Merge pull request #55 from breezefront/feat/vision-image-input

      Vision foundation: let a message carry images, encode them per provider fd09a0

    • refactor(config): one registry for the extra_config fields

      Which fields live in the extra_config JSON was written down three times: in the
      save controller, in the form data provider, and in the form XML. Nothing failed
      when they disagreed — a field missing from the save list is silently not stored,
      one missing from the read list comes back blank on every edit. Adding `vision`
      meant three coordinated edits with no test that would catch a missed one.

      `temperature` is what that cost. All three providers read it; neither list
      carried it and the form had no field, so it could not be set from the admin at
      all. It has a field now, and the notice says what the ranges are and that GPT-5
      and the o-series ignore it.

      Model/ModelConfig/ExtraFields owns the keys and how each normalises. Types
      matter here: a blank submission clears the key, an int at zero clears it — no
      token cap means anything at zero — but a float at zero is kept, because zero is
      a meaningful temperature and dropping it would silently restore the provider
      default for someone asking for deterministic output.

      The form XML still declares its own fields: a field needs a label, a notice and a
      control that no list can supply. What moved is which keys travel in and out.

      Needs setup:di:compile — both consumers gained a constructor argument.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1dc4b

    • refactor(vision): cut ImageBuilder to the shape callers send

      Five accepted spellings of the same input for one call site: fromBinary had no
      caller at all, and fromArray took `data_uri` beside `url`, `mimeType` beside
      `mime_type`, and a data URI hidden in `data`. Each was a guess about a caller
      that does not exist — no skill attaches an image yet, and no JS sends one.

      What is left is what GenerateService passes: `{mime_type, data}`, or a data URI
      under `url` — the key OpenAI's own image_url part uses, so it is the one a caller
      already has a name for. fromDataUri and the base64 check became private; the
      public surface is fromArray, which is the only method anything calls.

      94 lines to 90, and 4 public methods to 1. Adding a shape back is cheap once a
      caller wants it; carrying four unused ones is not.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 66ac12

    • fix(vision): address the review findings on this PR

      VisionSupport keeps only the `vision` config key and reads ModelMetadata for
      what the name means, so its two prefix tables and its copy of matchesPrefix are
      gone — 116 lines to 70. The o-series can no longer be listed here as text-only
      while being listed there as reasoning.

      ImageValidator collects every image, checks the count, and only then decodes.
      The cap was the last rule tested, so an over-cap request paid for N base64
      decodes to be refused, and was refused for whichever image happened to be
      malformed first rather than for being over cap.

      ImageBuilder accepts and discards parameters between the mime type and
      `;base64`. A `charset` an exporter felt like adding says nothing about image
      bytes, and refusing the URI over it refused a readable image.

      GenerateService::toMessage() no longer claims Web API hands history over as
      arrays. It does not: /V1/breezeai/generate declares MessageInterface[] and
      ServiceInputProcessor dies with "Cannot instantiate interface" before the service
      runs, because the interface has no DI preference. The caller that really passes
      arrays is the admin controller, which decodes the body itself. History over REST
      predates image input and is not fixed here; docs/DEVELOPMENT.md says so instead
      of implying the route works.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> dd6371

    • feat(providers): let a message carry images, and encode them per provider

      A message gains getImages(): ImageInterface[] alongside its string content,
      and each provider encodes those images into its own wire format — an
      Anthropic base64 image block, an OpenAI image_url part holding a data URI,
      a Gemini inline_data part. Content stays a string, so a text-only turn
      produces the same request body it did before and every existing caller of
      `new Message($role, $content)` keeps working.

      Two things guard the send. ImageValidator enforces the limits the three
      providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
      request, user turns only — and VisionSupport answers whether the model a
      call is routed to reads images at all, from the model name plus a `vision`
      override on the model row (the new Image Input field). AiRequest runs both
      before reaching the provider, so an image sent to a text-only model fails
      with one sentence naming the model rather than a provider 400. Callers that
      want to know in advance ask AiRequest::supportsVision().

      ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
      a decoded request payload — into an ImageInterface, and GenerateService
      uses it so history posted over the wire can carry images too.

      No skill attaches an image yet; this is the layer under that.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ad9747

    • fix(data): document every Message constructor parameter

      Magento reads @param tags positionally — TypeProcessor::getParamDocBlockTag()
      indexes the tag list with the parameter's own offset — so a block documenting
      only $images typed $role from the images tag and had no tag to return for
      position 2. Reflecting the constructor to build a Message from request data
      died there with a TypeError before any of our own validation ran.

      The two missing tags are the whole fix. A test pins the invariant, since the
      next parameter added to this constructor would silently reintroduce it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b793a0

    • Merge pull request #56 from breezefront/refactor/model-metadata

      One table for what a model name means eb79b5

    • fix(model): correct the model facts the merge exposed

      Putting both axes in one table made three disagreements visible:

      o1 reads images. It was absent from the image-input list while present in the
      parameter-set one, which is the drift this table exists to stop. o1-mini and
      o1-preview genuinely do not, so they say so as longer prefixes.

      gpt-4-vision-preview — where vision on GPT-4 started — matched nothing and read
      as text-only, because the list carried gpt-4-turbo and gpt-4o but not the model
      named for the capability.

      claude-4 was dead weight: Claude 4 and later put the tier first, as in
      claude-opus-4-5, and those already match claude-opus.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f113ee

    • refactor(model): read model facts from one table

      RequestBody carried three prefix lists and VisionSupport (in #55) carried two
      more, each with its own copy of matchesPrefix. Nothing tied them together, so
      they drifted: the parameter-set list knew about o1 and the image-input list did
      not, and no test could catch it because neither list knew the other existed.

      ModelMetadata holds the facts once, keyed by provider and name prefix. Every
      prefix a name starts with contributes, shortest first, so a longer prefix
      overrides a shorter one and states only what differs — o3-mini says it takes no
      image and inherits its parameter set from o3. That ordering is what removes the
      separate lists of exceptions each consumer used to keep beside its main list.

      A per-row sampling or vision value stays with the consumer that reads it: the
      row overriding the name is config, not a fact about the model.

      Behaviour is unchanged — every existing RequestBody test passes untouched, and
      the table reproduces the old lists name for name, o1's missing image support
      included. Correcting that is the next commit, kept separate so this one reads
      as the move it is.

      supportsVision() has no caller on master yet; #55 is the caller and rebases
      onto this.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 891642

    • feat(providers): let a message carry images, and encode them per provider

      A message gains getImages(): ImageInterface[] alongside its string content,
      and each provider encodes those images into its own wire format — an
      Anthropic base64 image block, an OpenAI image_url part holding a data URI,
      a Gemini inline_data part. Content stays a string, so a text-only turn
      produces the same request body it did before and every existing caller of
      `new Message($role, $content)` keeps working.

      Two things guard the send. ImageValidator enforces the limits the three
      providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
      request, user turns only — and VisionSupport answers whether the model a
      call is routed to reads images at all, from the model name plus a `vision`
      override on the model row (the new Image Input field). AiRequest runs both
      before reaching the provider, so an image sent to a text-only model fails
      with one sentence naming the model rather than a provider 400. Callers that
      want to know in advance ask AiRequest::supportsVision().

      ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
      a decoded request payload — into an ImageInterface, and GenerateService
      uses it so history posted over the wire can carry images too.

      No skill attaches an image yet; this is the layer under that.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 659e74

    • Version 1.1.0 9aeb4e

    • Merge pull request #54 from breezefront/feat/bulk-run-model-and-system-prompt

      feat(bulk): pick the model and the framing per run, and pin them to it cab8ed

    • fix(bulk): keep the closing script tag out of a JS comment

      The comment warning about a prompt body holding a closing script tag
      contained one, which ended the block early and dumped the rest of the
      script onto the page as text.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1f118

    • feat(bulk): pick the model and the framing per run, and pin them to it

      The bulk wizard ran on whatever the configuration said at the moment each
      operation was picked up, with no way to steer the model or tell it what
      kind of writing was wanted. The client rated the German output 4/10 and
      asked for both: a model per run, and a free-text instruction that reaches
      the model as framing rather than as part of the product text.

      Two things had to be true for that to mean anything.

      The framing has to arrive in the system channel, ahead of the content.
      It does: the skills put it in a system message, and the providers already
      agree that a caller's system message wins over their built-in default.
      Where a run brief is present the translate prompt drops its own "you are
      a professional e-commerce translator" opening — that line answers the one
      question the brief exists to answer — while keeping the mechanical rules
      about numbers, SKUs and HTML, which are output format rather than
      persona.

      And both have to hold still. A batch of a few thousand products drains
      over hours, so resolving configuration per operation means editing the
      default model halfway through silently splits the batch across two
      models with nothing in the result to say which got which. The model and
      the prompt are now decided when the run is scheduled and travel in the
      operation payload, which is already immutable once written — no queue
      schema change. Even leaving the dropdown alone pins a concrete row:
      "the default model" is a moving target.

      The model ID is a pointer and the row behind it stays editable, so the
      provider and model name ride along as a snapshot and the consumer stops
      rather than bill a model nobody chose. The API key is deliberately not
      snapshotted — rotating one has to reach the runs already queued.

      For Generate this reverses an existing precedence: a model passed in now
      beats the one bound to the prompt. Step 5 tells the operator which model
      the run will use, and a per-prompt model overriding that would make the
      screen lie. Nothing outside the wizard passes one, so the prompt's own
      model still decides everywhere else.

      Two small stores back the UI. Prompts gain a type, so a run brief can be
      saved in the existing library without turning up in the per-field
      dropdowns; rows written before the column are field prompts, which is all
      the module could store then. And one row per admin remembers what the
      wizard was last run with, so it opens on that instead of an empty form —
      a preference, not a reference, which is why it carries no foreign key to
      the model table: a model since deleted is simply not offered.

      Operations queued before any of this carry none of the new keys and keep
      running on the default model.

      setup:upgrade applies the schema; Claude, Gemini and the bulk consumer
      take new constructor arguments, so setup:di:compile is needed as well.

      ./bin/test unit — OK (454 tests, 901 assertions)
      phpcs --standard=Magento2 — 0 errors on every touched file
      Schema verified against a real database, not only declared.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> fcb9d8

    • fix(providers): agree on what a caller's system message means (#53)

      * fix(providers): agree on what a caller's system message means

      The three providers disagreed. OpenAI lets a caller-supplied system message
      replace the built-in framing; Claude and Gemini filtered it out of the
      conversation and then sent their own constant anyway, so the instruction was
      read, dropped, and never reached the model. The same prompt therefore meant
      different things depending on which model it happened to be assigned to — and
      silently, because nothing errors when a system message is discarded.

      Nothing sends one today, so this fixes no live failure. It removes a trap: the
      first skill that reaches for its own framing would have got it on OpenAI and
      lost it on the other two, and the symptom is bad output rather than an error.

      `Model\Provider\SystemMessage` now resolves the framing for all three — the
      caller's, or the built-in default when the caller supplied none. Where it goes
      still differs per API: inline in `messages` for OpenAI, the top-level `system`
      field for Claude, `system_instruction` for Gemini. More than one system message
      is joined in order rather than reduced to the first, since Claude and Gemini
      have a single field for it and dropping the rest would lose instructions.

      The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
      points at the shared constant so it keeps working for anything referencing it.

      Claude and Gemini take a new constructor argument, so this needs
      `setup:di:compile`.

      ./bin/test unit — OK (409 tests, 810 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      * fix(openai): resolve the framing through the shared rule, not a copy of it

      The previous commit gave Claude and Gemini a shared SystemMessage but left
      OpenAI deciding for itself in buildMessages(). Only the default string was
      shared, so the two paths could still drift: several system messages were
      joined on Claude and Gemini and left scattered through the conversation on
      OpenAI, and any later change to resolve() would have reached two providers
      out of three.

      OpenAI now calls resolve() as well. The framing still travels as the first
      entry of `messages` because that is what the endpoint takes; the caller's
      system messages are lifted out of the conversation and folded into it, so
      the instruction is sent once rather than twice.

      resolve() also stops treating a blank system message as one supplied. An
      empty string is not framing, and passing it through meant Gemini receiving
      an empty `system_instruction` part — which it refuses — where before the
      constant always went out.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      ---------

      Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 2c716b

    • fix(openai): resolve the framing through the shared rule, not a copy of it

      The previous commit gave Claude and Gemini a shared SystemMessage but left
      OpenAI deciding for itself in buildMessages(). Only the default string was
      shared, so the two paths could still drift: several system messages were
      joined on Claude and Gemini and left scattered through the conversation on
      OpenAI, and any later change to resolve() would have reached two providers
      out of three.

      OpenAI now calls resolve() as well. The framing still travels as the first
      entry of `messages` because that is what the endpoint takes; the caller's
      system messages are lifted out of the conversation and folded into it, so
      the instruction is sent once rather than twice.

      resolve() also stops treating a blank system message as one supplied. An
      empty string is not framing, and passing it through meant Gemini receiving
      an empty `system_instruction` part — which it refuses — where before the
      constant always went out.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7ddc5f

    • fix(providers): agree on what a caller's system message means

      The three providers disagreed. OpenAI lets a caller-supplied system message
      replace the built-in framing; Claude and Gemini filtered it out of the
      conversation and then sent their own constant anyway, so the instruction was
      read, dropped, and never reached the model. The same prompt therefore meant
      different things depending on which model it happened to be assigned to — and
      silently, because nothing errors when a system message is discarded.

      Nothing sends one today, so this fixes no live failure. It removes a trap: the
      first skill that reaches for its own framing would have got it on OpenAI and
      lost it on the other two, and the symptom is bad output rather than an error.

      `Model\Provider\SystemMessage` now resolves the framing for all three — the
      caller's, or the built-in default when the caller supplied none. Where it goes
      still differs per API: inline in `messages` for OpenAI, the top-level `system`
      field for Claude, `system_instruction` for Gemini. More than one system message
      is joined in order rather than reduced to the first, since Claude and Gemini
      have a single field for it and dropping the rest would lose instructions.

      The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
      points at the shared constant so it keeps working for anything referencing it.

      Claude and Gemini take a new constructor argument, so this needs
      `setup:di:compile`.

      ./bin/test unit — OK (409 tests, 810 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 435eb3

    • fix(openai): send the parameter set the model family accepts (#52)

      * fix(openai): send the parameter set the model family accepts

      Every request carried temperature, frequency_penalty and presence_penalty,
      and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
      family and the o-series refuse all four on the same endpoint: the two
      penalties are rejected outright even at 0, temperature takes nothing but
      its default, and the token cap was renamed to max_completion_tokens. A
      request built the old way comes back 400, so those models did not degrade
      on this provider — they failed every call.

      Nobody hit it because the model dropdown stopped at gpt-4o-mini and
      reaching a newer one meant typing it into the custom field. The client on
      the translation project asked for gpt-5.6-terra, which is where this
      starts to matter.

      The body is now built by a small class of its own. The provider builds its
      HTTP client inline and cannot be exercised without a network, which left
      the part that has to be right per model family with no test at all; that
      part is now pure and covered.

      Family is decided by the model name, with a sampling extra-config key to
      override it: an Azure deployment or a proxy can serve GPT-5 under a name
      that says nothing, and prefix matching would send it what it refuses.
      reasoning_effort and verbosity are passed through where they are
      understood, so the effort knob is reachable without another release.

      A caller-supplied system message now replaces the built-in one instead of
      being appended after it — two system messages in one request left the
      model to pick. No caller sends one yet.

      The three GPT-5.6 tiers are in the model dropdown, so the client's model
      is a selection rather than a hand-typed string.

      phpcs --standard=Magento2 reports 0 errors on the touched files.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      * fix(openai): gate the reasoning knobs per family and expose them in admin

      The first pass treated GPT-5 and the o-series as one family. They are not:
      `verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
      rejects `reasoning_effort` as well. Sending either to a model that refuses it
      reproduces the 400 this branch set out to remove, so the knobs are now offered
      per family while the token-cap rename and the dropped sampling parameters stay
      common to both.

      The knobs themselves had no way in. `sampling`, `reasoning_effort` and
      `verbosity` were readable only by hand-editing the extra_config JSON, which
      made the escape hatch for unnamed Azure and proxy deployments unreachable in
      practice. All three are now fields on the model form, saved into and read back
      out of extra_config alongside max_tokens.

      Also:

      - honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
      matching what the Claude and Gemini providers already do
      - read `sampling` without casting, so a non-string value in the JSON cannot
      raise an array-to-string conversion
      - note in the class docblock that an unrecognised model name falls back to the
      GPT-4 parameter set, so the next family will need its prefix added here

      ./bin/test unit — OK (401 tests, 802 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      ---------

      Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 37217f

    • fix(openai): gate the reasoning knobs per family and expose them in admin

      The first pass treated GPT-5 and the o-series as one family. They are not:
      `verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
      rejects `reasoning_effort` as well. Sending either to a model that refuses it
      reproduces the 400 this branch set out to remove, so the knobs are now offered
      per family while the token-cap rename and the dropped sampling parameters stay
      common to both.

      The knobs themselves had no way in. `sampling`, `reasoning_effort` and
      `verbosity` were readable only by hand-editing the extra_config JSON, which
      made the escape hatch for unnamed Azure and proxy deployments unreachable in
      practice. All three are now fields on the model form, saved into and read back
      out of extra_config alongside max_tokens.

      Also:

      - honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
      matching what the Claude and Gemini providers already do
      - read `sampling` without casting, so a non-string value in the JSON cannot
      raise an array-to-string conversion
      - note in the class docblock that an unrecognised model name falls back to the
      GPT-4 parameter set, so the next family will need its prefix added here

      ./bin/test unit — OK (401 tests, 802 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7d1b03

    • fix(openai): send the parameter set the model family accepts

      Every request carried temperature, frequency_penalty and presence_penalty,
      and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
      family and the o-series refuse all four on the same endpoint: the two
      penalties are rejected outright even at 0, temperature takes nothing but
      its default, and the token cap was renamed to max_completion_tokens. A
      request built the old way comes back 400, so those models did not degrade
      on this provider — they failed every call.

      Nobody hit it because the model dropdown stopped at gpt-4o-mini and
      reaching a newer one meant typing it into the custom field. The client on
      the translation project asked for gpt-5.6-terra, which is where this
      starts to matter.

      The body is now built by a small class of its own. The provider builds its
      HTTP client inline and cannot be exercised without a network, which left
      the part that has to be right per model family with no test at all; that
      part is now pure and covered.

      Family is decided by the model name, with a sampling extra-config key to
      override it: an Azure deployment or a proxy can serve GPT-5 under a name
      that says nothing, and prefix matching would send it what it refuses.
      reasoning_effort and verbosity are passed through where they are
      understood, so the effort knob is reachable without another release.

      A caller-supplied system message now replaces the built-in one instead of
      being appended after it — two system messages in one request left the
      model to pick. No caller sends one yet.

      The three GPT-5.6 tiers are in the model dropdown, so the client's model
      is a selection rather than a hand-typed string.

      phpcs --standard=Magento2 reports 0 errors on the touched files.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3b7370

    • Merge pull request #51 from breezefront/fix/category-translate-source-language

      fix(translate): name the source language when translating categories db336b

    • docs(translate): tighten the source-store comment

      The docblock read like a changelog entry for 1.0.11. Keep the reason the
      source store is handed to the skill, drop the history around it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f63c1b

    • fix(translate): name the source language when translating categories

      1.0.11 changed the translate prompt to name both languages ("translate
      from Danish to German") because a weak model, told only the target, left
      a good part of the text in the source language. The bulk consumer was
      only half-converted: translateProducts() passes source_store_id, but
      translateCategories() still calls the skill with the attribute and the
      target store alone.

      The source store was already used to load the category, so the values fed
      to the model were right — only the prompt was missing the "from" half.
      Category translations therefore kept running on the pre-1.0.11 prompt and
      came back partly untranslated, the exact symptom 1.0.11 was released to
      cure. Nothing surfaced it: the skill treats source_store_id as optional
      and silently omits the clause when it is absent.

      The unit test now asserts the whole options array rather than accepting
      any call, so the next field added to it cannot go missing the same way.

      phpcs --standard=Magento2 reports 0 errors on the touched files, the same
      as before.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6871f1

    • Merge pull request #50 from breezefront/fix/bulk-empty-field-panels-v2

      fix(bulk): do not offer a task whose field panel has nothing in it acd541

    • fix(bulk): do not offer a task whose field panel has nothing in it

      The wizard decides what step 2 may offer by asking whether a step 4 panel
      exists for the pair. A panel could exist and still be empty: Generate
      renders a row per attribute only when a prompt is bound to it, so with no
      prompts configured the panel is a bare fieldset. The check said yes, the
      operator picked Generate, and step 4 came up blank with a Run button that
      never enabled — the dead end the panel check was meant to close, reached
      from inside the panel instead of around it.

      Prompts are now resolved once up front and an attribute without any is
      dropped, which makes "the panel has fields" knowable before rendering.
      A panel with no fields is not rendered, so the existing check reports the
      truth for it. Translate gets the same treatment for symmetry, though its
      field list is only empty on entity types nobody can select today.

      That leaves step 2 able to come up with nothing at all in it, so it now
      says why instead of showing an empty box.

      phpcs --standard=Magento2 reports 0 errors on the touched file, the same
      as before.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 392330

    • Merge pull request #48 from breezefront/fix/bulk-save-skill-entity-validation

      fix(bulk): refuse a skill the queue cannot run on the chosen entity type 24f2e1

    • Merge pull request #49 from breezefront/fix/bulk-empty-field-panels

      fix(bulk): do not offer a task whose field panel has nothing in it ecfa84

    • Merge pull request #46 from breezefront/fix/bulk-wizard-skill-filter

      fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word 87738f

    • fix(bulk): refuse a skill the queue cannot run on the chosen entity type

      BulkConsumer::execute() routes on (skill, entity type) pairs and throws on
      anything it has no handler for, but the scheduling controller accepted
      whatever was posted. A request for a pair the consumer cannot route —
      generate on cms_page, say, which the wizard hides but the form will still
      carry — was answered with "has been scheduled", split into operations, and
      failed one by one where only the bulk log would show it.

      Save now checks the pair before it schedules anything, so the operator is
      told no while still looking at the screen. The check runs before the entity
      ids are resolved, which also spares a full-catalog id query for a request
      that was never going to run.

      QueueRoutes holds the pairs rather than inlining them, because the
      consumer's routing table is the real authority and the two must not drift:
      QueueRoutesTest reads the consumer's match arms and asserts they are
      exactly the declared list.

      It is deliberately not SkillSupport. That asks whether a skill can act on
      an entity type at all and answers from getSupportedEntityTypes(); Generate
      claims cms_page for the inline editor and is right to, but there is no
      generate/cms_page route in the consumer. What a skill can do and what the
      queue can carry out are separate facts, and only the second one decides
      what may be scheduled.

      370 unit tests pass. phpcs --standard=Magento2 reports 0 errors on the
      touched files, and Save.php carries the same 10 warnings as before.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 600476

    • Merge pull request #47 from breezefront/fix/cms-page-translate-scope

      fix(translate): stop writing CMS page translations into a category 219f60

    • fix(bulk): do not offer a task whose field panel has nothing in it

      The wizard decides what step 2 may offer by asking whether a step 4 panel
      exists for the pair. A panel could exist and still be empty: Generate
      renders a row per attribute only when a prompt is bound to it, so with no
      prompts configured the panel is a bare fieldset. The check said yes, the
      operator picked Generate, and step 4 came up blank with a Run button that
      never enabled — the dead end the panel check was meant to close, reached
      from inside the panel instead of around it.

      Prompts are now resolved once up front and an attribute without any is
      dropped, which makes "the panel has fields" knowable before rendering.
      A panel with no fields is not rendered, so the existing check reports the
      truth for it. Translate gets the same treatment for symmetry, though its
      field list is only empty on entity types nobody can select today.

      That leaves step 2 able to come up with nothing at all in it, so it now
      says why instead of showing an empty box.

      phpcs --standard=Magento2 reports 0 errors on the touched file, the same
      as before.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1fe13b

    • fix(translate): refuse an unsupported entity type in the skill itself

      Narrowing getSupportedEntityTypes() to product and category told the truth
      but gated nothing. Three paths still reached a CMS page:

      - McpServer::executeSkill() passes entityType straight to GenerateService;
      the schema enum is a hint to the client, not a check. translate +
      cms_page + targetStore>0 went through Translate::execute(), which took
      the attribute list from the input field pool — and the pool still knows
      the cms_page fields — translated the text, saved nothing, and reported
      success. A silent no-op instead of an error.
      - The CMS page form kept its "AI Translate" buttons. Block\Adminhtml\Js
      hands translate.js every input field of the entity type, and the form
      has no store switcher, so currentStore is always 0: every click ran
      translateForAllStores() and died on the new save guard.
      - That death cost one AI request. translateForAllStores() calls the skill
      first and saves after, so the provider was paid before anything refused
      the entity.

      Translate::execute() now asserts the entity type against its own
      getSupportedEntityTypes() right after resolving it — before the provider
      call, so a refused entity costs nothing, and for every caller at once.
      saveAttributeForStore() keeps its throw as the last line of defence.

      SkillSupport answers "can this skill act on this entity type" for UI code;
      Js publishes skills.translate and translate.js renders no buttons when it
      is false. Generate still gets the full field list.

      Js gained a constructor argument — setup:di:compile.

      364 unit tests pass. phpcs --standard=Magento2 reports no errors on the
      touched files.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 551992

    • refactor(bulk): give the step 4 panel lookup one home

      The panel query behind the wizard's "can this task run here" check was
      written out three times over. wizardCanRun() and hasFieldSelection() ran
      byte-identical selectors, each with its own empty-argument guard, so a
      change to how a panel is addressed had to be made in more than one place
      to hold.

      findPanel() is now that one place, and both callers read as what they
      ask: does a panel exist, and does the panel that exists have a selection.

      The Build Landing Page panel also rendered unconditionally, though step 1
      offers CMS pages only when the Content Builder is installed. Without it
      the panel was unreachable markup in every admin; it now follows the same
      condition as the content type it serves.

      No behaviour change. 358 unit tests pass; phpcs --standard=Magento2
      reports the same 0 errors as before on the touched file.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d13ac

    • fix(translate): stop writing CMS page translations into a category

      translateForAllStores() routes every non-product entity through
      saveAttributeForStore()'s else branch, which loads a *category* by the entity's
      id. For a CMS page that id is a page_id, so translating page 8 for all store
      views wrote its translated meta_title onto category 8 — on this install, "New
      Luma Yoga Collection". A silent write into an unrelated record, no error, no
      trace.

      Extending the branch is not the fix either: cms_page has nowhere to put a
      per-store-view value. The table holds one row per page — title, meta and content
      are not store-scoped — and the store assignment lives in cms_page_store. A
      translated page in Magento is a separate page. So Translate stops claiming
      cms_page, which also narrows the MCP tool's entityType enum, and
      saveAttributeForStore() raises on anything it does not explicitly handle instead
      of guessing: entity ids collide freely across tables, and guessing corrupts.

      Generate keeps cms_page — that path is real. GenerateService::loadEntity()
      already resolves pages through PageRepository, so the AI button works on the SEO
      fields of the CMS page form. content_heading joins them: plain text, same
      mechanics as product-name, and the one obvious gap. title and identifier are
      left out on purpose (identifier drags url rewrites behind it), and content
      belongs to the Build Landing Page skill, which writes the whole document.

      Bulk is unaffected: BulkConsumer never handled generate/cms_page and still does
      not, so the wizard offers Build Landing Page alone for CMS pages.

      Verified against the compiled DI:

      cms_page fields content_heading, meta_title, meta_keywords, meta_description
      generate [product, category, cms_page]
      translate [product, category]
      build_page [cms_page]

      360 unit tests pass, including one that pins the corruption: translating a CMS
      page for all store views now raises and never touches the category repository.
      phpcs --standard=Magento2 reports no errors on the touched files. afe08f

    • fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word

      Picking CMS Pages still listed Generate Content and Translate: both declare
      cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
      in the previous commit let them through. Choosing either landed on an empty step
      4 with no step 5 and no Run button — the exact dead end this branch set out to
      remove, just reached by another route.

      A skill's own entity list says nothing about whether this screen can drive it.
      The wizard now asks the only thing that matters: does a step 4 panel exist for
      this (content type, task) pair. That is the condition for not dead-ending, and it
      stays true as panels are added or removed.

      getSkillEntityTypes() existed only to feed the old check and is dropped with it.

      Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
      skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
      schedules "AI Build Landing Page for 1 CMS page". 92afa6

    • fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word

      Picking CMS Pages still listed Generate Content and Translate: both declare
      cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
      in the previous commit let them through. Choosing either landed on an empty step
      4 with no step 5 and no Run button — the exact dead end this branch set out to
      remove, just reached by another route.

      A skill's own entity list says nothing about whether this screen can drive it.
      The wizard now asks the only thing that matters: does a step 4 panel exist for
      this (content type, task) pair. That is the condition for not dead-ending, and it
      stays true as panels are added or removed.

      getSkillEntityTypes() existed only to feed the old check and is dropped with it.

      Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
      skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
      schedules "AI Build Landing Page for 1 CMS page". 7413c9

    • Merge pull request #45 from breezefront/feat/bulk-cms-landing-page

      feat(bulk): build landing pages from the AI Bulk Action wizard eeabe7

    • fix(bulk): file the generated page draft under the scheduling admin

      BCB drafts are per user: Content/Load reads getDraft($pageId, 0, $currentUserId)
      and the collection filters on equality, so a draft written for anyone else is
      invisible in the editor. bcb_page_content.user_id is also a foreign key on
      admin_user, so the placeholder 0 the consumer was passing failed the insert
      outright — every build_page operation died with an integrity violation and no
      draft was ever stored. The unit tests missed it because DraftWriter is mocked.

      The scheduling admin's id now travels in the payload and DraftWriter requires it,
      rejecting 0 before touching the repository. Save refuses to schedule a page build
      when the current user cannot be identified, rather than queueing work that cannot
      land.

      Alongside that:

      - CMS Pages is only offered when the Content Builder is installed. Without it
      nothing can store the document, so the wizard used to run the whole batch
      through the AI only to fail on save. Step 1, the grids and the LISTING map are
      all derived from ViewData::getEntityTypes() now.
      - Step 4's heading follows the picked task: "Describe the page" for Build Landing
      Page instead of the field-list copy, which still said "Select attributes such
      as name, description, meta title".
      - Operator messages use real names: "AI Build Landing Page for 3 CMS pages"
      rather than "AI Build_page for 3 cms_page(s)".
      - Step 4 warns that the run replaces the current draft on every selected page.
      - Dropped the unused is_active column from the CMS page grid's select.

      Tests: DraftWriterTest covers both guards and the write, SaveTest covers the
      payload and the messages, BulkConsumerTest asserts the user id reaches the
      writer and that a payload without one fails the operation. 359 pass; phpcs
      --standard=Magento2 reports no errors. e8f294

    • feat(bulk): build landing pages from the AI Bulk Action wizard

      Step 2 listed every registered skill, so Build Landing Page was offered for
      products and categories. It declares cms_page and no attributes, leaving step 4
      empty and step 5 unreachable — the wizard dead-ended with no error.

      Tasks are now filtered by getSupportedEntityTypes() against the content type
      picked in step 1, and CMS Pages joins that step so the skill has a real path:
      a brief instead of the per-attribute field list, a CMS page grid, and no store
      view step (Content Builder documents are store-agnostic).

      The generated document is stored as the page's Content Builder draft. The BCB
      page flag is left alone: turning it on with nothing published renders a blank
      storefront page, so enabling the editor and publishing stay manual. The BCB
      dependency stays soft — the repository is resolved by name behind
      interface_exists(), like the component catalog reader.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> afe7f0

    • Version 1.0.13 43bc3d

    • Merge pull request #44 from breezefront/chore/mcp-graphql-review-followups

      chore(mcp): document graphql guard params, look the tool up by name 4aade7

    • chore(mcp): document graphql guard params, look the tool up by name

      Follow-ups from the review of #43.

      `assertReadOnly()`, `getDefinitionKeywords()` and `skipStringValue()` were
      missing `@param` annotations, which `phpcs --standard=Magento2` reports on
      `Model/McpServer.php` (18 warnings before, 15 after — the rest are pre-existing).

      `testGraphqlToolIsDocumentedAsReadOnly` picked the graphql tool out of
      `tools/list` by its position in the array. The position shifts as soon as another
      skill is registered, so the test would start asserting against the wrong tool
      rather than failing for a reason anyone could read. It now looks the tool up by
      name and asserts the entry exists.

      No behaviour change. ./bin/test unit: OK (339 tests, 680 assertions).

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e3bc09

    • Merge pull request #43 from breezefront/fix/mcp-graphql-read-only

      fix(mcp): reject mutations in the read-only graphql tool 84c5b5

    • fix(mcp): reject mutations in the read-only graphql tool

      The `graphql` MCP tool is documented as "read-only catalog access" in its own
      tool description, in the README and on breezefront.com, but `McpServer::graphql()`
      forwarded the `query` argument to the storefront GraphQL endpoint verbatim. No
      auth header is sent, so the document executes in guest scope — and guest scope is
      not read-only: Magento accepts `createEmptyCart`, `addProductsToCart`,
      `setGuestEmail` and the rest of the guest cart surface there. An agent connected
      over MCP could therefore write to the store through a tool advertised as read-only.

      `assertReadOnly()` now refuses any top-level definition other than `query` and
      `fragment` before the request goes out.

      The operation type is read from the definition position of the document rather
      than by searching for the word "mutation", which would be both bypassable and
      wrong. `getDefinitionKeywords()` walks the document tracking brace and paren
      depth, skips comments and string literals (including block strings), and records
      the keyword that opens each top-level definition — treating the anonymous
      shorthand `{ ... }` as `query`. So a leading newline, a `# comment`, an operation
      name, variable definitions with object default values or a directive cannot
      smuggle a keyword past it, and `mutation` used as a field name, an alias or
      inside a string argument is not mistaken for an operation.

      What the check covers: every definition in the document, so a mutation appended
      after a legitimate query is rejected regardless of `operationName`; mutations and
      subscriptions; type-system definitions (an allowlist, so unknown keywords are
      refused rather than passed through).

      What it does not cover: field-level read restrictions — any data guest scope can
      already read stays readable, which is what "read-only" claims and no more. It is
      not a GraphQL validator either; a malformed document (an unterminated string, say)
      may still be forwarded, and the endpoint rejects it before executing anything.

      `/llms.txt` is a separate case. Its "read-only" claim describes the store's own
      storefront `/graphql`, which Magento serves and this module cannot restrict, so
      the wording is corrected there instead of enforced: the endpoint is unauthenticated
      and does accept guest mutations, and the file documents read queries only.

      Also corrects the assumption in docs/plan/mcp-server.md that seeded this —
      "Catalog GraphQL is public/read-only, so no auth risk" conflated public with
      read-only.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a63cf6

    • Version 1.0.12 e71d2a

    • Merge pull request #42 from breezefront/feat/ai-landing-page-builder

      feat: build a landing page from a text brief (build_page skill) ee8d3c

    • fix(content-builder): report every repair and honour the config cache

      Review of #42 found four gaps between what the skill promises and what it
      does.

      Silent repairs. The validator clamped out-of-range numbers and padded short
      children arrays without saying so, while the skill states that every repair
      lands in "warnings" — a merchant saw a page that did not match the brief with
      no way to tell why. coerceNumber() now reports both the clamp and the
      fallback-to-default, and normalizeChildren() reports a short slot list. An
      omitted "children" stays silent: that is a deliberately empty container, not a
      mis-count.

      Grid rule keyed off a component name. rejectNestedContainers() compared
      $definition['name'] against the literal 'grid', so a rename in the Content
      Builder, or a second grid-like container, would drop the rule without a sound.
      Catalog::allowsOnlyLeafChildren() now derives it from the "slots" metadata of
      the layout preset, which is what actually makes those slots CSS grid areas.

      Catalog cache outlived the config cache type. Catalog took
      Framework\App\CacheInterface, so it kept a 24-hour copy even with the config
      cache disabled, while the Content Builder's own registry re-read the XML.
      Editing bcb_components.xml left the two disagreeing until a full cache:flush.
      Switched to Framework\Config\CacheInterface, matching ComponentRegistry.

      JSON extraction cut on the wrong brace. The old slice ran from the earliest
      '[' or '{' to the latest ']' or '}', so prose carrying a stray brace ("the
      layout (a {row} first): [...]") produced a broken span. jsonCandidates() now
      offers the fenced block, the bare reply and the widest run of each bracket
      kind separately, and decode() keeps the first that parses.

      Also covers Console\Command\BuildPage, which had no test: option handling,
      the stdout/stderr split that keeps `> page.json` valid, and the failure exit
      code.

      Suite: 305 tests, 578 assertions. phpcs --standard=Magento2: 0 errors.
      Verified end to end against the real 14-component registry — the repaired
      document renders through Swissup\BreezeContentBuilder\Model\ContentRenderer
      and now emits 14 warnings where it previously emitted 8.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c725c4

    • feat(content-builder): build a landing page from a text brief

      Adds a build_page skill that composes a Breeze Content Builder document
      out of the components a store actually has installed, so a merchant can
      describe a landing page instead of assembling it section by section.

      The skill returns {"components": [...], "warnings": [...]} for a CMS page
      and saves nothing — the caller decides whether to store the draft through
      the Content Builder's own save endpoint.

      No dependency on Swissup_BreezeContentBuilder: CatalogReader collects
      bcb_components.xml through Module\Dir\Reader, which is a file name rather
      than a class, so the AI module still installs on its own and the skill
      simply reports that no components are available.

      Model output cannot be trusted as-is. ComponentValidator checks it against
      the same catalog the prompt was built from — dropping unknown component
      types and props, snapping select values back into their option lists,
      clamping numbers to their declared range, and resizing the children arrays
      of containers to the slot count the type requires. Every repair is
      reported so the merchant can see why the result differs from the brief.
      Slot arithmetic lives in Catalog::resolveSlotCount and mirrors the admin
      editor's component-factory.js, keeping one source of truth for tabs, grid
      presets, columns and rows.

      Also exposed over MCP (brief instead of attribute) and through
      bin/magento breezeai:page:build, which prints the document on stdout and
      the warnings on stderr. --show-catalog dumps the catalog exactly as the
      model receives it.

      Vision input and URL screenshots are deliberately out of scope here: this
      lands the part that had to work first, which is a model emitting a
      schema-valid document.

      Refs #23

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1bf01f

    • Merge pull request #41 from breezefront/fix/translate-source-language-prompt

      fix(translate): translate from source language + module-logger dev logging (1.0.11) e7a8f5

    • Version 1.0.11

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> dca39c

    • fix(translate): translate from the selected source language, not just to target

      Bulk translation only told the model the TARGET language ("Translate to German"),
      never the source. Weak models (e.g. gpt-4o-mini) then left many words in the
      source language when translating Danish→German — option titles and values on the
      target store came out as a Danish/German mix, while English→German looked clean.

      - Pass the Source Store View through to the Translate skill (source_store_id) so
      the prompt is "translate from <source> to <target>".
      - Strengthen the prompt: instruct the model to translate EVERY word and leave
      nothing in the source language, while preserving numbers/SKUs/HTML.
      - Applies to EAV attributes, custom option titles/values, and media labels.

      Optional query/response logging now goes through swissup/module-logger
      (require-dev), gated by the BREEZEAI_TRANSLATE_DEBUG constant — a no-op unless
      that module is installed and the constant is set.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 520ff4

    • test(translate): drop error-silencing @ on mkdir in media scope test

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 78d334

    • Version 1.0.10

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 300ae5

    • fix(translate): write media labels to target store without full product save

      MediaTranslator persisted translated media gallery labels / video metadata with
      a full ProductRepository::save() of the product loaded at the target store. That
      full save re-processes the product's custom options through Magento core, and
      saving a product loaded at a non-default store propagates its option/value titles
      into other store views — silently overwriting the SOURCE store's custom option
      titles. Symptom: after a bulk translation that includes media fields (image /
      gallery labels, video), the source (e.g. Danish) store view shows English custom
      options, while the target store is translated correctly.

      Write the translated label / video metadata directly into the target store's
      rows of catalog_product_entity_media_gallery_value and
      catalog_product_entity_media_gallery_value_video (update-or-insert, inheriting
      position/disabled and provider/url/metadata from the default-store row). No
      ProductRepository::save(), so custom options and other stores are untouched —
      the media counterpart of the 1.0.9 OptionTranslator fix.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 6f808f

    • test(translate): integration guard for media translation vs option scope

      Reproduces the bug where translating media gallery labels corrupts the custom
      option titles of other store views. Creates a product with a media image plus a
      drop-down custom option carrying source-store title overrides, runs
      MediaTranslator for the gallery label, and asserts the option titles are
      unchanged in every store. Fails on the full-save implementation, passes with the
      direct target-store media write.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 5236d5

    • Version 1.0.9

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 195f5b

    • test(translate): integration guard for custom option store scope

      Runs the real BulkConsumer against the live DB with a stubbed Translate skill.
      Fixture mirrors the client's data: a drop-down custom option with per-store-view
      titles (source + target) and no default (store 0) row. Asserts translation lands
      on the target store scope only — target gets the translated titles, default
      scope stays absent, source store is unchanged. Fails on the old
      emulation/ProductRepository::save() path, passes with the direct target-store
      write.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 58a666

    • fix(translate): write custom option titles to target store scope only

      Bulk custom-option translation round-tripped through ProductRepository::save()
      under target-store emulation. Two problems:

      1. Scope leak — Magento core Option::_saveValueTitles()/Value::_saveValueTitles()
      loop over [DEFAULT_STORE_ID, storeId]. When an option has no default-scope
      title row, the store-0 iteration inserts the current (translated) title into
      store 0, and the default store view (which resolves titles via the store-0
      fallback) then displays the translation. Emulation cannot prevent this — it
      happens inside the same save().
      2. Dropped writes — a product reloaded via getById() does not carry
      canSaveCustomOptions=true, so in a queue-consumer context save() silently
      ignores the option-title changes.

      Write each translated option/value title directly into the target store's row
      via insertOnDuplicate on catalog_product_option_title /
      catalog_product_option_type_title. The unique (option_id, store_id) /
      (option_type_id, store_id) keys make it a single-row upsert that never touches
      store 0 and does not depend on the product save persisting options — the custom
      option counterpart of the 1.0.7 category fix that replaced
      CategoryRepository::save() with CategoryResource::saveAttribute().

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 86c13e

    • Version 1.0.8

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> c293f3

    • Merge pull request #38 from breezefront/fix/llms-txt-markdown-links

      fix(llms-txt): emit Markdown links so Agentic Browsing audit passes a24f22

    • fix(llms-txt): emit Markdown links so Agentic Browsing audit passes

      PageSpeed's Agentic Browsing "Agent Accessibility" audit failed with
      "llms.txt does not follow recommendations - File does not appear to
      contain any links." The generated llms.txt had a valid H1 and summary
      but wrote every endpoint as plain text / inline code, so it contained
      zero Markdown links.

      Convert the GraphQL endpoint, schema introspection, and BreezeAI
      content-generation endpoint lines into Markdown links. Add a regression
      unit test asserting the output contains at least one [text](url) link.

      Fixes #37

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 49206f

    swissup/module-breeze-layout-builder — 1.2.0 (was 1.0.0)
    • Version 1.2.0 24fff8

    • Added Contact Us page support d64e46

    • Config option for upcoming TaxVAT switcher in theme header 7f31bd

    • Version 1.1.1 5a2bcc

    • Improved drag-and-drop in the components tree 05a7f1

    • Version 1.1.0 1bcf8f

    • Added installer cfe73f

    • Disable Header/Footer feature 544da0

    • Fix Header logo publishing 71e7dd

    • Fixed header/footer publishing and preview 237ea4

    • Moved header/footer configuration to separate page b9b4cb

    • WIP: header/footer configs save, apply, and restore 127237

    • Added special Header and Footer items with their layout containers adbb7b

    swissup/module-easycatalogimg — 1.5.15 (was 1.5.14)
    • Version 1.5.15 a12d86

    • Fixed inability to create new category on PHP 8.5. Closes #17 a5d3fe

    swissup/module-gdpr — 1.9.2 (was 1.9.1)
    swissup/module-navigationpro — 1.19.3 (was 1.18.0)
    • Version 1.19.3 421267

    • Do not use `is_active` from remote entity when creating new item
      Since we have new option `use_remote_include_in_menu`, use it instead
      of syncing `is_active` on item creation. 7c215a

    • Fixed not working ribbon for vertical menu 8fdb50

    • LESS 5.3.0 compatibility 922d05

    • Version 1.19.2 c00164

    • Fixed unresponsive browser when toggling 7000 categories ef21c1

    • Version 1.19.1 b7ca42

    • Added ability to select all items in "Mass Edit" form 6f9251

    • Version 1.19.0 cb840c

    • Added ability to respect "Include in menu" setting 879226

    swissup/module-pagespeed — 1.19.0 (was 1.18.0)
    • Version 1.19.0

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 94fd39

    • fix(preload): cap font preloads and fix @font-face parsing (#117)

      Font preloads had no cap and no notion of whether a face blocks rendering, while the url() parsing dropped most real-world forms and could aim a preload at a nonexistent host. Measured on a live Argento product page, 138 KB of preloaded fonts plus an eager 219 KB footer PNG stretched a 68 KB LCP JPEG into a 3.1 s download; removing them took LCP from 3.83 s to 2.11 s on Slow 4G.

      - pagespeed/main/font_preload_limit (default 0, opt-in) caps the preloads per response
      - pagespeed/main/font_preload_ignore skips urls by substring, for icon fonts
      - candidates ranked by effective font-display: render-blocking first, then fallback, then swap; optional never preloaded
      - url() values unquoted, fragments dropped, queries kept, hrefs mirroring the protocol-relative form the delivered css uses
      - @font-face delimited and read on a comment-masked copy, with offset-based injection
      - collection armed one-shot per process() call, so it no longer depends on caller order

      Preload gained a Config constructor argument, so deploys need setup:di:compile.

      Fixes #117 9a26a2

    • fix(preload): make font preloading from stylesheets opt-in (#117)

      Default font_preload_limit goes from 2 to 0.

      Fixing the url parsing means stores whose @font-face urls are quoted or
      absolute — most of them — start getting font preloads they never had,
      because the old code silently dropped those urls. Shipping a default of 2
      would therefore add up to two High priority requests into the LCP window
      on upgrade, which is the exact regression this branch exists to remove:
      on the store that prompted it, 138 KB of preloaded fonts cost 1.7 s of
      LCP. With font-display:swap injected the fonts do not block rendering
      anyway, so the bandwidth buys little.

      The budget and the ranking stay; an admin who knows which weight renders
      above the fold raises it to 1 or 2.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6c93d

    • fix(preload): make font collection one-shot per armed call (#117)

      Sixth review pass pointed out that CssResolver calls process() without
      ever calling setResponse(), so merged stylesheets never contribute
      candidates — my claim that the ranking converges across every stylesheet
      was wrong. Only the critical style blocks feed it.

      Rather than widen collection, which would pull fonts out of every merged
      stylesheet in a change meant to cap them, the gate is now honest and
      deterministic. setResponse() arms exactly the next process() call, so a
      bare process() never pushes. Left armed, whether a merged stylesheet
      contributed depended on the order the two callers happened to run in for
      a given request, which made the preload set vary between requests.

      CriticalCss already chains setResponse() before each process() call, so
      production behaviour is unchanged. The multi-call tests now re-arm the
      same way, which also makes them mirror that caller.

      Also pins the two remaining shapes of the mask's documented limit — a }
      and a ; inside a quoted value — as misses rather than corruption.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 95ecf9

    • docs(preload): record the comment mask's limit and pin its failure mode (#117)

      The mask cannot tell a real comment from a comment opener inside a css
      string or data uri, so a fake opener can blank a whole @font-face. That
      limit is now stated at maskCssComments(), together with why a css
      tokenizer is not worth carrying to read one descriptor.

      The test pins the direction the failure takes: the face is skipped, so it
      gets no injection and contributes no preload candidate, and the
      stylesheet passes through unchanged. Skipping is acceptable; rewriting a
      stylesheet we could not read is not.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 48133a

    • fix(preload): delimit @font-face on a comment-masked copy (#117)

      Fifth review pass.

      A } inside a comment ended the face body early for the plain regex, so
      @font-face{/* } */src:url(a.woff2)} yielded the body "/* " — the url was
      never collected, and worse, the injection then ran
      str_replace('/* ', 'font-display:swap;/* ') across the whole stylesheet.
      The truncation predates this branch; the injection path made it harmful.

      Comments are now blanked to spaces of the same byte length before the
      faces are delimited, so a masked } cannot end a body while every offset
      still points into the original text. Injection splices at those offsets,
      from the end so earlier ones stay valid. That also fixes a second
      injection bug: two identical face bodies in one stylesheet had their
      swap injected by str_replace on the body text, which rewrote both and
      then did it again on the next iteration.

      Invalid duplicate descriptors no longer beat a valid one either. The
      browser drops invalid declarations at parse time, so
      font-display:optional;font-display:garbage stays optional; the last
      valid value wins and auto applies only when nothing valid was declared.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7b87f1

    • fix(preload): validate the whole font-display value (#117)

      Fourth review pass. The value pattern captured the leading alphabetic
      token, so font-display: optional garbage read as optional and its
      preload was suppressed — but the whole value is invalid css and the
      browser falls back to auto, which blocks. Same for a descriptor written
      with !important, which is dropped entirely.

      The value is now taken up to the declaration terminator, trimmed, then
      checked against the five valid keywords, so anything invalid resolves to
      auto as the browser does.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9ca057

    • fix(preload): read @font-face without its comments (#117)

      Third review pass. A css comment may sit wherever whitespace may, so
      font-family:A;/* keep */font-display:optional; hid the declaration from
      the boundary matcher. The face then got a swap injected in front of a
      declaration that stays effective, and was ranked as swap and preloaded
      even though the author marked it optional.

      Every read of a @font-face body now works on a comment-free copy, which
      fixes the same class of bug in url extraction too: a commented-out
      url(old.woff2) was being harvested as a preload candidate and could
      spend the budget on a file the browser never requests. The injection
      itself still targets the original text.

      While there: a face declaring font-display twice resolved to the first
      declaration, where the cascade takes the last.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d0f213

    • fix(preload): match font-display as a declaration, mirror scheme case (#117)

      Second review pass found two more holes.

      The injection guard tested for the substring "font-display" anywhere in
      the @font-face body, so a family named after the descriptor or a font
      path containing the word suppressed the swap injection and left the face
      blocking. Both the guard and the value read now require the descriptor
      at a declaration boundary — start of the body, or after a previous
      declaration.

      Scheme stripping accepted any spelling, but the css rewrite in process()
      is a case-sensitive str_replace: HTTPS://host/f.woff2 stays absolute in
      the delivered css while the preload became protocol-relative, which is a
      different url on an http storefront. The href now mirrors that replace
      exactly and keeps an unusual spelling absolute. The http(s) check also
      moved off filter_var, which accepts file:// happily.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ba6b69

    • fix(preload): move the font budget to Preload and tighten parsing (#117)

      Review follow-up. Four defects in the first pass:

      Selection ran per process() call, but CriticalCss calls the improver
      once per critical <style> block and the css resolver plugin once per
      stylesheet. A swap face in the first block could spend the whole budget
      before a render-blocking face in a later one was seen, and the same href
      in two blocks consumed two slots. Candidates now accumulate in Preload
      and the ranked selection happens in getAssets(), once, when every
      stylesheet has been through the improver. Selection is pure, so
      repeated getAssets() calls agree.

      Deduplication kept the least urgent rank. Wrong: when one woff2 backs
      two families, the family that blocks still blocks, so the file is
      render-critical and the most urgent declaration has to win.

      The font-display regex was case-sensitive and matched only the
      alphabetic prefix of a value, so FONT-DISPLAY: OPTIONAL read as
      render-blocking while the invalid optional-invalid read as optional and
      was skipped. It is now case-insensitive, takes the whole token, and
      resolves anything outside the five valid values to auto.

      Preload hrefs forced https. process() rewrites absolute css urls to
      protocol-relative, so on an http storefront the preload pointed at a
      url the stylesheet never requests. Hrefs now mirror the delivered css:
      protocol-relative for absolute and protocol-relative sources,
      root-relative left alone, non-http schemes skipped.

      Also adds declare(strict_types=1) to the new test file.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8b22be

    • fix(preload): cap font preloads and fix url() parsing (#117)

      Improver pushed a preload for every woff2 it could parse out of a
      stylesheet, with no cap and no check on whether the face blocks
      rendering. Chrome fetches preloaded fonts at High priority, so each one
      competes with the LCP image on the same connection and fetchpriority
      cannot outrank it. Measured on a live Argento product page: dropping
      138 KB of preloaded fonts (plus an eager 219 KB footer PNG) took LCP
      from 3.83 s to 2.11 s on Slow 4G.

      The url handling was broken in both directions. str_replace('//', '/')
      ran before the validity check, so absolute and quoted urls were
      silently dropped, while a root-relative url became https://<first path
      segment> — a preload aimed at a host named "static". The cache-busting
      query was stripped too, so a preload that did survive missed the css
      request and the font downloaded twice.

      - pagespeed/main/font_preload_limit (default 2, 0 disables) caps the
      preloads per response, shared across every stylesheet processed
      - pagespeed/main/font_preload_ignore skips urls by substring, for
      decorative icon fonts
      - candidates are ranked by effective font-display: render-blocking
      first, then fallback, then swap; optional is never preloaded
      - url() values are unquoted, fragments dropped, queries kept, and
      root-relative hrefs left alone; hrefs are deduped

      The budget lands together with the parsing fix on purpose: fixing the
      parsing alone would start emitting the preloads that were being dropped
      and ship the regression by default.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c16e23

    • Version 1.18.8 953e26

    • fix(preload): crossorigin="anonymous" is font-only and host-independent (#115)

      crossorigin was added to any third-party resource hint based on host alone.
      It switches the hint to a CORS-mode fetch, so a third-party script whose
      server sends no Access-Control-Allow-Origin had its response blocked
      (s.kk-resources.com/leadtag.js, #115). Even a permissive third party is hurt:
      a CORS-mode hint never matches the no-cors <script> request, so the file is
      fetched twice.

      The host check was wrong in the other direction too — @font-face fetches in
      CORS mode even from the store's own domain, so every same-origin font hint
      was emitted without crossorigin and could not be reused by the real font
      request.

      crossorigin now depends on the asset type alone: fonts always, whatever host
      they come from; scripts, styles and images never — they mirror the origin
      element instead. An explicit crossorigin from the asset definition or from an
      existing link is preserved; the rule only governs adding one. Applied in both
      createPreloadLinkElement() and updatePreloadLinkInPlace(), so preload links
      the theme or a tag manager already placed in <head> follow it too.

      With the host out of the decision, isThirdPartySource(), getHost() and
      getBaseUrlHost() are dead and removed, along with the StoreManagerInterface
      constructor argument and the try/catch that only swallowed Laminas URI parse
      errors.

      Tests: 8 regression cases in AddLinkPreloadTest — third-party script, style
      and image get no crossorigin; third-party and same-origin fonts keep it;
      explicit crossorigin survives; existing in-head links follow the same rule.

      Fixes #115
      Closes #116 41be41

    • fix(preload): make font crossorigin host-independent (#115)

      Review catch on the previous commit: @font-face fetches in CORS mode even
      when the font sits on the store's own domain, so gating crossorigin on
      "third-party host" left every same-origin font hint without the attribute
      — the hint then cannot serve the real request and the font is downloaded
      twice. That is the same failure this PR set out to remove, only pointed
      at local fonts instead of third-party scripts.

      crossorigin now depends on the asset type alone: fonts always, everything
      else never. With the host no longer part of the decision, the whole
      isThirdPartySource() / getHost() / getBaseUrlHost() chain is dead, and
      with it the StoreManager dependency and the try/catch that only existed
      to swallow Laminas URI parse errors.

      - needsCrossorigin(string $as) — type-only decision
      - Drop isThirdPartySource(), getHost(), getBaseUrlHost(), $baseUrlHost
      - Drop the StoreManagerInterface constructor argument (DI resolves the
      constructor by reflection, so no di.xml change) and the store mocks it
      required in 28 test construction sites
      - Drop the now-unreachable try/catch around both call sites

      Tests: 2 more cases — a same-origin font hint, and a same-origin font
      preload link already in <head>, both must keep crossorigin.

      709 unit, 34 integration — all green. 225428

    • fix(preload): limit crossorigin="anonymous" to font hints (#115)

      A third-party resource hint got crossorigin="anonymous" based on host
      alone, with no regard for the asset type or for what the live tag asked
      for. crossorigin switches the hint to a CORS-mode fetch, so for
      s.kk-resources.com/leadtag.js — a script whose server sends no
      Access-Control-Allow-Origin — the browser blocked the response outright.
      Even a permissive third party is hurt: the CORS-mode hint never matches
      the no-cors <script> request, so the file is fetched twice.

      Only fonts are always fetched in CORS mode and therefore need the
      attribute. Scripts, styles and images must mirror the origin element,
      never invent it.

      - Add CROSSORIGIN_TYPES = ['font'] and needsCrossorigin($as, $href)
      - Use it in both createPreloadLinkElement() and
      updatePreloadLinkInPlace(), so the rule also covers preload links the
      theme or a tag manager already placed in <head>
      - An explicit crossorigin from the asset definition or the existing link
      is still preserved — the rule only governs adding one

      The blanket rule dates back to e7a35a0 (2020), whose own comment noted
      "add warning and more requests but increase score. why???".

      Tests: 6 regression cases in AddLinkPreloadTest — third-party script,
      style and image get no crossorigin; fonts keep it; explicit crossorigin
      survives; existing in-head preload links follow the same rule. Three of
      them reproduce the reported markup exactly against the old code.

      Fixes #115 cc021a

    • Version 1.18.7 d50f2d

    • chore: add repo-specific code-review skill for GitHub Copilot (#114)

      GitHub's PR reviewer suggests adding an agent skill or MCP servers for
      context-aware reviews. Give it one: the strategy-chain / patch-tracking
      architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
      matches, untracked DOM mutations) that a generic review won't know to
      look for, on top of this repo's Magento 2 / phpcs conventions. 5021e3

    • chore: add repo-specific code-review skill for GitHub Copilot

      GitHub's PR reviewer suggests adding an agent skill or MCP servers for
      context-aware reviews. Give it one: the strategy-chain / patch-tracking
      architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
      matches, untracked DOM mutations) that a generic review won't know to
      look for, on top of this repo's Magento 2 / phpcs conventions. 005217

    • fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036) (#113)

      PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
      against picking the wrong element by class alone, but SmartTagStrategy runs
      earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
      element whose attributes cleared a 70% similarity score, with no check for
      a later, better (or exact) candidate.

      Two product images in the same category grid share nearly every attribute
      (class, style, loading, decoding, width, height, sizes, srcset) and differ
      only in src/alt, comfortably clearing 70% similarity - so a patch meant for
      the second image could land on the first one instead, reproducing the same
      image-swap symptom the 1.18.3 fix was supposed to close.

      SmartTagStrategy now scores every candidate and applies the best-scoring one
      only when it isn't tied with another - same "lose the optimization rather
      than corrupt the page" rule AttributeBasedStrategy already uses for class.

      Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
      testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
      first-match-wins behavior on a genuine tie, which is no longer correct. 0cf639

    • fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036)

      PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
      against picking the wrong element by class alone, but SmartTagStrategy runs
      earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
      element whose attributes cleared a 70% similarity score, with no check for
      a later, better (or exact) candidate.

      Two product images in the same category grid share nearly every attribute
      (class, style, loading, decoding, width, height, sizes, srcset) and differ
      only in src/alt, comfortably clearing 70% similarity - so a patch meant for
      the second image could land on the first one instead, reproducing the same
      image-swap symptom the 1.18.3 fix was supposed to close.

      SmartTagStrategy now scores every candidate and applies the best-scoring one
      only when it isn't tied with another - same "lose the optimization rather
      than corrupt the page" rule AttributeBasedStrategy already uses for class.

      Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
      testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
      first-match-wins behavior on a genuine tie, which is no longer correct. e769d6

    • Version 1.18.6 9678b2

    • fix(test): resolve module and Magento root by walking up, not fixed depth (#112)

      bin/test assumed the module sits exactly 5 levels below the docker root
      and always tested the canonical vendor/swissup/module-pagespeed checkout
      regardless of where the script was invoked from. Any checkout at a
      different depth (a git worktree, a standalone clone) couldn't run the
      suite, and even a resolved root would still test the wrong code.

      Walk up from the script location to find the docker root (bin/clinotty +
      src/), derive the module's path relative to that root, and invoke phpunit
      via its absolute in-container path so a differing checkout depth no
      longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
      canonical path when the checkout isn't under src/.

      tests/bootstrap.php had the same fixed-depth assumption and failed with
      an unhelpful "Failed opening required ..." error. Walk up for
      vendor/autoload.php instead, throw a RuntimeException naming the
      searched path when not found, and derive the Logger/Image/minify
      autoload map entries from the resolved root.

      Fixes #111 f60df9

    • fix(test): resolve module and Magento root by walking up, not fixed depth

      bin/test assumed the module sits exactly 5 levels below the docker root
      and always tested the canonical vendor/swissup/module-pagespeed checkout
      regardless of where the script was invoked from. Any checkout at a
      different depth (a git worktree, a standalone clone) couldn't run the
      suite, and even a resolved root would still test the wrong code.

      Walk up from the script location to find the docker root (bin/clinotty +
      src/), derive the module's path relative to that root, and invoke phpunit
      via its absolute in-container path so a differing checkout depth no
      longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
      canonical path when the checkout isn't under src/.

      tests/bootstrap.php had the same fixed-depth assumption and failed with
      an unhelpful "Failed opening required ..." error. Walk up for
      vendor/autoload.php instead, throw a RuntimeException naming the
      searched path when not found, and derive the Logger/Image/minify
      autoload map entries from the resolved root.

      Fixes #111 c8861a

    • fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack (#110)

      fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
      into two scripts specifically so the assignment stays synchronous. But that split runs before the main
      processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
      that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
      rewrites it to the deferred type exactly like the pre-split single script was, leaving
      window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
      TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.

      - shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
      write) and skips retyping/moving it.
      - fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
      gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
      move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
      the split entirely.

      Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
      document, one reproducing the bug when it doesn't).

      Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> 28291b

    • fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack

      fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
      into two scripts specifically so the assignment stays synchronous. But that split runs before the main
      processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
      that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
      rewrites it to the deferred type exactly like the pre-split single script was, leaving
      window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
      TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.

      - shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
      write) and skips retyping/moving it.
      - fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
      gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
      move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
      the split entirely.

      Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
      document, one reproducing the bug when it doesn't).

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8ad474

    • Merge pull request #109 from swissup/claude/inspiring-elbakyan-cfe2e6

      fix(dom): keep the page's own <meta charset> during optimization 3f00d8

    • fix(dom): keep the page's own <meta charset> during optimization

      TrackedDomDocument injects <meta http-equiv="Content-Type" content="text/html;
      charset=utf-8"> before loadHTML() so libxml interprets bytes as UTF-8, then
      strips it again in its saveHTML() override.

      addUtf8Charset() skips the injection when the page already declares a charset,
      but removeUtf8Charset() stripped unconditionally, and stripMetaCharset()'s last
      pattern matches *any* meta carrying charset=utf-8. On a page that declares its
      own charset nothing was injected, yet the page's own declaration was deleted.
      Magento\Framework\View\Page\Config\Renderer renders <meta charset="%content"/>
      from the `charset` metadata on every page, so every optimized page lost its
      charset declaration.

      Not a libxml artifact and not the patch/strategy layer: libxml round-trips the
      tag intact, the DOM node is never detached, and the loss reproduces on a bare
      parse with zero patches recorded. It happened purely in the string
      post-processing of saveHTML(), which is why the <head> opening tag stays
      byte-identical.

      Impact is usually masked because PHP appends charset to the Content-Type header
      when default_charset is set. With default_charset empty the header carries no
      charset and the meta is the only encoding signal, leaving the browser to guess
      from its locale. Lighthouse also reports "Charset declaration is missing or
      occurs too late", which a page-speed module should not be causing.

      Fix: record what addUtf8Charset() actually inserted ('none', 'meta', 'head' or
      'wrapper') and only strip when we injected something. Fragment-mode structure
      restoration still runs, so the injected helper tag is still cleaned up.

      Regression coverage in CharsetMetaPreservedTest pins the behaviour and the
      cause: the charset survives DeferJs on the minisport production fixture, stays
      the first meta in <head> and inside the first 1024 bytes, a non-UTF-8
      declaration is never rewritten, the injected helper tag still never leaks, and
      two guards assert that libxml preserves the tag and that the loss is
      patch-independent so no replacement strategy can be blamed later.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1951

    • Version 1.18.5

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b3dcbb

    • Merge pull request #108 from swissup/test/production-head-order-27428

      test(defer): cover the stock-RequireJS production head 479d3d

    • test(defer): cover the stock-RequireJS production head

      Every real-page fixture in this suite is a Breeze page, and Breeze never renders
      Magento's RequireJS bootstrap. Nothing covered the configuration the TTF-27428
      failures live in, which is how 1.18.2 and 1.18.3 both shipped with a broken
      bootstrap order.

      Rather than hand-write the head, derive the asset order from the code that
      produces it in production: Magento\RequireJs\Block\Html\Head\Config and our own
      Block\Html\Head\Scripts, both run against a real asset Collection with
      minification reported as enabled and JS bundling on. A developer-mode install
      cannot produce this order at all -- Minification::isEnabled() short-circuits on
      the app mode, so requirejs-min-resolver.js is never even rendered.

      The resulting order matches what the affected store serves, and running the
      released builds through it reproduces each shipped regression:

      1.18.2 the global `var require = {baseUrl}` block and
      requirejs-min-resolver.js are left behind while require.js, mixins and
      requirejs-config stay in <head>. The global object then overwrites an
      already-initialised window.require, which is the reported
      "require.config is not a function", and the .min suffix is never
      applied, which is the reported mage/requirejs/static.js 404.
      5 of 7 tests fail.
      1.18.3 our bundle pool and override.js are relocated and made async.
      3 of 7 tests fail.
      1.18.4 passes.

      Refs TTF-27428

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d2c33

    • Merge pull request #105 from swissup/fix/fuzzy-content-strategy-head-corruption

      fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy (#104) ac1fae

    • fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy

      Two bugs in apply() caused a last-resort patch to replace a <meta>
      tag in <head> with a <body> element (e.g. <img>) when the patched
      node's attribute value (alt, src, class ≥ 10 chars) appeared earlier
      inside a head meta description's content="..." attribute.

      Fixes #104 (radiatorkopen.nl: meta description overwritten by <img>).

      Changes:
      - Restrict strpos search to the <body> substring; <head> content is
      never a valid match target for body-element patches.
      - After expanding to the surrounding element, reject the match if the
      element tag name differs from the patched node's tag name
      (<meta> ≠ <img> → continue to next candidate).

      Tests:
      - testDoesNotReplaceHeadMetaWhenAltTextAppearsInMetaDescriptionFirst
      reproduces the exact radiatorkopen.nl scenario (was failing).
      - testReturnsNullWhenOnlyMatchIsInsideHead covers the variant where
      the only document match is inside <head> (must return null). 772112

    • Version 1.18.4

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> aa4b80

    • Merge pull request #102 from swissup/fix/requirejs-bundle-pool-order-27428

      fix(defer): keep the bundle pool and our RequireJS patches in place 7aeb97

    • test: repair the typo'd DOCTYPE in the offset fixture

      `<!` followed by a space is not a doctype, so libxml parsed it as a bogus
      comment. With LIBXML_HTML_NOIMPLIED the parser then had no document element to
      attach the real <html> to, emitting "misplaced <html> tag"; Magento's unit
      bootstrap promotes that warning to an exception, so the test errored on every
      run instead of asserting anything.

      Also close the space inside the require.js src in the same fixture. That one did
      not fail the test — the assertions only count //script[@src] and compare their
      offsets — but the path was meaningless as written.

      The three other fixtures in this file already spell the doctype correctly.

      Fixes #103

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> cdbf37

    • fix(defer): narrow the RequireJS patch guard to override.js

      The bootstrap guard matched the whole Swissup_Pagespeed/js/lib/requirejs/
      directory, which pinned preload-all-scripts.js in <head> as well. That file is
      not bootstrap: addPreloadAllScripts() add()s it at the end of the asset
      collection rather than inserting it after the min resolver, and its entire body
      runs inside a window load handler, so it cannot race require.config(). Guarding
      it only costs a render-blocking head script on stores with Force RequireJS
      Loading enabled.

      Match 'Swissup_Pagespeed/js/lib/requirejs/override' instead, and correct the
      comment: it described insert()-after-min-resolver placement that applies to
      override.js alone.

      testPreloadAllScriptsStaysOptimizable pins the reverse direction - the file must
      still move to body end and take async past the counter threshold. It fails with
      the directory-wide prefix and passes with the narrowed one.

      Also assert asset presence while collecting positions in
      testLiveHeadKeepsBootstrapChainInOrder: strpos returns false for a dropped
      asset, which asort() sorts first, reporting a bogus ordering violation instead
      of the missing script.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e0e10

    • fix(defer): keep the bundle pool and our RequireJS patches in place

      The 1.18.2 bootstrap guard was meant to cover the bundled module pool, but
      'js/bundle/bundle' only matches Magento's own bundle0.js. Advanced JS Bundling
      names its pool after the layout handle -- default-bundle0.js,
      cms-page-view-bundle0.js -- so none of our bundles ever matched, and
      Swissup_Pagespeed/js/lib/requirejs/override.js was not listed at all.

      Combined with the 1.18.2 counter change, where skipped-but-executable scripts
      consume a slot, these scripts crossed the jsCounter > 5 threshold and started
      getting async -- something that did not happen before 1.18.2. Both call
      require.config(): the bundle pool registers the jsbuild sources, and override.js
      wraps require.config() and must be installed before requirejs-config calls it.
      So on a store running Advanced JS Bundling or Interactive Defer, 1.18.3
      introduced a fresh instance of the very failure class it fixed.

      Match the whole js/bundle/ directory instead of a filename prefix (anything
      living there is a module pool by construction) and add our own RequireJS patch
      directory.

      Regression coverage uses the head sequence dev2.dlastore.com actually serves,
      with both features on: six ignored third-party inline blocks, the global require
      config, then Magento's bootstrap interleaved with the bundle pool. The fixture
      asserts the whole chain stays ahead of the body content, carries no async/defer,
      and keeps its document order. It fails in seven places without the fix.

      Refs TTF-27428

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1b477c

    • Version 1.18.3

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 82a806

    • Merge pull request #101 from swissup/fix/requirejs-bootstrap-coverage

      fix(defer): cover every RequireJS bootstrap asset and stop counter drift 5a4389

    • fix(defer): count only executable scripts and cover global require config

      Three follow-ups on the previous commit in this branch.

      Counting every skipped script restored bootstrap coverage but also counted
      inert blocks. text/x-magento-template, text/html and JSON payloads are never
      executed by the browser, and Magento emits a handful of them on most pages
      (minicart, checkout, swatches). Counting them inflated jsCounter and pushed
      later scripts past the async (> 5) and third-party defer (> 1) thresholds, so
      scripts that used to stay synchronous started getting async and losing their
      execution order. jsCounter now advances only for script tags the browser
      actually runs: an empty type, a JS MIME type, or the configured delayed type
      (those are executed by the delayed-script runtime, so they keep their slot).

      The inline bootstrap matcher only recognized the require.config() call. The
      other legitimate form, a global `var require = {...}` / `window.require =
      {...}` object emitted before require.js loads, carries the same
      baseUrl/paths/map data and is under the same ordering constraint, so it was
      still relocated behind the content and reproduced TTF-27428 on themes that
      use it. Added REQUIREJS_INLINE_GLOBAL_PATTERN.

      Inline matching is textual, not parsed, so a script mentioning the config
      statement inside a string or a comment is opted out too. That direction is
      deliberate - a missed optimization instead of a broken RequireJS bootstrap -
      but it was documented as if only src matching had that property. Corrected the
      docblock and pinned the behaviour with a test. Also gated both patterns behind
      a stripos() check, since the matcher runs on every inline body including the
      multi-hundred-kilobyte checkoutConfig payload.

      Tests: 35 -> 42. Verified against the pre-fix code: 6 failures, one per gap
      (inert-counter case plus five global-config spellings). With this change
      705/705 green, phpcs 0 errors.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ee4c79

    • fix(defer): cover every RequireJS bootstrap asset and stop counter drift

      PR #99 protected only requirejs/require.js, mage/requirejs/mixins.js and
      requirejs-config.js. Magento renders up to eight bootstrap assets in <head>
      (Magento\RequireJs\Block\Html\Head\Config::_prepareLayout), and the rest were
      still relocated to the end of <body>:

      - requirejs-min-resolver.js patches ctx.nameToUrl to append the .min sign and
      is emitted whenever JS minification is on, i.e. in production
      - mage/requirejs/baseUrlResolver.js + requirejs-map.js install the URL map
      - mage/requirejs/static.js and js/bundle/bundle*.js define the module pool

      Moving any of them behind the content reproduces the same failure class as
      TTF-27428: module ids resolve against the bare baseUrl and static URLs are wrong.

      Replace the five ignore signatures with isRequireJsBootstrap(), which matches
      external assets on src only (an unrelated script merely mentioning a bootstrap
      path in its code is no longer opted out of deferring) and inline blocks on the
      require.config()/requirejs.config() call itself, tolerant to any spacing.

      Also increment jsCounter for skipped scripts. Without it every ignore signature
      shifted the unpack/async/defer thresholds of later scripts: with PR #99 only
      three of six eligible inline scripts were still delayed on a page whose <head>
      carries the bootstrap.

      Extend RequireJsBootstrapOrderRegressionTest with a provider over all eight
      minified asset names, seven inline-config spellings, a guard that a script only
      mentioning a bootstrap path stays optimized, and a counter-stability assertion.
      Against the previous code the new cases produce 9 failures; with this change the
      suite is 698/698 green.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8ae870

    • Merge pull request #100 from swissup/fix/category-image-swap-xss-14036

      fix(image): keep the processed marker out of the DOM (#xss-14036) 374f33

    • fix(dom): rank class last in attribute matching (#xss-14036)

      Follow-up on review of the processed-marker fix.

      AttributeBasedStrategy tried `class` before `src`, which is what let the
      production patch land on a foreign product image in the first place. The
      ambiguity guard alone does not cover a class that matches exactly one -
      but wrong - element, so order the attributes from most to least
      identifying and keep `class` as the last resort. Testing for a second
      match from the first match's offset replaces the full-document
      preg_match_all, and a skipped match is now logged instead of silently
      dropping the optimization.

      ProcessedNodeRegistryTrait no longer requires the consuming class to call
      an initializer from its constructor: the storage is created on first use,
      so a reader can never hit an uninitialized typed property. Added __clone
      so a cloned document gets its own registry, and documented that entries
      are keyed by PHP object identity and do not survive a node being rebuilt
      from HTML.

      PipelineCategoryImageSwapTest claimed to reproduce the image swap. It
      does not: in a fixture that small OffsetBasedStrategy still resolves the
      orphaned patch group, so AttributeBasedStrategy is never reached and no
      image is swapped. Its src assertions are non-regression guards; what is
      actually red before the fix is the dropped Lcp patch and the leaked
      marker. Docblock now says so, and the swap stays pinned at unit level.

      Tests: 673 tests, 2030 assertions, OK.
      phpcs --standard=Magento2 on the touched files: 0 errors.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e9d02

    • fix(image): keep the processed marker out of the DOM (#xss-14036)

      On a category page the first two product images were rendered with each
      other's <img> tag. Reproduced on mcprod.boutinot.com/wines.html: item 1
      links to CHV722 but shows CHV726, item 2 the reverse. Only the two images
      that LCP marks with fetchpriority="high" were affected, and the wrong
      output was then stored in the full page cache.

      markImageAsProcessed() set data-pagespeed-processed through the raw
      \DOMElement::setAttribute(), so the mutation was never recorded as a patch.
      That made the oldHtml of every later patch of the same node stale, so
      PatchGrouper could not chain it and the patch fell through to the fuzzy
      replacement strategies. AttributeBasedStrategy then matched by
      class="product-image-photo" - shared by every product image - and rewrote
      the first product image with the second product's HTML.

      Keep the marker in an SplObjectStorage registry on TrackedDomDocument
      instead of in the DOM, and stop AttributeBasedStrategy from matching by a
      class that several elements share. A patch that fails to apply only loses
      an optimization; a patch applied to the wrong element corrupts the page.

      Side effect: data-pagespeed-processed no longer appears in page output.
      Nothing in JS, templates or tests reads it.

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> c5b7a3

    • Version 1.18.2 47a960

    • Merge pull request #99 from swissup/fix/requirejs-bootstrap-load-order

      fix(defer): keep RequireJS bootstrap in place so map/paths apply first 9504be

    • fix(defer): keep RequireJS bootstrap in place so map/paths apply first

      DeferJs moved every <head> script to the end of <body>. That relocated
      Magento's RequireJS bootstrap (requirejs/require, mage/requirejs/mixins,
      requirejs-config) behind the body content, so early synchronous require([...])
      calls executed before requirejs-config applied its map/paths. RequireJS then
      resolved module ids against the bare baseUrl, requesting wrong static URLs
      (e.g. domReady.min.js instead of requirejs/domReady.min.js). Browser-dependent
      parse/execute timing made it surface in Chrome but not Firefox (TTF-27428).

      Add the RequireJS bootstrap and inline require.config()/requirejs.config()
      blocks to the defer ignore signatures so they are neither moved, deferred, nor
      made async — same mechanism already used for NREUM.

      Add RequireJsBootstrapOrderRegressionTest guarding the load-order invariant.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> ec4081

    • Version 1.18.1 31e2a3

    • Merge pull request #98 from swissup/fix/directmatch-mbstrpos-perf

      Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches f71c7a

    • Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches

      DirectMatchStrategy::findAndReplace branched to mb_strpos()/mb_substr() for
      any non-ASCII oldHtml. On PHP 8.2 mbstring that path is ~O(n*m): a Breeze
      enterprise product page produced three merged body-level patches (~310-388KB,
      containing "£"), each taking ~8s in DirectMatch (which then failed the match
      anyway), accounting for ~24s of the ~29s page-generation time.

      Use byte-based strpos()/substr_replace() unconditionally. This is correct for
      UTF-8 because UTF-8 is self-synchronising: a valid substring's byte sequence
      only matches at a character boundary, and substr_replace() operates on byte
      offsets. Also removes the now-dead charPosToBytePos() helper.

      Verified on staging: page generation 32s -> 6.6s. Full module test suite green.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 779231

    • fix(preload): default missing as attribute to style on existing links

      In-place update (1.18.1) stopped writing the "as" attribute that the
      old re-create path always set. rel="preload" without a valid "as"
      triggers browser warnings and may double-fetch the resource.

      - add regression test for the missing-as default
      - rename testProcessRemovesDuplicatePreloadLinks to match the
      keep-in-place semantics; drop stale comments

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 47d617

    • fix(preload): keep existing preload links in place to preserve CSS order

      AddLinkPreload removed every existing rel="preload" link and re-inserted
      it right after <title>, above the critical <style>. Deferred stylesheets
      (converted by DeferCss, with onload flipping them back to
      rel="stylesheet") ended up before critical CSS in the DOM, inverting the
      cascade: critical CSS overrode the full theme CSS after load.

      - Existing preload links keep their DOM position; per-type rel,
      fetchpriority (within limits) and crossorigin are updated in place
      - Only new preloader assets are inserted after <title>; href dedupe
      against existing links and the live-script prefetch guard are kept
      - Replace applyPreloadChanges() with updatePreloadLinkInPlace() +
      insertPreloadLinks()

      Tests: add regression tests for link position relative to critical
      <style>; suite OK (644 tests, 1944 assertions).

      Fixes #97

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 07ef75

    • fix: replace deprecated SplObjectStorage::contains() for PHP 8.5

      PHP 8.5 deprecates SplObjectStorage::contains(); with an error handler
      that escalates deprecations (e.g. Ignition) this kills page rendering
      inside the optimizer pipeline. Use offsetExists() — identical
      semantics, available since PHP 5.3.

      Fixes #96

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> cee3e2

    swissup/module-pro-labels — 1.10.1 (was 1.10.0)
    • Version 1.10.1 c44745

    • Add null check for associated products in getCheapestFromGrouped method 9156db

    swissup/module-quantity-switcher — 1.2.4 (was 1.2.2)
    • Version 1.2.4 e4c020

    • Merge pull request #5 from swissup/fix/arrow-room-lost-after-mount

      fix(arrows): keep the arrow room after the switcher mounts 1a9480

    • fix(arrows): keep the arrow room after the switcher mounts

      `.control:has(> .input-text.qty)` only matches the server-rendered markup.
      `product.js` wraps the input into `.qty-wrapper` on init, after which the input
      is no longer a direct child of `.control`, the rule stops matching and the 20px
      reserved on each side for the arrows disappears — the arrows overflow the field
      and the layout shifts once JS runs.

      Match the input as a descendant instead, which holds both before and after the
      wrap. Verified in the browser: `.control` padding went `0px 20px` -> `0px` the
      moment the wrapper was inserted, and stays `0px 20px` with this selector.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 082597

    • Version 1.2.3 f2d44f

    • Fixed MSI error for bundle products (with no MSI source) on the cart page aa1c46

    swissup/module-rich-snippets — 1.8.5 (was 1.8.4)
    • Version 1.8.5 2685fe

    • Refactor Offers class to use PriceFormatter for price formatting (#43) 830f60

    • Enhance AdditionalProperty handling by adding 'id' field and include it in structured data as 'propertyID' (#43) c05490

    swissup/module-seo-cross-links — 1.2.0 (was 1.1.26)
    swissup/module-testimonials — 1.4.2 (was 1.4.0)
    • Version 1.4.2 4c12e4

    • Fixed wrong output when rating is not set 64ce08

    • Added show image config and more options for visible slides dropdown 283b05

    • Version 1.4.1 8ca632

    • Slider widget: added arrows/dots configuration 68ea17

    swissup/theme-frontend-argentobreeze-force — 3.0.1 (was 3.0.0)
    • Version 3.0.1 924b64

    • H1-H3 and block title font sizes https://github.com/swissup/argento-breeze/issues/3 573e43

    swissup/theme-frontend-argentobreeze-stripes — 3.1.3 (was 3.1.0)
    • Version 3.1.3 99a8af

    • H1-H3 and block title font sizes https://github.com/swissup/argento-breeze/issues/3 7bf6d1

    • Version 3.1.2 5e6042

    • Fixed gap in Highlight blocks headers in BCB homepage content 3b512e

    • Added Product Listing Item Background config to Theme Editor 12578b

    • Version 3.1.1 f241e3

    • Updated footer CMS content to use Theme Editor colors 63952b

    swissup/module-core — 1.13.1 (was 1.12.27)
    • Version 1.13.1 5b7b24

    • Fixed invisible update instructions when Mollie Payments is installed 72aab6

    • Version 1.13.0 f34c7b

    • Fill the links after ajax refresh b6a316

    • Show loading state on the "Check for Updates" button 1ab16e

    • Use optimized repo to fetch latest releases 1aa8fb

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2

    • Mark core section with badge when outdated modules found 9a3764

    • Outdated modules badge and Modules config group (#25) 7df026

    • MCS fix 3b9121

    • Update instructions 0e686f

    • Read links info from marketplace meta 21e95b

    • Keep the last check time when a refresh fails

      Removing the stored time was what made the next load re-check the feed.
      An unreachable feed left no time at all: the config page had nothing to
      display, and every page load kept re-checking until one request made it
      through.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9

    • Post the modules refresh to an action of its own

      The refresh drops the version check throttle and re-reads the remote
      source, and a get carries no form key validation - Magento checks it for
      post requests only, and the url secret key is optional. Reading the list
      stays a get, so the two live in separate actions now.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9

    • Keep the module links out of the response

      The links cell is rendered by the server and never touched by js.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db

    • Send only the module metadata the browser renders

      The merged Loader record carries the local filesystem path and the
      download, license and purchase-code fields of the remote feed. None of
      them is rendered by the config page, so keep them on the server.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425

    • Too much red 2e307e

    • Use the same `outdated` class name everywhere

      The version span was already renamed, while the row and the js still said
      `_outdated` - a row that stopped being outdated kept its red version pill.

      Since the row now wears the very same class, the pill rule had to be
      scoped to the version cell, or the whole row would turn red.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086

    • Report the check time with the modules, and keep outdated rows first

      Two things the clients were guessing at:

      The `Check for Updates` handler stamped the label with the current time,
      but the request may have re-used the stored packages after a failed feed
      request - `just now` was not always true. And an automatic load did check
      the remote source, yet nothing updated the label at all. The endpoint now
      returns `['items' => ..., 'last_check' => ...]`, and the label follows the
      time the server reports for every load.

      The table was re-rendered cell by cell, so a module that became outdated
      kept its old position while the counter above it grew. Mark the rows and
      move the outdated ones back to the top - both groups stay sorted by name.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271

    • Keep the last check time once the check is due

      The timestamp was stored with a one hour lifetime and the throttle was
      `is the entry still there?`. Handy, but it also meant the config page
      could no longer tell how old the data was as soon as the check was due -
      exactly when that is worth showing next to `Check for Updates`.

      Store it without a lifetime and compare the age instead. Same hour
      boundary, and the label now reads `Last checked 2 h ago` instead of
      disappearing.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a

    • Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66

    • Hovered color for outdated badge 0a0016

    • Show outdated modules count in config and add Modules group

      The admin had no hint that installed Swissup modules have updates
      available - one had to open Module Manager to find out.

      Add a counter badge to the Swissup tab of the config navigation, and a
      Modules group in Swissup > Core listing every installed module with its
      installed and latest version, outdated ones first.

      Neither of them may slow down the config page, so both render from the
      data stored in var/swissup/core (Loader::setOfflineMode()), and the
      versions, the counters and the badge itself are then refreshed by a
      single ajax request shared by all the consumers. `Check for Updates`
      re-runs that request with refresh=1, which drops the version check
      throttle and re-reads the remote source.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6

    • Move core config to the top as it will display outdated modules 115f37

    • Added ability to immidiately re-check remote source for updates `-r` 357d33

    • Keep the remote packages data in files instead of the cache (#24) cbeecc

    • Trim the comments down to what is not in the code

      The two lock docblocks carried the same paragraph, and save() explained
      the reasoning that belongs in the commit that introduced it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9

    • Serialize the writes with a lock taken before the entry is opened

      openFile() truncates whatever it opens, so a lock taken afterwards keeps
      the writes apart but not the truncations. Two saves could interleave into
      one file: the second one truncates, the first one writes a whole entry,
      and the second one then overwrites its beginning - leaving the head of
      one entry on the tail of another. The length line does not catch that
      when both entries encode to the same size, as fixed width values like a
      version hash always do.

      Take the lock before opening, on a file of its own - the entry cannot be
      opened before the lock is held, and the file lock() uses is likely held
      by the caller already, which flock would deadlock against.

      Failing to open a lock file is no longer reported as a lock held by
      somebody else. A lock left behind by another user cannot be opened at
      all, and reporting that as contention kept the caller away from the
      entry for good - silently serving a list that could never be refreshed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28

    • Don't download the list that another process has just stored

      The version is fetched before the download lock is taken, so a process
      that was still asking for it when the lock was released would go on to
      download the very list the lock holder had just stored. Look at the
      storage once more before downloading.

      Also correct what the save() lock claims to do: it serializes the
      writes, but the truncation happens in openFile(), outside of it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0

    • Reject an entry whose expiration time is not a number

      A corrupt header such as `expires:invalid` was cast to zero, which reads
      as "never expires" - so an entry that should have gone stale could be
      served indefinitely instead of being treated as missing. Validate the
      field the same way the length line is validated.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16

    • Keep the remote packages data in files instead of the cache

      The packages list was kept in the Magento cache, so every cache flush
      threw it away and the next admin request had to download it again. It is
      now stored under var/swissup/core, where it outlives the flush.

      FileStorage writes the expiration time and the data length ahead of the
      contents, so an entry knows itself when it goes stale, and a reader can
      tell a half-written entry from a complete one - openFile() truncates the
      file before the write lock is taken, so a reader can catch the entry
      mid-save. An incomplete entry is reported as missing and refetched,
      rather than served as if it were whole.

      Remote revalidates against the version hash from packages.json, at most
      once an hour, and only downloads the full list when that hash moves. The
      download itself is guarded by a non-blocking lock, so several admins
      hitting the page at once produce one request instead of one each - the
      others serve the stored copy, or wait for the download when there is
      nothing stored yet. fetch() now reports connection errors and 4xx/5xx
      responses by returning an empty body, letting the caller fall back to
      the stored data instead of decoding a failure page.

      The admin notification feed keeps its last-update timestamp in the same
      storage, replacing its own copy of the file handling.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd

    • Small fixes f7eef6

    • Single source of truth for is_outdated, is_installed 37ad01

    • Fixed `Undefined array key "type"` warning b2a9c1

    • Don't show non installed modules when asking for outdated bf5fb1

    swissup/module-breeze-content-builder — 1.2.0 (was 1.0.6)
    • Version 1.2.0 7f774f

    • Changes for support of the contact us page 812853

    • Added h1 for the Text component and switched to tags instead of CSS classes e054e6

    • Added new Map component c2d71b

    • Improve Columns vertical stretch 6b5da8

    • Version 1.1.0 d0ef07

    • Merge pull request #35 from breezefront/feat/ai-components-bridge

      feat(content-builder): apply AI-generated components to the working copy (#34) fa5587

    • Added new Video component 94bb8d

    • fix(content-builder): clone the incoming array before installing it as the working copy d02528

    • fix(content-builder): stay a no-op until sub-modules finish initializing 2c2289

    • fix(content-builder): commit a pending dirty edit before pushing the AI snapshot 3a1ce7

    • feat(content-builder): apply AI-generated components to the working copy (#34) 030d4c

    • Added 5 columns layout for the Columns component 4adc04

    • Version 1.0.12 ccc005

    • fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)

      Three review findings on the H1 work:

      1. The guard counted only Banner components, so it stayed silent on the most
      common duplicate: a CMS page with a Content Heading, where
      Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
      module-theme's html/title.phtml emits <h1 class="page-title">. Load now
      reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
      same flag from its opts so BLB can declare the product/category page title,
      and the guard adds that H1 to the count.

      2. The guard only ran on a headline_type change, so pasting an H1 Banner never
      warned. It now runs after paste and after content load as well, which also
      covers a tree that arrived from an AI build.

      3. getDraft() reloaded the draft by id without checking the load succeeded. If
      the row was deleted between the collection query and the reload, it returned
      an empty model and the caller saved a row with no page_id — rejected by
      MySQL, since page_id is NOT NULL with no default. It now falls back to the
      seeded-draft path, which is extracted into createSeededDraft().

      Load's constructor gained a dependency, so setup:di:compile is needed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> abc266

    • feat(banner): allow H1 for the banner headline (#32)

      The Banner headline_type prop only offered h2-h6, so a page built entirely
      in Content Builder shipped without a primary heading — the document had no
      h1 at all, which is an on-page SEO defect.

      Add h1 to the option list and to the template's tag whitelist. The default
      stays h2, so existing pages and newly inserted Banners render exactly as
      before.

      Because a page should carry at most one h1, the editor now shows a soft
      notice when a headline is switched to h1 while another Banner already uses
      it. It warns rather than blocks — a legitimate layout may keep its h1
      outside the Content Builder bands.

      The Text component's heading_type is deliberately left alone: it renders a
      div with a heading class, not a real heading tag, so an "h1" there would be
      visual only and misleading.

      Fixes #32

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ced844

    • fix(content): reload draft through singular load so updated_at advances

      getDraft() returned a collection-hydrated item with empty storedData,
      so save()'s update-diff wrote every column back unchanged, pinning
      updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.

      Related to #31 (does not close it — content-loss symptom unconfirmed). b1759b

    • fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)

      Three review findings on the H1 work:

      1. The guard counted only Banner components, so it stayed silent on the most
      common duplicate: a CMS page with a Content Heading, where
      Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
      module-theme's html/title.phtml emits <h1 class="page-title">. Load now
      reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
      same flag from its opts so BLB can declare the product/category page title,
      and the guard adds that H1 to the count.

      2. The guard only ran on a headline_type change, so pasting an H1 Banner never
      warned. It now runs after paste and after content load as well, which also
      covers a tree that arrived from an AI build.

      3. getDraft() reloaded the draft by id without checking the load succeeded. If
      the row was deleted between the collection query and the reload, it returned
      an empty model and the caller saved a row with no page_id — rejected by
      MySQL, since page_id is NOT NULL with no default. It now falls back to the
      seeded-draft path, which is extracted into createSeededDraft().

      Load's constructor gained a dependency, so setup:di:compile is needed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6d73f

    • feat(banner): allow H1 for the banner headline (#32)

      The Banner headline_type prop only offered h2-h6, so a page built entirely
      in Content Builder shipped without a primary heading — the document had no
      h1 at all, which is an on-page SEO defect.

      Add h1 to the option list and to the template's tag whitelist. The default
      stays h2, so existing pages and newly inserted Banners render exactly as
      before.

      Because a page should carry at most one h1, the editor now shows a soft
      notice when a headline is switched to h1 while another Banner already uses
      it. It warns rather than blocks — a legitimate layout may keep its h1
      outside the Content Builder bands.

      The Text component's heading_type is deliberately left alone: it renders a
      div with a heading class, not a real heading tag, so an "h1" there would be
      visual only and misleading.

      Fixes #32

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 620e7f

    • fix(content): reload draft through singular load so updated_at advances

      getDraft() returned a collection-hydrated item with empty storedData,
      so save()'s update-diff wrote every column back unchanged, pinning
      updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.

      Related to #31 (does not close it — content-loss symptom unconfirmed). 5c797e

    • Remove custom accordion script d8cb73

    • Added new Accordion/FAQ component 3135bd

    • Version 1.0.11 17c28e

    • Use text color for Heading when set in the Text component 0335a1

    • Added font-size field and source button to WYSIWYG editor dfc235

    • Improve grid component styles on 640px-768px screen sizes fe4e91

    • Added Feature Left/Right layouts to Grid component b1b27c

    • Use banner text color for headings inside banner if set eb8b24

    • Version 1.0.10 86c7b3

    • Improved drag-and-drop in the components tree, closes #28 840287

    • Version 1.0.9 033a62

    • Added AI-readable descriptions to component definitions

      Each component now carries a <description> element instead of an XML
      comment above its declaration, so the text is available to code and not
      just to whoever opens the file. ComponentXmlReader exposes it as the
      "description" key of every definition.

      The wording is aimed at a consumer that has to pick a component without
      seeing the storefront: what the component is for, which component to
      prefer when two of them overlap, how many children slots a container
      takes, and which props must be left empty because their values are
      entity IDs or widget directives that cannot be guessed.

      First consumer is the AI landing page builder, which turns the registry
      into the component catalog it sends to the model.

      Refs breezefront/module-breeze-ai#23

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bad797

    • Added AI-readable descriptions to component definitions

      Each component now carries a <description> element instead of an XML
      comment above its declaration, so the text is available to code and not
      just to whoever opens the file. ComponentXmlReader exposes it as the
      "description" key of every definition.

      The wording is aimed at a consumer that has to pick a component without
      seeing the storefront: what the component is for, which component to
      prefer when two of them overlap, how many children slots a container
      takes, and which props must be left empty because their values are
      entity IDs or widget directives that cannot be guessed.

      First consumer is the AI landing page builder, which turns the registry
      into the component catalog it sends to the model.

      Refs breezefront/module-breeze-ai#23

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9d3287

    • Version 1.0.8 ea7d14

    • Force STRAIGHT_JOIN usage to fix slow query on old MariaDB version 10.6 01441a

    • Do not use rand in fallback product provider 9270bb

    • Product grid performance improvements 0804ac

    • Performance improvements for random product lists d5cab2

    • Version 1.0.7 39992b

    • Fixed less error on old compiler 9b5330

swissup / argento-m2

1.54.0 Aug
  • 1.54.0

    Report (rev.1)

    Package was generated and uploaded to GitHub on with the following dependencies:

    swissup/module-ajaxpro:1.7.39

    swissup/module-ajaxsearch:1.14.11

    swissup/module-amp:1.6.0

    swissup/module-askit:1.14.27

    swissup/module-attributepages:1.8.6

    swissup/module-chat-gpt-assistant:1.1.4

    swissup/module-compare:1.2.8

    swissup/module-easy-slide:1.8.18

    swissup/module-easybanner:1.9.24

    swissup/module-easycatalogimg:1.5.15

    swissup/module-easytabs:1.13.4

    swissup/module-fblike:1.3.9

    swissup/module-featured-attributes:1.1.9

    swissup/module-font-awesome:1.4.5

    swissup/module-gdpr:1.9.2

    swissup/module-gdpr-askit:1.1.3

    swissup/module-gdpr-reviewreminder:1.0.0

    swissup/module-gdpr-testimonials:1.1.1

    swissup/module-highlight:1.11.9

    swissup/module-hover-gallery:1.3.15

    swissup/module-hreflang:1.6.12

    swissup/module-hreflang-import-export:1.0.1

    swissup/module-lightboxpro:1.3.10

    swissup/module-navigationpro:1.19.3

    swissup/module-pagespeed:1.19.0

    swissup/module-pro-labels:1.10.1

    swissup/module-pro-labels-configurable-product:1.0.9

    swissup/module-quantity-switcher:1.2.4

    swissup/module-reviewreminder:1.1.19

    swissup/module-rich-snippets:1.8.5

    swissup/module-seo-canonical:1.11.10

    swissup/module-seo-core:1.3.10

    swissup/module-seo-cross-links:1.2.0

    swissup/module-seo-html-sitemap:1.1.23

    swissup/module-seo-images:1.3.21

    swissup/module-seo-pager:1.4.0

    swissup/module-seo-templates:1.7.12

    swissup/module-seo-urls:1.5.55

    swissup/module-seo-xml-sitemap:1.2.0

    swissup/module-sold-together:1.11.4

    swissup/module-sold-together-custom-options:1.0.2

    swissup/module-sold-together-email:1.0.0

    swissup/module-sold-together-import-export:1.0.2

    swissup/module-testimonials:1.4.2

    swissup/module-theme-editor-argento-chic:1.7.4

    swissup/module-theme-editor-argento-essence:1.7.3

    swissup/module-theme-editor-argento-flat:1.7.3

    swissup/module-theme-editor-argento-force:1.4.2

    swissup/module-theme-editor-argento-home:1.2.3

    swissup/module-theme-editor-argento-luxury:1.7.2

    swissup/module-theme-editor-argento-mall:1.7.3

    swissup/module-theme-editor-argento-marketplace:1.7.3

    swissup/module-theme-editor-argento-pure2:1.6.4

    swissup/module-theme-editor-argento-stripes:1.7.5

    swissup/theme-frontend-argento-blank:1.49.5

    swissup/theme-frontend-argento-chic:1.48.3

    swissup/theme-frontend-argento-essence:1.46.4

    swissup/theme-frontend-argento-flat:1.46.6

    swissup/theme-frontend-argento-force:1.48.3

    swissup/theme-frontend-argento-home:1.48.5

    swissup/theme-frontend-argento-luxury:1.48.2

    swissup/theme-frontend-argento-mall:1.48.2

    swissup/theme-frontend-argento-marketplace:1.48.4

    swissup/theme-frontend-argento-pure2:1.46.6

    swissup/theme-frontend-argento-stripes:1.48.3

    swissup/module-core:1.13.1

    swissup/module-suggestpage:1.2.14

    swissup/module-image-optimizer:1.0.12

    swissup/module-theme-editor:1.12.0

    Changelog (autogenerated)

    swissup/module-ajaxpro — 1.7.39 (was 1.7.34)
    • Version 1.7.39 650093

    • fix: guard productInfoResolver call in ajaxSubmit (#57 follow-up)

      Found while verifying the YQE-14065 fix on the customer's dev store:
      their theme (Webspaceconfig/Webspace) ships its own outdated copy of
      Magento_Catalog/js/catalog-add-to-cart.js that predates Magento core
      adding productInfoResolver support. Our widget extends that file as
      its base ($.mage.catalogAddToCart) and called
      self.options.productInfoResolver(form) unconditionally, so on that
      theme every ajax add-to-cart submit threw
      "productInfoResolver is not a function" before the request was even
      sent — configurable/size options couldn't be added to cart from
      category listing pages.

      Root cause is the theme's stale file, not this module — reported
      separately. This guard just keeps ajaxpro working regardless of
      whether the base widget a theme provides happens to support this
      option.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 320f71

    • Version 1.7.38 3b2284

    • Merge pull request #58 from swissup/fix/gift-card-options-data-post-57

      fix: stop stripping data-post so third-party widgets can still read it (#57) eb4490

    • fix: pass bind:false when reusing ajaxcian-data-post as an _ajax() helper (#57)

      Copilot review on #58: _confirm()'s replacement click handler runs on
      every confirmed compare remove/clear click and constructs
      AjaxproAjaxcianDataPost({}, element) to reuse its _ajax() method. With
      the default options ({} means options.bind stays true), _create()
      also calls _bind(), adding a brand new click listener on the same
      element — and since data-post is no longer stripped (YQE-14065), that
      _bind() call succeeds every time instead of bailing out on a missing
      attribute like it used to.

      None of those listeners are ever removed, so click N fires N ajax
      requests: click 1 adds listener #1 (plus its own direct call), click 2
      runs the _confirm handler directly again AND listener #1, click 3 runs
      the _confirm handler, listener #1, and listener #2, etc.

      Fixed by passing { bind: false } — this call only ever wanted _ajax()
      as a helper method. Verified by reverting locally and re-running the
      new test: 2nd click produces 3 ajax calls instead of 2 without the fix.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> c5bd0f

    • fix: stop propagation in compare.js rebind, close double-submit (#57)

      Copilot review on #58: Swissup_Ajaxpro/js/compare's _confirm() rebinds
      the click handler on compare remove/clear-all links on every
      compare-products section update:

      $(selector).off('click').on('click', function (e) { ... });

      .off('click') strips ANY click handler already on the element,
      including ajaxcian-data-post's own (which stopPropagation()s). The
      replacement handler here called _ajax() directly without stopping
      propagation of its own. Since ajaxcian-data-post no longer strips
      data-post, a click after this rebind reached both this handler's ajax
      call and the native document-delegated mage.dataPost handler — a real
      double submit for compare remove/clear links specifically. Previously
      this was masked because data-post was already gone by the time any
      rebind happened.

      Added e.stopPropagation() to this handler too, plus a regression test
      that reproduces the exact rebind sequence and asserts exactly one ajax
      call / zero native-handler calls (and fails without the fix, verified
      locally). Needed two additions to the jest $.widget shim along the
      way: this._super() support (compare.js's _create calls it) and calling
      the widget constructor without `new` (compare.js does
      AjaxproAjaxcianDataPost({}, element) directly, as real jQuery UI
      widget constructors support).

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 727bd4

    • fix: bind click handler synchronously, drop startup delay (#57)

      Copilot review on #58: the widget still delayed binding its own click
      handler by 500ms. That delay used to be harmless because the old
      buggy code removed data-post synchronously in _bind(), so the native
      mage.dataPost document-delegated handler couldn't match during the
      window either. Now that data-post stays on the element permanently,
      a click within that window reached mage.dataPost alone and triggered
      a full page navigation instead of Ajaxpro's ajax request.

      No commit in this file's history documents a reason for the delay.
      Binding synchronously closes the window; our handler is on the
      element itself so it still runs (and stopPropagation()s) before the
      event can reach the document-level native handler regardless of when
      it was bound, as long as it's bound before the click happens.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> cc70c1

    • fix: clone cached data-post before mutating it (#57)

      Copilot review on #58: element.data('post') returns jQuery's cached
      object for the attribute (same reference on every call). _ajax() was
      mutating that object's .data (adding form_key) in place, so a later
      $(el).data('post') read — by this widget on a second click, or by a
      third party like Mageplaza_GiftCard/js/view/information.js — would see
      the leaked form_key instead of the original payload.

      Also documents the stopPropagation() trade-off raised in the same
      review: it can't be scoped to only the native mage.dataPost handler
      (jQuery event namespaces don't affect propagation, and mage/dataPost.js
      is a core file we don't patch) — no other ancestor-delegated click
      listener touches these links today, so accepted as-is.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 75a2b4

    • fix: stop stripping data-post so third-party widgets can still read it (#57)

      ajaxcian-data-post.js renamed data-post to data-post-ajax and removed the
      original attribute when hijacking a link for ajax submit. Any other script
      reading $(el).data('post') from the same link after that point (e.g.
      Mageplaza_GiftCard/js/view/information.js, on the same .tocompare /
      .towishlist links used to build Gift Card add-to-wishlist/compare payloads)
      got undefined and threw, aborting its own init before it could render the
      Gift Card options form.

      Keep data-post intact and use stopPropagation() instead to stop the native
      mage.dataPost document-delegated click handler from double-submitting.

      Verified live on the customer's dev store: Gift Card options render
      correctly with the patch, no duplicate ajax request on click.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 508331

    • Breeze: added missing imports 115dd9

    • Version 1.7.37 264578

    • fix: quick view popup silenced by the 1.7.35 gating guard

      is-ajaxpro-request's "trust a section load only after a recent ajaxpro
      POST" guard (048361e, 1.7.35) only recognized add-to-cart as a plain
      GET customer/section/load preceded by a POST. Quick view has no such
      POST — it fetches ajaxpro-product directly — so ModalManager never
      opened the popup: the request succeeded, the section arrived, nothing
      rendered, no error.

      Have quick-view declare the request explicitly via the new
      isAjaxproRequest.mark() instead of relying on inference.

      Reported on 420shop.nl (customer-facing, all listing pages).

      Closes #57 30e663

    • fix: ajaxpro-cart/reinit/product section render must not 400 section/load

      Magento\Customer\Controller\Section\Load turns any exception thrown by
      a section source into an HTTP 400 for the whole request. Cart::getSectionData()
      renders the full cart layout server-side even for an empty/absent quote,
      and on some environments (Magento 2.4.9 + Breeze, per report) that render
      throws, aborting customer/section/load for every guest with an empty cart.

      Wrap the render logic in Cart, Init and Product CustomerData classes in
      try/catch: on failure, log via the newly injected Psr\Log\LoggerInterface
      and return [] for that section instead of letting the exception bubble up.
      Core sections (cart, customer, ...) keep loading either way.

      Closes #56 5f061c

    • docs: add manual QA scenarios ba69b1

    • Version 1.7.36 82272a

    • test: config options and type-source coverage

      Covers the options x settings surface at the logic level:
      - Helper\Config: master switch (every feature off when
      ajaxpro/main/enabled is 0), the override-minicart handle whitelist
      (allowed only for minicart / shopping-cart handles, matching the
      system.xml depends), standalone flags that ignore the master switch,
      and type casts.
      - Source\Handle / Source\Type: lock the popup handle and cart type
      enumerations referenced by name across system.xml, layout and config.

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 8f91cf

    • test: popup gating matrix across component flavors and popup types

      Runs the gating scenarios (popup after add-to-cart, silence on stale
      re-fetch, redirect flow) for every combination of component flavor
      (luma uiComponent / breeze view) and cartHandle popup type (minicart,
      shopping cart, suggest page), plus the simple-popup messages gate and
      closeTimeout config variations (disabled, countdown with continue
      button, reset on user activity).

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ef33d0

    • test: integration coverage of the popup gating chain

      Covers section update -> _subscribe -> ModalManager.checkAndShow end
      to end: popup after add-to-cart post, popup after redirect-based add
      to cart, silence on stale section re-fetch (the bsxl scenario), no
      reopen after the popup was shown. This chain is the only protection
      on both stacks since disposableCustomerData was dropped (#54).

      Refs #54

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 4f7514

    • refactor: drop disposableCustomerData, unify luma and breeze strategy

      Luma applied the disposableCustomerData extender to ajaxpro sections
      (wiping them from storage and section_data_ids cookie after use) while
      Breeze had it commented out — the divergence made stale-section bugs
      Breeze-only and the behavior hard to reason about.

      Since 048361e the popup opens only within 30s after a real add-to-cart
      post (is-ajaxpro-request marker), so a stale re-fetch on page load can
      not trigger it on either stack. Ajaxpro sections now persist like any
      other customer-data section on both frontends.

      Verified on luma storefront: popup on add to cart, section persists,
      stale section re-fetch on navigation stays silent.

      Closes #54

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> b0a6dd

    • refactor: replace PostHelper preference with a before plugin

      The preference on Magento\Framework\Data\Helper\PostHelper replaced
      the core class entirely and conflicted silently with other modules
      customizing the same helper. A before plugin composes instead.

      before (not after/around): the decision "caller did not pass uenc
      explicitly" is only visible in the original arguments, and no result
      re-serialization is needed. Semantics are identical to the old
      override: inside an ajax request reuse the real page uenc from the
      request parameters.

      Old helper class kept as @deprecated for code extending it.

      Closes #55

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 4f0ca4

    • chore: strict_types in all php files, drop dead code

      - declare(strict_types=1) in every php file (project standard);
      verified with php -l, unit tests and storefront smoke run
      - remove commented-out debug/dead blocks (md5 debug, unused methods,
      disabled section actions)
      - drop stale deprecated setup_version from module.xml (no Setup/ dir)

      Refs #53

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> a46452

    • test: cover event handling hygiene fixes (issue #52)

      Refs #52

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> bf5db7

    • fix: event handling hygiene in frontend js

      - Namespace click handlers (.off('click.ajaxpro')) in floating cart
      and minicart override: bare .off() also removed handlers attached
      by other modules.
      - Cap modal-manager register() polling at 10s and register anyway:
      on pages with permanent ajax activity $.active never reaches 0 and
      the interval lived forever.
      - Dispose customer-data subscriptions on destroy (ajaxpro.js,
      compare.js) to avoid duplicate handlers after component re-init.
      - Fix dead fallback typo: form.prop('prop') -> form.prop('action').

      Closes #52

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 635251

    • test: cover modal auto-close timer (issue #51)

      Refs #51

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> e53aaf

    • fix: modal auto-close timer throws instead of closing the modal

      setTimeout received this.closeModal unbound, so the closeTimeout
      feature fired with `this` pointing at window and threw a TypeError.
      Same defect in luma and breeze variants.

      Closes #51

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> d4fe2b

    • test: cover ajax notice message suppression (issue #49)

      Adds phpunit setup (mirrors module-breeze-theme-editor) and wires
      bin/test unit.

      Refs #49

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 67257d

    • test: cover section reload once-guard (issue #50)

      Refs #50

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ceb693

    • fix: infinite section reload loop on website/store mismatch

      Minicart and floating cart views reloaded cart sections whenever the
      cart section website_id/storeId diverged from window.checkout. When
      the quote really belongs to another website/store (same-domain
      multi-store), reloading can never fix the mismatch, and since the
      Breeze view re-creates on every contentUpdated, the check re-fired
      after each reload — an endless cart/ajaxpro-cart request stream.

      Reload at most once per page load and guard undefined website_id in
      the floating cart (undefined !== websiteId used to trigger it too).

      Closes #50

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 84dcbd

    • fix: notice messages silently dropped on every ajax request

      isMessageToIgnore() compared strpos() result with 1 instead of false,
      so it returned true for nearly any message and addNoticeMessage()
      discarded all ajax notices — not only the two "choose options"
      messages it was meant to suppress.

      Closes #49

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 79940e

    • Version 1.7.35 148d35

    • test: add jest suite for is-ajaxpro-request

      Setup mirrors module-breeze-theme-editor: jsdom, AMD transform,
      Magento_Customer mocks. Run with bin/test [jest].

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> e6ff4c

    • fix: popup opening on page load when ajaxpro sections expire

      Any GET customer/section/load with ajaxpro-product in sections was
      treated as an add-to-cart request. Expired ajaxpro sections (logged-in
      customer, out-of-sync section_data_ids cookie) are re-fetched on every
      page load, so the cart popup opened on plain category navigation.

      Trust the section load only within 30s after a real ajaxpro POST.
      Marker lives in sessionStorage: survives redirect-based add to cart,
      per-tab, cleared once the popup is shown.

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 048361

    swissup/module-ajaxsearch — 1.14.11 (was 1.14.6)
    • Version 1.14.11 0db900

    • Fixed layout shift when result list is long 8715cc

    • Fixed overflowed content in Firefox (Apollo theme) de660b

    • Merge pull request #57 from swissup/phpunit12-test-api-56

      Migrate Test/Unit off removed PHPUnit APIs (PHPUnit 12 / PHP 8.5) 095093

    • test: migrate removed test APIs for PHPUnit 12 / PHP 8.5

      The CI runner targets PHP 8.5, which requires PHPUnit 12.x. Migrate the
      unit tests off APIs removed in PHPUnit 10-12:

      - setMethods() (removed v10) -> onlyMethods(); all mocked methods verified
      to exist on the target class via reflection. Dropped dead duplicate mock
      builders (Magento CollectionFactory immediately overwritten by the
      Swissup one).
      - getMockForAbstractClass() (removed v12) -> createMock().
      - will($this->returnValue()/returnSelf()/onConsecutiveCalls()) (removed
      v12) -> willReturn()/willReturnSelf()/willReturnOnConsecutiveCalls().
      - Non-static data providers (hard error v12) -> public static function.

      Also fixed two removed APIs not listed in the issue but caught while
      running the suite:

      - $this->at() (removed v10) in DataTest and ProductTest -> willReturnMap()
      / atLeastOnce() / consecutive-call stubs.

      InitTest was stale independently of PHPUnit: it exercised a removed
      getClassNames() method and the old constructor/getSettings signature.
      Realigned it to the current Init block and dropped the redundant
      getClassNames test (already covered by Helper\DataTest).

      Verified: 41 tests, 99 assertions, 0 errors, 0 deprecations on
      PHPUnit 10.5 / PHP 8.3 (`bin/test`).

      Refs swissup/module-ajaxsearch#56

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 20854c

    • test: add module-local PHPUnit runner

      Add bin/test and phpunit.xml.dist so the unit suite can be run in
      isolation via `bin/test` (runs vendor/bin/phpunit inside the container),
      mirroring the setup already present in module-askit. .gitignore keeps the
      PHPUnit result cache out of the repo.

      Refs swissup/module-ajaxsearch#56

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> a4e899

    • Version 1.14.10 28072f

    • Breeze: do not require redeployment when changing config d37dfa

    • Version 1.14.9 d8bfef

    • Breeze 2.0 fixes 7c5b2e

    • Revert input-bg for Breeze 2.0 compatibility 88f0a6

    • Version 1.14.8 73c965

    • Magento 2.4.9 fix eaaafd

    • Breeze: use `--input-bg` as input background if set 1e84b3

    • Inherit placeholder color from theme 670f3d

    • Fix for the previous commit 6c4ddd

    • Better fix for unscrollable fullscreen mode 8f7477

    • Improve fullscreen styles 71d92f

    • Fullscreen: sync results width with input 7204a2

    • Improve close button position when outline is visible, fix input height c4bf30

    • Fixed missing close button on fullscreen mode 9aa874

    • Ability to remove styling when ajaxsearch is enabled b7e4ff

    • Breeze: improve dark support f5e2c7

    • Version 1.14.7 453b4d

    • fix: scope autocomplete container to avoid conflicts with multiple instances

      Replace global $('.block-swissup-ajaxsearch-results') selectors with instance-
      scoped references to prevent conflicts when multiple ajaxsearch widgets exist
      on the same page (e.g. header search + nav search).

      - breeze/ajaxsearch.js: use this.autoComplete instead of global selector
      - ajaxsearch/results.js: addWrappers(), recalcWidth(), _addSectionTitle()
      now accept optional $container argument (fallback to global selector)
      - ajaxsearch.js: pass scoped $container when calling Results methods 4014dc

    • Breeze: compatibility with `header__dark=deep` ec7063

    swissup/module-askit — 1.14.27 (was 1.14.21)
    • Version 1.14.27 a71b71

    • Fixed answer(s) Dutch translation d2124a

    • Version 1.14.26 8374b0

    • Merge pull request #74 from swissup/fix/issue-73-i18n-dictionary

      Regenerate i18n dictionary, fix uncollectable and stale phrases ef2944

    • Regenerate i18n dictionary, fix uncollectable and stale phrases

      `en_US.csv` had drifted well behind the source: 106 rows against the 196
      phrases `i18n:collect-phrases` finds in the module. Regenerated it and
      brought every locale file back in line with it.

      Source fixes that had to come first:

      - `listing.phtml` built the answer counter label as
      `__($collection->getSize() == 1 ? 'answer' : 'answers')`. The phrase
      collector cannot resolve a ternary inside `__()`, so it emitted the
      literal `answer' 'answers` as a key — which every locale file then
      faithfully translated, while the real `answer` and `answers` strings
      stayed untranslatable. Moved the ternary outside the `__()` calls.
      - `"id" is incorect` -> `"id" is incorrect` (CreateVote.php).
      - `MassAssign.php` used `Assign to...` where the UI component label says
      `Assign to ...`, producing two dictionary keys for one label. Unified
      on `Assign to ...`.

      Dictionary changes:

      - `en_US.csv` regenerated from `i18n:collect-phrases`, 106 -> 196 rows.
      This also drops the stray quoting on `It is helpful` and `Not much
      help`, whose values were ` "It is helpful"` and ` "Not much help"` —
      rendered on the frontend complete with the quotes.
      - Removed 13 dead keys from every locale (`Hint`, `Askit Items`,
      `New Askit Question`, `Answer Information`, `You saved.`, the
      `${ $.$data.text }` UI-component leftovers, the misspelled
      `We can't find a answer to delete.`, and the mangled
      `answer' 'answers`).
      - `fa_IR.csv`: removed a duplicate `Not much help` row whose value was
      double-quoted, and folded the stray ` answer` key into `answer`.
      - Normalised the leading space on the `asks` / `about` / `answer` /
      `answers` values — the templates already prepend it.
      - All files rewritten fully quoted.

      Every locale now parses to exactly two columns per row, carries no
      duplicate keys, and contains no key absent from `en_US.csv`.

      Translating the newly exposed phrases is left to the translators;
      coverage per locale is currently 44-49%.

      Refs #73

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0c1b32

    • Merge pull request #72 from swissup/fix/issue-71-locale-csv

      Fix malformed "Answer" row in locale files, add cs/sk/hu/ro 8b3f84

    • Fix malformed "Answer" row in locale files, add cs/sk/hu/ro

      The "Answer" entry had an unclosed quote in 18 of 19 locale files:

      "Answer,Answer

      fgetcsv merged that line with the next one, so both "Answer" and
      "Entity Type" were lost in every affected locale. Closing the quotes
      restores them.

      Also:
      - add cs_CZ, sk_SK, hu_HU and ro_RO translations
      - add the missing "Plain text only. HTML is not allowed.",
      "Submit Question" and "Name" keys to en_US, de_DE, it_IT and nl_NL

      Translations contributed by @dimdimich in #71. Over-escaped quotes
      ("""" instead of "") in the submitted hu_HU and ro_RO files were
      corrected before merging. fa_IR was already well-formed and is
      left untouched.

      Fixes #71
      Closes #70

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 0381cd

    • Version 1.14.25 d0112b

    • Merge pull request #69 from swissup/fix/issue-68-null-array-offset

      Fix null array offset deprecation on new product form (#68) 1a7529

    • Fix null array offset deprecation on new product form

      modifyData() wrote $data[$productId] where $productId is null for a
      new (unsaved) product, triggering on PHP 8.5:

      Deprecated Functionality: Using null as an array offset is
      deprecated, use an empty string instead

      Guard against a null product id and return $data unchanged, mirroring
      the existing guard in modifyMeta(). current_product_id is meaningless
      for a product that does not exist yet, so skipping the write is safe;
      editing an existing product is unaffected.

      Fixes #68

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 0fd6d0

    • Version 1.14.24 3b54b7

    • test: drop addMethods() for PHPUnit 12 compatibility

      PHPUnit 12 removed MockBuilder::addMethods(). CaptchaTest used it to
      mock getControllerAction() on the Observer mock, but that is a magic
      DataObject getter, not a declared method.

      Replace the two mocks with a real Observer created with the data set
      (`new Observer(['controller_action' => $controller])`) so the magic
      getter returns the controller. Tests pass on both PHPUnit 9.6 (current
      CI) and PHPUnit 12.5.

      Refs swissup/module-askit#67

      Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> 8412ad

    • Fixed php 8.4 error: Cannot unset property $totalCount because it might have hooks in a subclass a679be

    • Use the frontend URL builder directly in blocks (BCB preview fix) 82af94

    • Version 1.14.23 13f6c8

    • fix: mock HttpResponse instead of ResponseInterface in CaptchaTest 106771

    • fix: remove duplicate ResponseInterface use statement in CaptchaTest 6ef280

    • test: update CaptchaTest for injected ResponseInterface

      Observer now receives ResponseInterface via constructor instead of
      calling controller->getResponse(). Update setUp(), makeObserver(),
      and testAjaxBlockWritesJsonErrorToResponse accordingly. ada7aa

    • fix: inject ResponseInterface instead of controller->getResponse()

      SaveAbstract implements HttpPostActionInterface directly (no AbstractAction),
      so its Interceptor has no getResponse(). Use injected ResponseInterface
      singleton instead — same object, no dependency on controller type. ff751a

    • feat: add Swissup reCAPTCHA protection for Askit question form

      - config.xml: enable protect_swissup_askit_new_question_form by default
      - system/recaptcha.xml: expose toggle in swissup_recaptcha admin config

      Swissup_Recaptcha module uses its own captcha factory plugin to replace
      Magento image captcha with Recaptcha model. isRequired() checks
      swissup_recaptcha/general/protect_{formId} — this was missing for askit,
      causing reCAPTCHA token to render but never be validated (spam bypass). e861b6

    • fix: write JSON error to response before FLAG_NO_DISPATCH for AJAX captcha block 92bd7c

    • test: add unit tests for Observer/Captcha reCAPTCHA and image CAPTCHA blocking logic 67b95c

    • fix: set FLAG_NO_DISPATCH to block controller execution on reCAPTCHA failure for AJAX requests d70979

    • fix: block AJAX form submission when reCAPTCHA validation fails 0739e0

    • Version 1.14.22 2a412a

    • PHP8.5: Using null as an array offset is deprecated, use an empty string 10a2bb

    • PHP8.5: Non-canonical cast (boolean) is deprecated, use the (bool) e14ee6

    • Typo fix c20706

    • Magento 2.4.9 fix 3f838b

    • Magento 2.4.9 fix 9f7426

    swissup/module-attributepages — 1.8.6 (was 1.8.1)
    • Added ability to move description to bottom:

      ```
      <referenceBlock name="attributepage.attribute.view">
      <arguments>
      <argument name="is_description_at_bottom" xsi:type="boolean">true</argument>
      </arguments>
      </referenceBlock>
      ``` f46297

    • Version 1.8.5 e137db

    • PHP8.5: Using null as an array offset is deprecated, use an empty string c2a322

    • PHP8.5: Using null as an array offset is deprecated, use an empty string ebee34

    • Version 1.8.4 eb7d54

    • Hide links from Seo html Sitemap based on config 5da82b

    • Version 1.8.3 d53e5b

    • Configurable search field to allow to quickly find the option (#39) eda575

    • Added translation for "No results found" 50c869

    • Configurable search field (disabled by default) daffa6

    • Breeze integration 2f866a

    • Fixed to work nicely with "Group by letter" config ea6c22

    • Transform js into component 901529

    • Add translation d1e420

    • Move styles to css 074dd5

    • Script to data-mage-init 5047aa

    • Attributepages: Added search 78b8df

    • Version 1.8.2 48a150

    • Added styling for mobile view 1f145a

    swissup/module-easy-slide — 1.8.18 (was 1.8.16)
    • Version 1.8.18 124a36

    • Fixed JS error on hidden slider, closes #41 5730d8

    • Version 1.8.17 24f2f0

    • PHP8.5: Using null as an array offset is deprecated, use an empty string 1a4514

    • Magento 2.4.9 fix. Tested on 2.4.6 also - these styles not needed. e51c52

    swissup/module-easybanner — 1.9.24 (was 1.9.23)
    • Version 1.9.24 ef2490

    • PHP8.5: Using null as an array offset is deprecated, use an empty string 788d7e

    swissup/module-easycatalogimg — 1.5.15 (was 1.5.13)
    • Version 1.5.15 a12d86

    • Fixed inability to create new category on PHP 8.5. Closes #17 a5d3fe

    • Version 1.5.14 09fa9c

    • Add ability to wrap block into `block, block-content` 6baa0f

    • No escape added 4540d2

    • Added missing escape 758dbc

    • Ability to add block title and css class using xml layout update
      Example:
      ```
      <referenceBlock name="subcategories">
      <arguments>
      <argument name="block_custom_css" xsi:type="string">block block-hero</argument>
      <argument name="block_title_html" xsi:type="string"><![CDATA[Browse <span class="color-muted">categories</span>]]></argument>
      </arguments>
      </referenceBlock>
      ``` 7cab67

    swissup/module-easytabs — 1.13.4 (was 1.13.2)
    swissup/module-font-awesome — 1.4.5 (was 1.4.4)
    • Version 1.4.5 7b323b

    • Add configurable option to disable Font Awesome preload (#7)

      * Add configurable toggle to disable Font Awesome font preload

      Preload hint is downloaded on every page (~75 KiB) even though most
      stores never render an icon using FontAwesome above the fold. New
      Swissup > Font Awesome > Preload Font setting (default: Yes, keeps
      existing behavior) lets stores turn off the <link rel="preload">
      without affecting the CSS or @font-face definition.

      * Potential fix for pull request finding

      Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

      * Potential fix for pull request finding

      Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

      ---------

      Co-authored-by: Chumak Roman <chumakrom@gmail.com>
      Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> 0768b3

    • Potential fix for pull request finding

      Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> 22a42f

    • Potential fix for pull request finding

      Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> 04ea9b

    • Add configurable toggle to disable Font Awesome font preload

      Preload hint is downloaded on every page (~75 KiB) even though most
      stores never render an icon using FontAwesome above the fold. New
      Swissup > Font Awesome > Preload Font setting (default: Yes, keeps
      existing behavior) lets stores turn off the <link rel="preload">
      without affecting the CSS or @font-face definition. f81e3f

    swissup/module-gdpr — 1.9.2 (was 1.8.5)
    • Version 1.9.2 f4ad5b

    • Breeze: fixed visible built-in cookie notice 90033f

    • Version 1.9.1 5e0c58

    • PHP 8.5 fix ea894d

    • Add files via upload 5cabb8

    • Version 1.9.0 30de7e

    • Blocked Cookies => Unregistered Cookies cc499a

    • Show count of blocked cookies in the button label 0a3907

    • Ability to register selected blocked cookie 1d5955

    • Cleanup corresponding blocked cookies when saving the cookie 0fa019

    • Redirect back to the page where the client pressed "Allow selection" 3a28cd

    • Add notice about * in cookie names 423049

    • Cleanup blocked cookies using cron. Keep up to 100 latest records 4eca60

    • PHP 8.5 fix ca663e

    swissup/module-hreflang — 1.6.12 (was 1.6.11)
    • Version 1.6.12 9a7e93

    • PHP8.5
      ReflectionProperty::setAccessible() is deprecated since 8.5, as it has no effect since PHP 8.1 4c3bc5

    • Magento 2.4.9 fix 4b4feb

    swissup/module-navigationpro — 1.19.3 (was 1.17.35)
    • Version 1.19.3 421267

    • Do not use `is_active` from remote entity when creating new item
      Since we have new option `use_remote_include_in_menu`, use it instead
      of syncing `is_active` on item creation. 7c215a

    • Fixed not working ribbon for vertical menu 8fdb50

    • LESS 5.3.0 compatibility 922d05

    • Version 1.19.2 c00164

    • Fixed unresponsive browser when toggling 7000 categories ef21c1

    • Version 1.19.1 b7ca42

    • Added ability to select all items in "Mass Edit" form 6f9251

    • Version 1.19.0 cb840c

    • Added ability to respect "Include in menu" setting 879226

    • Version 1.18.0 58f9cc

    • PHP8.5: Using null as an array offset is deprecated, use an empty string d87100

    • PHP8.5: Non-canonical cast (boolean) is deprecated, use the (bool) b74f7d

    • Ability to set gap for multicolumn layout 65b99a

    • If at least one link is marked as parent, highlight all other parents
      (Event if they are empty) 7026c8

    • Breeze: reset dark colors only if header__dark = true 1032ef

    • Use CSS variables edeed5

    • Fixed not visible loading-mask because of Magento bug 7a6d30

    • Fixed incorrect column width in backend preview 27683b

    • Version 1.17.36 54f8f9

    • Fixed broken resizable columns 69ba9c

    swissup/module-pagespeed — 1.19.0 (was 1.17.3)
    • Version 1.19.0

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 94fd39

    • fix(preload): cap font preloads and fix @font-face parsing (#117)

      Font preloads had no cap and no notion of whether a face blocks rendering, while the url() parsing dropped most real-world forms and could aim a preload at a nonexistent host. Measured on a live Argento product page, 138 KB of preloaded fonts plus an eager 219 KB footer PNG stretched a 68 KB LCP JPEG into a 3.1 s download; removing them took LCP from 3.83 s to 2.11 s on Slow 4G.

      - pagespeed/main/font_preload_limit (default 0, opt-in) caps the preloads per response
      - pagespeed/main/font_preload_ignore skips urls by substring, for icon fonts
      - candidates ranked by effective font-display: render-blocking first, then fallback, then swap; optional never preloaded
      - url() values unquoted, fragments dropped, queries kept, hrefs mirroring the protocol-relative form the delivered css uses
      - @font-face delimited and read on a comment-masked copy, with offset-based injection
      - collection armed one-shot per process() call, so it no longer depends on caller order

      Preload gained a Config constructor argument, so deploys need setup:di:compile.

      Fixes #117 9a26a2

    • fix(preload): make font preloading from stylesheets opt-in (#117)

      Default font_preload_limit goes from 2 to 0.

      Fixing the url parsing means stores whose @font-face urls are quoted or
      absolute — most of them — start getting font preloads they never had,
      because the old code silently dropped those urls. Shipping a default of 2
      would therefore add up to two High priority requests into the LCP window
      on upgrade, which is the exact regression this branch exists to remove:
      on the store that prompted it, 138 KB of preloaded fonts cost 1.7 s of
      LCP. With font-display:swap injected the fonts do not block rendering
      anyway, so the bandwidth buys little.

      The budget and the ranking stay; an admin who knows which weight renders
      above the fold raises it to 1 or 2.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6c93d

    • fix(preload): make font collection one-shot per armed call (#117)

      Sixth review pass pointed out that CssResolver calls process() without
      ever calling setResponse(), so merged stylesheets never contribute
      candidates — my claim that the ranking converges across every stylesheet
      was wrong. Only the critical style blocks feed it.

      Rather than widen collection, which would pull fonts out of every merged
      stylesheet in a change meant to cap them, the gate is now honest and
      deterministic. setResponse() arms exactly the next process() call, so a
      bare process() never pushes. Left armed, whether a merged stylesheet
      contributed depended on the order the two callers happened to run in for
      a given request, which made the preload set vary between requests.

      CriticalCss already chains setResponse() before each process() call, so
      production behaviour is unchanged. The multi-call tests now re-arm the
      same way, which also makes them mirror that caller.

      Also pins the two remaining shapes of the mask's documented limit — a }
      and a ; inside a quoted value — as misses rather than corruption.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 95ecf9

    • docs(preload): record the comment mask's limit and pin its failure mode (#117)

      The mask cannot tell a real comment from a comment opener inside a css
      string or data uri, so a fake opener can blank a whole @font-face. That
      limit is now stated at maskCssComments(), together with why a css
      tokenizer is not worth carrying to read one descriptor.

      The test pins the direction the failure takes: the face is skipped, so it
      gets no injection and contributes no preload candidate, and the
      stylesheet passes through unchanged. Skipping is acceptable; rewriting a
      stylesheet we could not read is not.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 48133a

    • fix(preload): delimit @font-face on a comment-masked copy (#117)

      Fifth review pass.

      A } inside a comment ended the face body early for the plain regex, so
      @font-face{/* } */src:url(a.woff2)} yielded the body "/* " — the url was
      never collected, and worse, the injection then ran
      str_replace('/* ', 'font-display:swap;/* ') across the whole stylesheet.
      The truncation predates this branch; the injection path made it harmful.

      Comments are now blanked to spaces of the same byte length before the
      faces are delimited, so a masked } cannot end a body while every offset
      still points into the original text. Injection splices at those offsets,
      from the end so earlier ones stay valid. That also fixes a second
      injection bug: two identical face bodies in one stylesheet had their
      swap injected by str_replace on the body text, which rewrote both and
      then did it again on the next iteration.

      Invalid duplicate descriptors no longer beat a valid one either. The
      browser drops invalid declarations at parse time, so
      font-display:optional;font-display:garbage stays optional; the last
      valid value wins and auto applies only when nothing valid was declared.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7b87f1

    • fix(preload): validate the whole font-display value (#117)

      Fourth review pass. The value pattern captured the leading alphabetic
      token, so font-display: optional garbage read as optional and its
      preload was suppressed — but the whole value is invalid css and the
      browser falls back to auto, which blocks. Same for a descriptor written
      with !important, which is dropped entirely.

      The value is now taken up to the declaration terminator, trimmed, then
      checked against the five valid keywords, so anything invalid resolves to
      auto as the browser does.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9ca057

    • fix(preload): read @font-face without its comments (#117)

      Third review pass. A css comment may sit wherever whitespace may, so
      font-family:A;/* keep */font-display:optional; hid the declaration from
      the boundary matcher. The face then got a swap injected in front of a
      declaration that stays effective, and was ranked as swap and preloaded
      even though the author marked it optional.

      Every read of a @font-face body now works on a comment-free copy, which
      fixes the same class of bug in url extraction too: a commented-out
      url(old.woff2) was being harvested as a preload candidate and could
      spend the budget on a file the browser never requests. The injection
      itself still targets the original text.

      While there: a face declaring font-display twice resolved to the first
      declaration, where the cascade takes the last.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d0f213

    • fix(preload): match font-display as a declaration, mirror scheme case (#117)

      Second review pass found two more holes.

      The injection guard tested for the substring "font-display" anywhere in
      the @font-face body, so a family named after the descriptor or a font
      path containing the word suppressed the swap injection and left the face
      blocking. Both the guard and the value read now require the descriptor
      at a declaration boundary — start of the body, or after a previous
      declaration.

      Scheme stripping accepted any spelling, but the css rewrite in process()
      is a case-sensitive str_replace: HTTPS://host/f.woff2 stays absolute in
      the delivered css while the preload became protocol-relative, which is a
      different url on an http storefront. The href now mirrors that replace
      exactly and keeps an unusual spelling absolute. The http(s) check also
      moved off filter_var, which accepts file:// happily.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ba6b69

    • fix(preload): move the font budget to Preload and tighten parsing (#117)

      Review follow-up. Four defects in the first pass:

      Selection ran per process() call, but CriticalCss calls the improver
      once per critical <style> block and the css resolver plugin once per
      stylesheet. A swap face in the first block could spend the whole budget
      before a render-blocking face in a later one was seen, and the same href
      in two blocks consumed two slots. Candidates now accumulate in Preload
      and the ranked selection happens in getAssets(), once, when every
      stylesheet has been through the improver. Selection is pure, so
      repeated getAssets() calls agree.

      Deduplication kept the least urgent rank. Wrong: when one woff2 backs
      two families, the family that blocks still blocks, so the file is
      render-critical and the most urgent declaration has to win.

      The font-display regex was case-sensitive and matched only the
      alphabetic prefix of a value, so FONT-DISPLAY: OPTIONAL read as
      render-blocking while the invalid optional-invalid read as optional and
      was skipped. It is now case-insensitive, takes the whole token, and
      resolves anything outside the five valid values to auto.

      Preload hrefs forced https. process() rewrites absolute css urls to
      protocol-relative, so on an http storefront the preload pointed at a
      url the stylesheet never requests. Hrefs now mirror the delivered css:
      protocol-relative for absolute and protocol-relative sources,
      root-relative left alone, non-http schemes skipped.

      Also adds declare(strict_types=1) to the new test file.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8b22be

    • fix(preload): cap font preloads and fix url() parsing (#117)

      Improver pushed a preload for every woff2 it could parse out of a
      stylesheet, with no cap and no check on whether the face blocks
      rendering. Chrome fetches preloaded fonts at High priority, so each one
      competes with the LCP image on the same connection and fetchpriority
      cannot outrank it. Measured on a live Argento product page: dropping
      138 KB of preloaded fonts (plus an eager 219 KB footer PNG) took LCP
      from 3.83 s to 2.11 s on Slow 4G.

      The url handling was broken in both directions. str_replace('//', '/')
      ran before the validity check, so absolute and quoted urls were
      silently dropped, while a root-relative url became https://<first path
      segment> — a preload aimed at a host named "static". The cache-busting
      query was stripped too, so a preload that did survive missed the css
      request and the font downloaded twice.

      - pagespeed/main/font_preload_limit (default 2, 0 disables) caps the
      preloads per response, shared across every stylesheet processed
      - pagespeed/main/font_preload_ignore skips urls by substring, for
      decorative icon fonts
      - candidates are ranked by effective font-display: render-blocking
      first, then fallback, then swap; optional is never preloaded
      - url() values are unquoted, fragments dropped, queries kept, and
      root-relative hrefs left alone; hrefs are deduped

      The budget lands together with the parsing fix on purpose: fixing the
      parsing alone would start emitting the preloads that were being dropped
      and ship the regression by default.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c16e23

    • Version 1.18.8 953e26

    • fix(preload): crossorigin="anonymous" is font-only and host-independent (#115)

      crossorigin was added to any third-party resource hint based on host alone.
      It switches the hint to a CORS-mode fetch, so a third-party script whose
      server sends no Access-Control-Allow-Origin had its response blocked
      (s.kk-resources.com/leadtag.js, #115). Even a permissive third party is hurt:
      a CORS-mode hint never matches the no-cors <script> request, so the file is
      fetched twice.

      The host check was wrong in the other direction too — @font-face fetches in
      CORS mode even from the store's own domain, so every same-origin font hint
      was emitted without crossorigin and could not be reused by the real font
      request.

      crossorigin now depends on the asset type alone: fonts always, whatever host
      they come from; scripts, styles and images never — they mirror the origin
      element instead. An explicit crossorigin from the asset definition or from an
      existing link is preserved; the rule only governs adding one. Applied in both
      createPreloadLinkElement() and updatePreloadLinkInPlace(), so preload links
      the theme or a tag manager already placed in <head> follow it too.

      With the host out of the decision, isThirdPartySource(), getHost() and
      getBaseUrlHost() are dead and removed, along with the StoreManagerInterface
      constructor argument and the try/catch that only swallowed Laminas URI parse
      errors.

      Tests: 8 regression cases in AddLinkPreloadTest — third-party script, style
      and image get no crossorigin; third-party and same-origin fonts keep it;
      explicit crossorigin survives; existing in-head links follow the same rule.

      Fixes #115
      Closes #116 41be41

    • fix(preload): make font crossorigin host-independent (#115)

      Review catch on the previous commit: @font-face fetches in CORS mode even
      when the font sits on the store's own domain, so gating crossorigin on
      "third-party host" left every same-origin font hint without the attribute
      — the hint then cannot serve the real request and the font is downloaded
      twice. That is the same failure this PR set out to remove, only pointed
      at local fonts instead of third-party scripts.

      crossorigin now depends on the asset type alone: fonts always, everything
      else never. With the host no longer part of the decision, the whole
      isThirdPartySource() / getHost() / getBaseUrlHost() chain is dead, and
      with it the StoreManager dependency and the try/catch that only existed
      to swallow Laminas URI parse errors.

      - needsCrossorigin(string $as) — type-only decision
      - Drop isThirdPartySource(), getHost(), getBaseUrlHost(), $baseUrlHost
      - Drop the StoreManagerInterface constructor argument (DI resolves the
      constructor by reflection, so no di.xml change) and the store mocks it
      required in 28 test construction sites
      - Drop the now-unreachable try/catch around both call sites

      Tests: 2 more cases — a same-origin font hint, and a same-origin font
      preload link already in <head>, both must keep crossorigin.

      709 unit, 34 integration — all green. 225428

    • fix(preload): limit crossorigin="anonymous" to font hints (#115)

      A third-party resource hint got crossorigin="anonymous" based on host
      alone, with no regard for the asset type or for what the live tag asked
      for. crossorigin switches the hint to a CORS-mode fetch, so for
      s.kk-resources.com/leadtag.js — a script whose server sends no
      Access-Control-Allow-Origin — the browser blocked the response outright.
      Even a permissive third party is hurt: the CORS-mode hint never matches
      the no-cors <script> request, so the file is fetched twice.

      Only fonts are always fetched in CORS mode and therefore need the
      attribute. Scripts, styles and images must mirror the origin element,
      never invent it.

      - Add CROSSORIGIN_TYPES = ['font'] and needsCrossorigin($as, $href)
      - Use it in both createPreloadLinkElement() and
      updatePreloadLinkInPlace(), so the rule also covers preload links the
      theme or a tag manager already placed in <head>
      - An explicit crossorigin from the asset definition or the existing link
      is still preserved — the rule only governs adding one

      The blanket rule dates back to e7a35a0 (2020), whose own comment noted
      "add warning and more requests but increase score. why???".

      Tests: 6 regression cases in AddLinkPreloadTest — third-party script,
      style and image get no crossorigin; fonts keep it; explicit crossorigin
      survives; existing in-head preload links follow the same rule. Three of
      them reproduce the reported markup exactly against the old code.

      Fixes #115 cc021a

    • Version 1.18.7 d50f2d

    • chore: add repo-specific code-review skill for GitHub Copilot (#114)

      GitHub's PR reviewer suggests adding an agent skill or MCP servers for
      context-aware reviews. Give it one: the strategy-chain / patch-tracking
      architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
      matches, untracked DOM mutations) that a generic review won't know to
      look for, on top of this repo's Magento 2 / phpcs conventions. 5021e3

    • chore: add repo-specific code-review skill for GitHub Copilot

      GitHub's PR reviewer suggests adding an agent skill or MCP servers for
      context-aware reviews. Give it one: the strategy-chain / patch-tracking
      architecture in Model/Dom has a specific failure mode (ambiguous fuzzy
      matches, untracked DOM mutations) that a generic review won't know to
      look for, on top of this repo's Magento 2 / phpcs conventions. 005217

    • fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036) (#113)

      PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
      against picking the wrong element by class alone, but SmartTagStrategy runs
      earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
      element whose attributes cleared a 70% similarity score, with no check for
      a later, better (or exact) candidate.

      Two product images in the same category grid share nearly every attribute
      (class, style, loading, decoding, width, height, sizes, srcset) and differ
      only in src/alt, comfortably clearing 70% similarity - so a patch meant for
      the second image could land on the first one instead, reproducing the same
      image-swap symptom the 1.18.3 fix was supposed to close.

      SmartTagStrategy now scores every candidate and applies the best-scoring one
      only when it isn't tied with another - same "lose the optimization rather
      than corrupt the page" rule AttributeBasedStrategy already uses for class.

      Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
      testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
      first-match-wins behavior on a genuine tie, which is no longer correct. 0cf639

    • fix(dom): guard SmartTagStrategy against ambiguous attribute-similarity matches (#xss-14036)

      PR #100 (1.18.3) hardened AttributeBasedStrategy's class-attribute fallback
      against picking the wrong element by class alone, but SmartTagStrategy runs
      earlier in HtmlReplacer's chain and was never touched. It picked the FIRST
      element whose attributes cleared a 70% similarity score, with no check for
      a later, better (or exact) candidate.

      Two product images in the same category grid share nearly every attribute
      (class, style, loading, decoding, width, height, sizes, srcset) and differ
      only in src/alt, comfortably clearing 70% similarity - so a patch meant for
      the second image could land on the first one instead, reproducing the same
      image-swap symptom the 1.18.3 fix was supposed to close.

      SmartTagStrategy now scores every candidate and applies the best-scoring one
      only when it isn't tied with another - same "lose the optimization rather
      than corrupt the page" rule AttributeBasedStrategy already uses for class.

      Updated SmartTagStrategyTest::testReplaceOnlyFirstMatchingTag, renamed to
      testSkipsReplacementWhenTwoTagsTieOnSimilarity: it asserted the exact same
      first-match-wins behavior on a genuine tie, which is no longer correct. e769d6

    • Version 1.18.6 9678b2

    • fix(test): resolve module and Magento root by walking up, not fixed depth (#112)

      bin/test assumed the module sits exactly 5 levels below the docker root
      and always tested the canonical vendor/swissup/module-pagespeed checkout
      regardless of where the script was invoked from. Any checkout at a
      different depth (a git worktree, a standalone clone) couldn't run the
      suite, and even a resolved root would still test the wrong code.

      Walk up from the script location to find the docker root (bin/clinotty +
      src/), derive the module's path relative to that root, and invoke phpunit
      via its absolute in-container path so a differing checkout depth no
      longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
      canonical path when the checkout isn't under src/.

      tests/bootstrap.php had the same fixed-depth assumption and failed with
      an unhelpful "Failed opening required ..." error. Walk up for
      vendor/autoload.php instead, throw a RuntimeException naming the
      searched path when not found, and derive the Logger/Image/minify
      autoload map entries from the resolved root.

      Fixes #111 f60df9

    • fix(test): resolve module and Magento root by walking up, not fixed depth

      bin/test assumed the module sits exactly 5 levels below the docker root
      and always tested the canonical vendor/swissup/module-pagespeed checkout
      regardless of where the script was invoked from. Any checkout at a
      different depth (a git worktree, a standalone clone) couldn't run the
      suite, and even a resolved root would still test the wrong code.

      Walk up from the script location to find the docker root (bin/clinotty +
      src/), derive the module's path relative to that root, and invoke phpunit
      via its absolute in-container path so a differing checkout depth no
      longer breaks the vendor/bin/phpunit relative lookup. Falls back to the
      canonical path when the checkout isn't under src/.

      tests/bootstrap.php had the same fixed-depth assumption and failed with
      an unhelpful "Failed opening required ..." error. Walk up for
      vendor/autoload.php instead, throw a RuntimeException naming the
      searched path when not found, and derive the Logger/Image/minify
      autoload map entries from the resolved root.

      Fixes #111 c8861a

    • fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack (#110)

      fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
      into two scripts specifically so the assignment stays synchronous. But that split runs before the main
      processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
      that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
      rewrites it to the deferred type exactly like the pre-split single script was, leaving
      window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
      TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.

      - shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
      write) and skips retyping/moving it.
      - fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
      gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
      move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
      the split entirely.

      Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
      document, one reproducing the bug when it doesn't).

      Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> 28291b

    • fix(defer): protect the isCustomerLoggedIn split from Interactive Defer unpack

      fixCustomerLoggedInScript() splits window.isCustomerLoggedIn = window.checkoutConfig.isCustomerLoggedIn; require(...)
      into two scripts specifically so the assignment stays synchronous. But that split runs before the main
      processContext() loop, so the assignment-only node gets walked again with its own jsCounter slot - and once
      that slot passes the unpack offset (2 with dev/js/merge_files on, as production runs it), Interactive Defer
      rewrites it to the deferred type exactly like the pre-split single script was, leaving
      window.isCustomerLoggedIn undefined for anything that reads it synchronously right after. Reproduces #106 /
      TTF-27428 on real CMS/login pages, which carry enough preceding scripts to push it past the offset.

      - shouldProcessScript() now recognizes a solo isCustomerLoggedIn assignment via content only (no extra DOM
      write) and skips retyping/moving it.
      - fixJavaScriptErrors() now calls markDirty() itself when it performs a split. This closes a separate real
      gap: the split's dirtiness was only ever exposed by accident, through the main loop's unrelated
      move-to-end-of-body side effect landing on the same node. Without it, some pages would silently discard
      the split entirely.

      Regression test added: CustomerLoggedInSplitRegressionTest (one case proving the split survives early in the
      document, one reproducing the bug when it doesn't).

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8ad474

    • Merge pull request #109 from swissup/claude/inspiring-elbakyan-cfe2e6

      fix(dom): keep the page's own <meta charset> during optimization 3f00d8

    • fix(dom): keep the page's own <meta charset> during optimization

      TrackedDomDocument injects <meta http-equiv="Content-Type" content="text/html;
      charset=utf-8"> before loadHTML() so libxml interprets bytes as UTF-8, then
      strips it again in its saveHTML() override.

      addUtf8Charset() skips the injection when the page already declares a charset,
      but removeUtf8Charset() stripped unconditionally, and stripMetaCharset()'s last
      pattern matches *any* meta carrying charset=utf-8. On a page that declares its
      own charset nothing was injected, yet the page's own declaration was deleted.
      Magento\Framework\View\Page\Config\Renderer renders <meta charset="%content"/>
      from the `charset` metadata on every page, so every optimized page lost its
      charset declaration.

      Not a libxml artifact and not the patch/strategy layer: libxml round-trips the
      tag intact, the DOM node is never detached, and the loss reproduces on a bare
      parse with zero patches recorded. It happened purely in the string
      post-processing of saveHTML(), which is why the <head> opening tag stays
      byte-identical.

      Impact is usually masked because PHP appends charset to the Content-Type header
      when default_charset is set. With default_charset empty the header carries no
      charset and the meta is the only encoding signal, leaving the browser to guess
      from its locale. Lighthouse also reports "Charset declaration is missing or
      occurs too late", which a page-speed module should not be causing.

      Fix: record what addUtf8Charset() actually inserted ('none', 'meta', 'head' or
      'wrapper') and only strip when we injected something. Fragment-mode structure
      restoration still runs, so the injected helper tag is still cleaned up.

      Regression coverage in CharsetMetaPreservedTest pins the behaviour and the
      cause: the charset survives DeferJs on the minisport production fixture, stays
      the first meta in <head> and inside the first 1024 bytes, a non-UTF-8
      declaration is never rewritten, the injected helper tag still never leaks, and
      two guards assert that libxml preserves the tag and that the loss is
      patch-independent so no replacement strategy can be blamed later.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1951

    • Version 1.18.5

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b3dcbb

    • Merge pull request #108 from swissup/test/production-head-order-27428

      test(defer): cover the stock-RequireJS production head 479d3d

    • test(defer): cover the stock-RequireJS production head

      Every real-page fixture in this suite is a Breeze page, and Breeze never renders
      Magento's RequireJS bootstrap. Nothing covered the configuration the TTF-27428
      failures live in, which is how 1.18.2 and 1.18.3 both shipped with a broken
      bootstrap order.

      Rather than hand-write the head, derive the asset order from the code that
      produces it in production: Magento\RequireJs\Block\Html\Head\Config and our own
      Block\Html\Head\Scripts, both run against a real asset Collection with
      minification reported as enabled and JS bundling on. A developer-mode install
      cannot produce this order at all -- Minification::isEnabled() short-circuits on
      the app mode, so requirejs-min-resolver.js is never even rendered.

      The resulting order matches what the affected store serves, and running the
      released builds through it reproduces each shipped regression:

      1.18.2 the global `var require = {baseUrl}` block and
      requirejs-min-resolver.js are left behind while require.js, mixins and
      requirejs-config stay in <head>. The global object then overwrites an
      already-initialised window.require, which is the reported
      "require.config is not a function", and the .min suffix is never
      applied, which is the reported mage/requirejs/static.js 404.
      5 of 7 tests fail.
      1.18.3 our bundle pool and override.js are relocated and made async.
      3 of 7 tests fail.
      1.18.4 passes.

      Refs TTF-27428

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d2c33

    • Merge pull request #105 from swissup/fix/fuzzy-content-strategy-head-corruption

      fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy (#104) ac1fae

    • fix(fuzzy): guard against <head> corruption in FuzzyContentStrategy

      Two bugs in apply() caused a last-resort patch to replace a <meta>
      tag in <head> with a <body> element (e.g. <img>) when the patched
      node's attribute value (alt, src, class ≥ 10 chars) appeared earlier
      inside a head meta description's content="..." attribute.

      Fixes #104 (radiatorkopen.nl: meta description overwritten by <img>).

      Changes:
      - Restrict strpos search to the <body> substring; <head> content is
      never a valid match target for body-element patches.
      - After expanding to the surrounding element, reject the match if the
      element tag name differs from the patched node's tag name
      (<meta> ≠ <img> → continue to next candidate).

      Tests:
      - testDoesNotReplaceHeadMetaWhenAltTextAppearsInMetaDescriptionFirst
      reproduces the exact radiatorkopen.nl scenario (was failing).
      - testReturnsNullWhenOnlyMatchIsInsideHead covers the variant where
      the only document match is inside <head> (must return null). 772112

    • Version 1.18.4

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> aa4b80

    • Merge pull request #102 from swissup/fix/requirejs-bundle-pool-order-27428

      fix(defer): keep the bundle pool and our RequireJS patches in place 7aeb97

    • test: repair the typo'd DOCTYPE in the offset fixture

      `<!` followed by a space is not a doctype, so libxml parsed it as a bogus
      comment. With LIBXML_HTML_NOIMPLIED the parser then had no document element to
      attach the real <html> to, emitting "misplaced <html> tag"; Magento's unit
      bootstrap promotes that warning to an exception, so the test errored on every
      run instead of asserting anything.

      Also close the space inside the require.js src in the same fixture. That one did
      not fail the test — the assertions only count //script[@src] and compare their
      offsets — but the path was meaningless as written.

      The three other fixtures in this file already spell the doctype correctly.

      Fixes #103

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> cdbf37

    • fix(defer): narrow the RequireJS patch guard to override.js

      The bootstrap guard matched the whole Swissup_Pagespeed/js/lib/requirejs/
      directory, which pinned preload-all-scripts.js in <head> as well. That file is
      not bootstrap: addPreloadAllScripts() add()s it at the end of the asset
      collection rather than inserting it after the min resolver, and its entire body
      runs inside a window load handler, so it cannot race require.config(). Guarding
      it only costs a render-blocking head script on stores with Force RequireJS
      Loading enabled.

      Match 'Swissup_Pagespeed/js/lib/requirejs/override' instead, and correct the
      comment: it described insert()-after-min-resolver placement that applies to
      override.js alone.

      testPreloadAllScriptsStaysOptimizable pins the reverse direction - the file must
      still move to body end and take async past the counter threshold. It fails with
      the directory-wide prefix and passes with the narrowed one.

      Also assert asset presence while collecting positions in
      testLiveHeadKeepsBootstrapChainInOrder: strpos returns false for a dropped
      asset, which asort() sorts first, reporting a bogus ordering violation instead
      of the missing script.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e0e10

    • fix(defer): keep the bundle pool and our RequireJS patches in place

      The 1.18.2 bootstrap guard was meant to cover the bundled module pool, but
      'js/bundle/bundle' only matches Magento's own bundle0.js. Advanced JS Bundling
      names its pool after the layout handle -- default-bundle0.js,
      cms-page-view-bundle0.js -- so none of our bundles ever matched, and
      Swissup_Pagespeed/js/lib/requirejs/override.js was not listed at all.

      Combined with the 1.18.2 counter change, where skipped-but-executable scripts
      consume a slot, these scripts crossed the jsCounter > 5 threshold and started
      getting async -- something that did not happen before 1.18.2. Both call
      require.config(): the bundle pool registers the jsbuild sources, and override.js
      wraps require.config() and must be installed before requirejs-config calls it.
      So on a store running Advanced JS Bundling or Interactive Defer, 1.18.3
      introduced a fresh instance of the very failure class it fixed.

      Match the whole js/bundle/ directory instead of a filename prefix (anything
      living there is a module pool by construction) and add our own RequireJS patch
      directory.

      Regression coverage uses the head sequence dev2.dlastore.com actually serves,
      with both features on: six ignored third-party inline blocks, the global require
      config, then Magento's bootstrap interleaved with the bundle pool. The fixture
      asserts the whole chain stays ahead of the body content, carries no async/defer,
      and keeps its document order. It fails in seven places without the fix.

      Refs TTF-27428

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1b477c

    • Version 1.18.3

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 82a806

    • Merge pull request #101 from swissup/fix/requirejs-bootstrap-coverage

      fix(defer): cover every RequireJS bootstrap asset and stop counter drift 5a4389

    • fix(defer): count only executable scripts and cover global require config

      Three follow-ups on the previous commit in this branch.

      Counting every skipped script restored bootstrap coverage but also counted
      inert blocks. text/x-magento-template, text/html and JSON payloads are never
      executed by the browser, and Magento emits a handful of them on most pages
      (minicart, checkout, swatches). Counting them inflated jsCounter and pushed
      later scripts past the async (> 5) and third-party defer (> 1) thresholds, so
      scripts that used to stay synchronous started getting async and losing their
      execution order. jsCounter now advances only for script tags the browser
      actually runs: an empty type, a JS MIME type, or the configured delayed type
      (those are executed by the delayed-script runtime, so they keep their slot).

      The inline bootstrap matcher only recognized the require.config() call. The
      other legitimate form, a global `var require = {...}` / `window.require =
      {...}` object emitted before require.js loads, carries the same
      baseUrl/paths/map data and is under the same ordering constraint, so it was
      still relocated behind the content and reproduced TTF-27428 on themes that
      use it. Added REQUIREJS_INLINE_GLOBAL_PATTERN.

      Inline matching is textual, not parsed, so a script mentioning the config
      statement inside a string or a comment is opted out too. That direction is
      deliberate - a missed optimization instead of a broken RequireJS bootstrap -
      but it was documented as if only src matching had that property. Corrected the
      docblock and pinned the behaviour with a test. Also gated both patterns behind
      a stripos() check, since the matcher runs on every inline body including the
      multi-hundred-kilobyte checkoutConfig payload.

      Tests: 35 -> 42. Verified against the pre-fix code: 6 failures, one per gap
      (inert-counter case plus five global-config spellings). With this change
      705/705 green, phpcs 0 errors.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ee4c79

    • fix(defer): cover every RequireJS bootstrap asset and stop counter drift

      PR #99 protected only requirejs/require.js, mage/requirejs/mixins.js and
      requirejs-config.js. Magento renders up to eight bootstrap assets in <head>
      (Magento\RequireJs\Block\Html\Head\Config::_prepareLayout), and the rest were
      still relocated to the end of <body>:

      - requirejs-min-resolver.js patches ctx.nameToUrl to append the .min sign and
      is emitted whenever JS minification is on, i.e. in production
      - mage/requirejs/baseUrlResolver.js + requirejs-map.js install the URL map
      - mage/requirejs/static.js and js/bundle/bundle*.js define the module pool

      Moving any of them behind the content reproduces the same failure class as
      TTF-27428: module ids resolve against the bare baseUrl and static URLs are wrong.

      Replace the five ignore signatures with isRequireJsBootstrap(), which matches
      external assets on src only (an unrelated script merely mentioning a bootstrap
      path in its code is no longer opted out of deferring) and inline blocks on the
      require.config()/requirejs.config() call itself, tolerant to any spacing.

      Also increment jsCounter for skipped scripts. Without it every ignore signature
      shifted the unpack/async/defer thresholds of later scripts: with PR #99 only
      three of six eligible inline scripts were still delayed on a page whose <head>
      carries the bootstrap.

      Extend RequireJsBootstrapOrderRegressionTest with a provider over all eight
      minified asset names, seven inline-config spellings, a guard that a script only
      mentioning a bootstrap path stays optimized, and a counter-stability assertion.
      Against the previous code the new cases produce 9 failures; with this change the
      suite is 698/698 green.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8ae870

    • Merge pull request #100 from swissup/fix/category-image-swap-xss-14036

      fix(image): keep the processed marker out of the DOM (#xss-14036) 374f33

    • fix(dom): rank class last in attribute matching (#xss-14036)

      Follow-up on review of the processed-marker fix.

      AttributeBasedStrategy tried `class` before `src`, which is what let the
      production patch land on a foreign product image in the first place. The
      ambiguity guard alone does not cover a class that matches exactly one -
      but wrong - element, so order the attributes from most to least
      identifying and keep `class` as the last resort. Testing for a second
      match from the first match's offset replaces the full-document
      preg_match_all, and a skipped match is now logged instead of silently
      dropping the optimization.

      ProcessedNodeRegistryTrait no longer requires the consuming class to call
      an initializer from its constructor: the storage is created on first use,
      so a reader can never hit an uninitialized typed property. Added __clone
      so a cloned document gets its own registry, and documented that entries
      are keyed by PHP object identity and do not survive a node being rebuilt
      from HTML.

      PipelineCategoryImageSwapTest claimed to reproduce the image swap. It
      does not: in a fixture that small OffsetBasedStrategy still resolves the
      orphaned patch group, so AttributeBasedStrategy is never reached and no
      image is swapped. Its src assertions are non-regression guards; what is
      actually red before the fix is the dropped Lcp patch and the leaked
      marker. Docblock now says so, and the swap stays pinned at unit level.

      Tests: 673 tests, 2030 assertions, OK.
      phpcs --standard=Magento2 on the touched files: 0 errors.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 5e9d02

    • fix(image): keep the processed marker out of the DOM (#xss-14036)

      On a category page the first two product images were rendered with each
      other's <img> tag. Reproduced on mcprod.boutinot.com/wines.html: item 1
      links to CHV722 but shows CHV726, item 2 the reverse. Only the two images
      that LCP marks with fetchpriority="high" were affected, and the wrong
      output was then stored in the full page cache.

      markImageAsProcessed() set data-pagespeed-processed through the raw
      \DOMElement::setAttribute(), so the mutation was never recorded as a patch.
      That made the oldHtml of every later patch of the same node stale, so
      PatchGrouper could not chain it and the patch fell through to the fuzzy
      replacement strategies. AttributeBasedStrategy then matched by
      class="product-image-photo" - shared by every product image - and rewrote
      the first product image with the second product's HTML.

      Keep the marker in an SplObjectStorage registry on TrackedDomDocument
      instead of in the DOM, and stop AttributeBasedStrategy from matching by a
      class that several elements share. A patch that fails to apply only loses
      an optimization; a patch applied to the wrong element corrupts the page.

      Side effect: data-pagespeed-processed no longer appears in page output.
      Nothing in JS, templates or tests reads it.

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> c5b7a3

    • Version 1.18.2 47a960

    • Merge pull request #99 from swissup/fix/requirejs-bootstrap-load-order

      fix(defer): keep RequireJS bootstrap in place so map/paths apply first 9504be

    • fix(defer): keep RequireJS bootstrap in place so map/paths apply first

      DeferJs moved every <head> script to the end of <body>. That relocated
      Magento's RequireJS bootstrap (requirejs/require, mage/requirejs/mixins,
      requirejs-config) behind the body content, so early synchronous require([...])
      calls executed before requirejs-config applied its map/paths. RequireJS then
      resolved module ids against the bare baseUrl, requesting wrong static URLs
      (e.g. domReady.min.js instead of requirejs/domReady.min.js). Browser-dependent
      parse/execute timing made it surface in Chrome but not Firefox (TTF-27428).

      Add the RequireJS bootstrap and inline require.config()/requirejs.config()
      blocks to the defer ignore signatures so they are neither moved, deferred, nor
      made async — same mechanism already used for NREUM.

      Add RequireJsBootstrapOrderRegressionTest guarding the load-order invariant.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> ec4081

    • Version 1.18.1 31e2a3

    • Merge pull request #98 from swissup/fix/directmatch-mbstrpos-perf

      Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches f71c7a

    • Fix DirectMatch O(n*m) mb_strpos on large non-ASCII patches

      DirectMatchStrategy::findAndReplace branched to mb_strpos()/mb_substr() for
      any non-ASCII oldHtml. On PHP 8.2 mbstring that path is ~O(n*m): a Breeze
      enterprise product page produced three merged body-level patches (~310-388KB,
      containing "£"), each taking ~8s in DirectMatch (which then failed the match
      anyway), accounting for ~24s of the ~29s page-generation time.

      Use byte-based strpos()/substr_replace() unconditionally. This is correct for
      UTF-8 because UTF-8 is self-synchronising: a valid substring's byte sequence
      only matches at a character boundary, and substr_replace() operates on byte
      offsets. Also removes the now-dead charPosToBytePos() helper.

      Verified on staging: page generation 32s -> 6.6s. Full module test suite green.

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 779231

    • fix(preload): default missing as attribute to style on existing links

      In-place update (1.18.1) stopped writing the "as" attribute that the
      old re-create path always set. rel="preload" without a valid "as"
      triggers browser warnings and may double-fetch the resource.

      - add regression test for the missing-as default
      - rename testProcessRemovesDuplicatePreloadLinks to match the
      keep-in-place semantics; drop stale comments

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 47d617

    • fix(preload): keep existing preload links in place to preserve CSS order

      AddLinkPreload removed every existing rel="preload" link and re-inserted
      it right after <title>, above the critical <style>. Deferred stylesheets
      (converted by DeferCss, with onload flipping them back to
      rel="stylesheet") ended up before critical CSS in the DOM, inverting the
      cascade: critical CSS overrode the full theme CSS after load.

      - Existing preload links keep their DOM position; per-type rel,
      fetchpriority (within limits) and crossorigin are updated in place
      - Only new preloader assets are inserted after <title>; href dedupe
      against existing links and the live-script prefetch guard are kept
      - Replace applyPreloadChanges() with updatePreloadLinkInPlace() +
      insertPreloadLinks()

      Tests: add regression tests for link position relative to critical
      <style>; suite OK (644 tests, 1944 assertions).

      Fixes #97

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 07ef75

    • fix: replace deprecated SplObjectStorage::contains() for PHP 8.5

      PHP 8.5 deprecates SplObjectStorage::contains(); with an error handler
      that escalates deprecations (e.g. Ignition) this kills page rendering
      inside the optimizer pipeline. Use offsetExists() — identical
      semantics, available since PHP 5.3.

      Fixes #96

      Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> cee3e2

    • Version 1.18.0 75bd79

    • test: add attribute metadata for PHPUnit 12 discovery

      PHPUnit 12 dropped doc-comment metadata: @dataProvider providers were
      not applied and @test snake_case methods were not discovered. Add
      #[DataProvider]/#[Test] attributes while keeping the annotations so
      the suite still runs on PHPUnit 9.6 (CI) and 10.5 (local), which
      ignore unknown attribute classes.

      Also accept the unused third data-set argument in testGetFilename —
      PHPUnit 12 warns when a provider passes more arguments than the test
      method accepts.

      Verified: PHPUnit 12.5.31 exit 0 (631 tests), PHPUnit 10.5.47 OK.

      Refs #92 5b20fe

    • feat!: remove Expire Header feature

      BREAKING CHANGE: Body\Expire optimizer, `pagespeed/expire/*` config
      and its admin section are removed.

      The feature set `Cache-Control: public, max-age=31536000` on document
      HTML for guest pages. Browsers and hosting-side proxies (invisible to
      the Varnish config check) cache such pages beyond any server-side
      flush, so merchants could not push content/price updates to returning
      visitors for up to a year. No PageSpeed/CWV benefit to offset that.
      Orphaned core_config_data rows are harmless.

      Closes #91 f659e6

    • chore: ignore phpunit cache and playwright artifacts be8bda

    • test: drop APIs removed in PHPUnit 12 from Test/Unit

      - replace getMockBuilder('stdClass')->addMethods() with createMock()
      of real classes (Store, Minify_HTML, HeaderInterface)
      - validate optimizers before logger init in Pipeline so expected
      InvalidArgumentException surfaces even without logger classes
      - register module-logger, module-image, mrclay/minify in tests
      bootstrap autoloader (absent from standalone test autoload)

      Refs #92 5393a1

    • fix(preload): prevent double download of scripts already in DOM

      DeferJs: remove addPreloadLinks() entirely — every external <script src>
      stayed live in the DOM while also getting a <link rel=prefetch> hint,
      causing the browser to fetch the file twice (confirmed: CookiePal,
      Elfsight, requirejs-config-breeze on mobile).

      - Remove Preload dependency, preload property, addPreloadLinks() method
      and all 4 call-sites from DeferJs
      - Remove script src collection in prepareElement()

      AddLinkPreload: add defensive liveScriptSrcs guard — skip any script
      href that already exists as a <script src> tag in the DOM, regardless
      of how it ended up in the Preload singleton.

      Tests: remove preloaderMock from DeferJsTest + DeferJsRegressionTest;
      add testScriptsAlreadyInDomAreNotPrefetched and
      testScriptsNotInDomCanGetPrefetchHint to AddLinkPreloadTest.

      Fixes #95 4b539f

    • fix(preload): per-type rel/fetchpriority; honor isEnabled config

      - Replace global priority counter with per-type counters
      - ALWAYS_PRELOAD_TYPES: font/style/image always rel=preload; scripts=prefetch
      - FETCHPRIORITY_LIMIT_BY_TYPE: null=unlimited (fonts), 2 styles, 1 image (LCP), 0 scripts
      - Fix isEnabled() hardcoded true -> config->isLinkPreloadEnabled()
      - Use array_key_exists to correctly handle null sentinel vs missing key (null??0 bug)
      - Add tests: image=preload, scripts=prefetch, per-type fetchpriority limits, isEnabled toggle

      Fixes #93 #94 84b155

    • feat: add dedicated cache type to isolate optimizer entries from block_html

      - Add Model/Cache/Type.php: new Magento cache type (swissup_pagespeed)
      - Add etc/cache.xml: register type with label/description
      - Update CacheManager: use Type::TYPE_IDENTIFIER instead of block_html,
      add Type::CACHE_TAG to entry tags for proper TagScope scoping

      Fixes Redis Default Cache pollution where 1.2M+ SWISSUP_PAGESPEED entries
      were mixed into the block_html cache type. Optimizer cache is now
      independently manageable in Stores > Cache Management. 9a552e

    • Version 1.17.8 fdc783

    • fix: correct double-backslash handling in ScriptTagEscaper JS parser

      A double backslash (\) in a JS string escapes the backslash itself,
      not the following character. The old check 'prevChar === \' incorrectly
      treated the char after \\ as escaped, causing findClosingScriptTag to
      never exit the string and escape the entire remaining HTML including
      </body> and </html>.

      Replace single-char prevChar check with isPrecededByOddBackslashes()
      helper that counts consecutive preceding backslashes: odd = escaped,
      even = not escaped. Applied to both the skip-escaped-char guard and
      the string-delimiter detection. 995cee

    • fix: prevent DeferJs from moving New Relic (NREUM) scripts out of head

      NREUM scripts must execute synchronously in <head> before any other JS.
      DeferJs was moving them to body end, breaking New Relic initialization.

      - Add NREUM signatures to DeferJs ignore list (window.NREUM, NREUM.init,
      NREUM.info, NREUM.loader_config)
      - Remove overly broad NREUM check from AfterRenderResultPlugin that
      disabled ALL optimization when any page content contained 'newrelic'
      - Add position-based tests that fail without the fix and pass with it 174268

    • Fixed script parsing issue caused with New Relic (Unexpected token '<') 2ebe4d

    • fix: add TTL to optimizer cache entries in CacheManager (30-day lifetime) 4b6e50

    • fix: remove block_html tag from optimizer cache entries in CacheManager (close #90) 445044

    • Version 1.17.7 aa19e8

    • Add support for native Magento 2.4.9 dev/js/defer_non_critical option 3f9c2c

    • Magento 2.4.9 fix (CLI execute command) fdc98a

    • fix: replace Zend_Cache constant with string for Magento 2.4.9 compat 9a8169

    • Version 1.17.6 edbcbc

    • refactor: update module-logger to ^2.0, remove from module sequence a8cca6

    • Add bin/test script for running unit and integration tests 8ae030

    • Extract Logger into swissup/module-logger, update namespaces and dependencies cc376b

    • fix: improve interaction mode in requirejs override

      - fix 'touch' event to 'touchstart' for proper mobile support
      - add requestIdleCallback(userInteractionListener) as idle fallback
      - add setTimeout fallback (2s) to ensure JS loads without interaction
      - fix DOMContentLoaded race condition via readyState check
      - extract init() to avoid logic duplication
      - remove dead commented code 95e010

    • Version 1.17.5 b68149

    • fix: remove new-in-initializer default to fix Magento DI compilation

      PHP 8.1 'new in initializers' syntax in IndexedAttributeOffsetFinder
      caused Magento DI compiler to serialize TagIndex via var_export(), which
      requires __set_state(). Fixed by removing the default value and passing
      $tagIndex explicitly from TrackerFactory and test setup trait. 9c8e61

    • Merge branch 'master' of github.com:swissup/module-pagespeed 0621b9

    • Version 1.17.4 33e6c5

    • fix(tests): replace @$dom->loadHTML error suppression with libxml_use_internal_errors 594c79

    • refactor: address code review — rename SiblingIndexCalculator, deduplicate buildIndex, add docs

      - Rename SiblingIndexCalculator → GlobalTagIndexCalculator to reflect that it
      calculates a document-wide index, not a parent-relative sibling position
      - TagIndex: extract maskScripts() as public method; expand TRACKED_TAGS to include
      'style' and MATCH_ATTRIBUTES to include 'data-role', 'data-block'
      - IndexedAttributeOffsetFinder: remove duplicated buildIndex/extractAttributes,
      delegate to TagIndex via constructor injection; remove commented-out is_duplicate guard
      - PatchGrouper: document why maxPasses = count(orphanChains) is the correct upper bound
      - PipelineEasyslideImgLostTest: add @group bisect to testFixtureBinarySearch;
      fix verify_peer: false → true in DOWNLOAD_PROD_HTML stream context 2f337b

    • fix: replace fwrite(STDERR) debug with logDebug, clean up debug tests, add regression tests for orphan picture/img bug

      - PatchGrouper: replace raw $dbg/fwrite(STDERR) in foldOrphanChains() with logDebug() wrapped in isDebugEnabled()
      - PatchGrouper: use count($orphanChains) instead of magic number 10 for maxPasses
      - Add PipelineEasyslideImgLostTest: integration regression tests for <picture> losing <img> after WebP conversion
      - Add production HTML fixture magento1609274-homepage.html.txt for integration tests
      - Remove PatchGrouperDebugTest (inspection-only debug test, no real assertions)
      - Remove testTrailingNewlineEffect and testDebugFullFixture (debug/inspection tests) 83b5e3

    • fix: fold orphan DOM chains so <picture> always contains <img> after WebP conversion

      When WebP optimizer wraps an <img> in a <picture>, the new <picture> element
      and its parent container are both 'orphan' patch chains (offset=null) because
      neither is in the DOM position index. The old inlineOrphanChain only searched
      anchored groups, so the <picture> patches were never applied and <picture>
      ended up without an <img> child on pages like Hero Slider with multiple slides.

      Introduce foldOrphanChains(): a multi-pass algorithm that folds child orphan
      chains into parent orphan chains by string-matching the child's intermediate
      HTML snapshot inside the parent's newHtml. After folding, the parent chain
      carries the fully-resolved <picture><source>...<img></picture> HTML and
      is emitted as a standalone group in the correct position.

      Add two regression tests that call indexNodes() before processContext() to
      reproduce the bug and verify the fix across single- and multi-slide layouts. fb1b3a

    • fix: correct WebP picture/img offset calculation for pages with script templates

      - SiblingIndexCalculator: use global getElementsByTagName() index instead of
      local previousSibling iteration so sibling index matches TagIndex candidates[]

      - TagIndex + IndexedAttributeOffsetFinder: mask <script> block bodies before
      regex scanning to prevent fake <img> inside Knockout/text-html templates from
      polluting the candidates array and shifting sibling indexes

      - ParentContextOffsetFinder: introduce calculateLocalSiblingIndex() that counts
      position among siblings within the immediate parent (not document-wide),
      fixing navigation <span> offset resolution

      - SiblingIndexTagOffsetFinder: restore is_duplicate guard so duplicate opening
      tags defer to ContentPriorityOffsetFinder for content-based disambiguation

      - WebPTest: add regression tests for easyslider mixed eager/lazy slides and
      for pages with script-template fake <img> nodes (magento-1609274 production bug) a1dfec

    swissup/module-pro-labels — 1.10.1 (was 1.8.1)
    • Version 1.10.1 c44745

    • Add null check for associated products in getCheapestFromGrouped method 9156db

    • Version 1.10.0 79b19f

    • Restore deprecate getProductLabels method for compatibility with older versions (close #47). 85974e

    • Refactor models (#46)

      * Refactor Data, LabelsProvider, ManualLabelsProcessor, and SystemLabelsProcessor to use ProductLabels helper and improve type hinting

      * Refactor Catalog helper to improve type hinting and streamline constructor properties

      * Refactor AbstractLabel and ProductLabels helpers to improve type hinting and streamline method signatures

      * Refactor LabelInterface and Label model to improve type hinting and streamline method signatures

      * Address Copilot suggestions. 072cea

    • Address Copilot suggestions. 5a19dd

    • Refactor LabelInterface and Label model to improve type hinting and streamline method signatures f5a2e2

    • Refactor AbstractLabel and ProductLabels helpers to improve type hinting and streamline method signatures fac13c

    • Refactor Catalog helper to improve type hinting and streamline constructor properties bd402b

    • Refactor Data, LabelsProvider, ManualLabelsProcessor, and SystemLabelsProcessor to use ProductLabels helper and improve type hinting 26bb79

    • Refactor renderLabels method to use require for prolabels module 067179

    • Feature/active period (#45)

      * Add from_timestamp and to_timestamp fields to Label model and interface

      * Add isActive function and filterLabels method to manage label visibility based on timestamps

      * Implement AMP helper integration and enhance label fetching logic.
      Fetch only active labels when AMP storefront is enabled.

      * Address Copilot suggestions. c7e128

    • Address Copilot suggestions. d937b5

    • Implement AMP helper integration and enhance label fetching logic.
      Fetch only active labels when AMP storefront is enabled. 2f1652

    • Add isActive function and filterLabels method to manage label visibility based on timestamps bc3140

    • Add from_timestamp and to_timestamp fields to Label model and interface bec56c

    • Version 1.9.2 54e250

    • Fixed ParseError error with template minification enabled d87fce

    • Version 1.9.1 8cf2b6

    • Fixed PHP<8.3 support (Typed const) 02e1ce

    • PHP8.5: Using null as an array offset is deprecated, use an empty string 6402e3

    • Fix call to undefined method preloadManualForProducts (close #43) 329b16

    • Magento 2.4.9 fix 2ad4a7

    • Refactor LabelsProvider and related services for improved structure and clarity; add ManualLabelsProcessor and OutputStrategyProcessor 19cc65

    • Refactor PHP templates to improve code structure and add strict types db06bb

    • Add media gallery toggle functionality and enhance CSS for disabled state e30cd6

    • Update templates and JavaScript for enhanced preview features; make it clearer and simplier. 3de63d

    • Remove obsolete file uploader fix for Magento 2.3 from prolabels_label_edit.xml 980ce3

    • Refactor CategoryLabelFieldset and ProductLabelFieldset for improved code clarity and structure 0acc63

    • Refactor image control handling and add dynamic stylesheet loading de2a78

    • Version 1.9.0 2d302a

    • Use "Upload + Media gallery" component for prolabels image (#42)

      * Use "Upload + Media gallery" component for manual prolabels edit form.

      * Add Media Gallery to New Labels on product page.

      * Add UpdateImagePath data patch to update image paths for system and manual labels

      * Refactor image field configuration to use MediaGallery frontend and backend models for category and product settings

      * Fix URL cleanup for existing images in ImageUploaderService

      * Add null checks in MediaGallery for safer image data handling

      * Fix not working preview for system labels.

      * Update image path resolve on storefront.

      * Fix MCS.

      * Address Copilot suggestions. 86e25e

    • Address Copilot suggestions.

      Co-authored-by: Copilot <copilot@github.com> 4c25b9

    • Fix MCS.

      Co-authored-by: Copilot <copilot@github.com> 558846

    • Update image path resolve on storefront.

      Co-authored-by: Copilot <copilot@github.com> d74c2d

    • Fix not working preview for system labels.

      Co-authored-by: Copilot <copilot@github.com> 94f32c

    • Add null checks in MediaGallery for safer image data handling ad0851

    • Fix URL cleanup for existing images in ImageUploaderService 819ab3

    • Refactor image field configuration to use MediaGallery frontend and backend models for category and product settings

      Co-authored-by: Copilot <copilot@github.com> 12c1c2

    • Add UpdateImagePath data patch to update image paths for system and manual labels

      Co-authored-by: Copilot <copilot@github.com> ae4b4f

    • Add Media Gallery to New Labels on product page.

      Co-authored-by: Copilot <copilot@github.com> 7f8cb3

    • Use "Upload + Media gallery" component for manual prolabels edit form.

      Co-authored-by: Copilot <copilot@github.com> 91c141

    swissup/module-quantity-switcher — 1.2.4 (was 1.2.1)
    • Version 1.2.4 e4c020

    • Merge pull request #5 from swissup/fix/arrow-room-lost-after-mount

      fix(arrows): keep the arrow room after the switcher mounts 1a9480

    • fix(arrows): keep the arrow room after the switcher mounts

      `.control:has(> .input-text.qty)` only matches the server-rendered markup.
      `product.js` wraps the input into `.qty-wrapper` on init, after which the input
      is no longer a direct child of `.control`, the rule stops matching and the 20px
      reserved on each side for the arrows disappears — the arrows overflow the field
      and the layout shifts once JS runs.

      Match the input as a descendant instead, which holds both before and after the
      wrap. Verified in the browser: `.control` padding went `0px 20px` -> `0px` the
      moment the wrapper was inserted, and stays `0px 20px` with this selector.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 082597

    • Version 1.2.3 f2d44f

    • Fixed MSI error for bundle products (with no MSI source) on the cart page aa1c46

    • Version 1.2.2 0797d6

    • Fix TypeError when product type_id is null in getMaxQty 48a93f

    swissup/module-rich-snippets — 1.8.5 (was 1.7.18)
    • Version 1.8.5 2685fe

    • Refactor Offers class to use PriceFormatter for price formatting (#43) 830f60

    • Enhance AdditionalProperty handling by adding 'id' field and include it in structured data as 'propertyID' (#43) c05490

    • Version 1.8.4 3c45a4

    • Add regex validation to modifier modal and alert on invalid patterns d3f200

    • Fix issue with not saving of reordered modifiers. 7f18af

    • Add regex modifier for PCRE find-and-replace functionality (#43) d10bd9

    • Add strip_pagebuilder modifier to remove Page Builder markup (#43) 7a6f70

    • Version 1.8.2 5ee8bc

    • PHP8.5
      ReflectionProperty::setAccessible() is deprecated since 8.5, as it has no effect since PHP 8.1
      Using null as an array offset is deprecated, use an empty string instead e38455

    • Refactor modifier modal and builder manager to improve event handling and modal interactions

      Co-authored-by: Copilot <copilot@github.com> 30beba

    • Version 1.8.1 bc23a1

    • Remove unused modal dependency from modifier builder 4060db

    • Version 1.8.0 d81a93

    • Refactor AdditionalProperty and implement visual modifier builder (#42)

      * Modifiers for AdditionalProperty

      * Refactor AdditionalProperty and ModifiersRenderer for improved modifier handling and UI enhancements

      * feat: Implement visual modifier builder with modal interface

      * Address MCS errors and warnings.

      * Address Copilit suggestions. fc4df1

    • Address Copilit suggestions. c01f14

    • Address MCS errors and warnings. 488ddc

    • feat: Implement visual modifier builder with modal interface ab2311

    • Refactor AdditionalProperty and ModifiersRenderer for improved modifier handling and UI enhancements e9cfbf

    • Breeze: Fixed not working breadcrumbs when merge is enabled 5150a7

    • Modifiers for AdditionalProperty fbdf7b

    • Add AdditionalProperty support for structured data and configuration (#41) c2dc26

    • Add skipCrosslinksProcessing parameter to productAttribute method ed9877

    • Added 'Local Business' section c7d12a

    swissup/module-seo-canonical — 1.11.10 (was 1.11.9)
    • Version 1.11.10 c94f86

    • Refactor type hints and return types in Config, Data, and UrlMaker classes af4b62

    • Fallback to current store when product is not assigned to forced store (close #2) 71c8ec

    swissup/module-seo-cross-links — 1.2.0 (was 1.1.26)
    swissup/module-seo-html-sitemap — 1.1.23 (was 1.1.21)
    • Version 1.1.23 75c59b

    • Added Hide Attribute Pages config c0dedb

    • Version 1.1.22 6b6b09

    • Added ability to set custom sitemap URL per store; added canonical tag d738b7

    swissup/module-seo-pager — 1.4.0 (was 1.3.5)
    • Version 1.4.0 0b9adc

    • Refactor Renderer class: streamline constructor properties and extract request variable logic into a separate method 5831e2

    • Enchance allowlist filter sets configuration for strategy. Update canonical URL. Close #6 07b704

    • Refactor Data helper: improve type hints and use constants for configuration paths c0f932

    • Add allowlist filter sets configuration for strategy (#6) 38c8b2

    swissup/module-seo-templates — 1.7.12 (was 1.7.10)
    • Version 1.7.12 4f7a81

    • PHP 8.5 fix 014b8a

    • Version 1.7.11 485c39

    • Add index to swissup_seotemplates_data for improved query performance (close #22) 5c24d9

    • Introduce FilterValueResolver service to make module compatible with Smile Elasticsuite (close #21) d54a51

    swissup/module-seo-urls — 1.5.55 (was 1.5.54)
    swissup/module-seo-xml-sitemap — 1.2.0 (was 1.1.16)
    • Version 1.2.0 c26fd8

    • Refactor Category plugin for improved type safety and code clarity. Drop Magento2.1.x support. 1c398c

    • Feature/product min lastmod (#6)

      * Refactor Category plugin for improved type safety and code clarity. Drop Magento2.1.x support.

      * Add product options configuration and date handling in admin settings

      * Add forced minimal lastmod date handling for products in sitemap

      * Refactor Category plugin to use Helper for image include policy retrieval

      * Add strict types and improve type safety in Data helper methods

      * Refactor XML configuration for sitemap settings by modularizing into separate files for categories, pages, other links, and advanced settings.

      * Address Copilot comments 1fc7bc

    • Address Copilot comments f7bf57

    • Refactor XML configuration for sitemap settings by modularizing into separate files for categories, pages, other links, and advanced settings. 55e24f

    • Add strict types and improve type safety in Data helper methods 782f41

    • Refactor Category plugin to use Helper for image include policy retrieval 386e7d

    • Add forced minimal lastmod date handling for products in sitemap e69cb2

    • Add product options configuration and date handling in admin settings 372385

    • Refactor Category plugin for improved type safety and code clarity. Drop Magento2.1.x support. d7935f

    swissup/module-sold-together — 1.11.4 (was 1.11.3)
    • Version 1.11.4 884c4f

    • PHP8.5
      ReflectionProperty::setAccessible() is deprecated since 8.5, as it has no effect since PHP 8.1 77cd08

    • PHP8.5: Non-canonical cast (boolean) is deprecated, use the (bool) 75227c

    • Magento 2.4.9 fix (CLI execute command) a8f141

    • Fixed ability to show `Customer` block on the shopping cart page 3b7a4e

    swissup/module-sold-together-import-export — 1.0.2 (was 1.0.1)
    swissup/module-testimonials — 1.4.2 (was 1.4.0)
    • Version 1.4.2 4c12e4

    • Fixed wrong output when rating is not set 64ce08

    • Added show image config and more options for visible slides dropdown 283b05

    • Version 1.4.1 8ca632

    • Slider widget: added arrows/dots configuration 68ea17

    swissup/theme-frontend-argento-blank — 1.49.5 (was 1.49.4)
    • Version 1.49.5 a40b6d

    • Remove call of deprecated method getProductLabels from product list template 23db6e

    • Sync actions template with magento to reuse `addto` block 7f9662

    • Revert "Magento 2.4.9 fix"

      This reverts commit 49e4b6c4385c2d5ac80c8fa936375c232cc3d957. 8806ef

    • Magento 2.4.9 fix 49e4b6

    swissup/theme-frontend-argento-chic — 1.48.3 (was 1.48.2)
    swissup/theme-frontend-argento-essence — 1.46.4 (was 1.46.3)
    swissup/theme-frontend-argento-flat — 1.46.6 (was 1.46.5)
    swissup/theme-frontend-argento-force — 1.48.3 (was 1.48.2)
    swissup/theme-frontend-argento-home — 1.48.5 (was 1.48.4)
    swissup/theme-frontend-argento-luxury — 1.48.2 (was 1.48.1)
    swissup/theme-frontend-argento-mall — 1.48.2 (was 1.48.1)
    swissup/theme-frontend-argento-marketplace — 1.48.4 (was 1.48.3)
    swissup/theme-frontend-argento-pure2 — 1.46.6 (was 1.46.5)
    swissup/theme-frontend-argento-stripes — 1.48.3 (was 1.48.2)
    swissup/module-core — 1.13.1 (was 1.12.26)
    • Version 1.13.1 5b7b24

    • Fixed invisible update instructions when Mollie Payments is installed 72aab6

    • Version 1.13.0 f34c7b

    • Fill the links after ajax refresh b6a316

    • Show loading state on the "Check for Updates" button 1ab16e

    • Use optimized repo to fetch latest releases 1aa8fb

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2

    • Mark core section with badge when outdated modules found 9a3764

    • Outdated modules badge and Modules config group (#25) 7df026

    • MCS fix 3b9121

    • Update instructions 0e686f

    • Read links info from marketplace meta 21e95b

    • Keep the last check time when a refresh fails

      Removing the stored time was what made the next load re-check the feed.
      An unreachable feed left no time at all: the config page had nothing to
      display, and every page load kept re-checking until one request made it
      through.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9

    • Post the modules refresh to an action of its own

      The refresh drops the version check throttle and re-reads the remote
      source, and a get carries no form key validation - Magento checks it for
      post requests only, and the url secret key is optional. Reading the list
      stays a get, so the two live in separate actions now.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9

    • Keep the module links out of the response

      The links cell is rendered by the server and never touched by js.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db

    • Send only the module metadata the browser renders

      The merged Loader record carries the local filesystem path and the
      download, license and purchase-code fields of the remote feed. None of
      them is rendered by the config page, so keep them on the server.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425

    • Too much red 2e307e

    • Use the same `outdated` class name everywhere

      The version span was already renamed, while the row and the js still said
      `_outdated` - a row that stopped being outdated kept its red version pill.

      Since the row now wears the very same class, the pill rule had to be
      scoped to the version cell, or the whole row would turn red.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086

    • Report the check time with the modules, and keep outdated rows first

      Two things the clients were guessing at:

      The `Check for Updates` handler stamped the label with the current time,
      but the request may have re-used the stored packages after a failed feed
      request - `just now` was not always true. And an automatic load did check
      the remote source, yet nothing updated the label at all. The endpoint now
      returns `['items' => ..., 'last_check' => ...]`, and the label follows the
      time the server reports for every load.

      The table was re-rendered cell by cell, so a module that became outdated
      kept its old position while the counter above it grew. Mark the rows and
      move the outdated ones back to the top - both groups stay sorted by name.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271

    • Keep the last check time once the check is due

      The timestamp was stored with a one hour lifetime and the throttle was
      `is the entry still there?`. Handy, but it also meant the config page
      could no longer tell how old the data was as soon as the check was due -
      exactly when that is worth showing next to `Check for Updates`.

      Store it without a lifetime and compare the age instead. Same hour
      boundary, and the label now reads `Last checked 2 h ago` instead of
      disappearing.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a

    • Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66

    • Hovered color for outdated badge 0a0016

    • Show outdated modules count in config and add Modules group

      The admin had no hint that installed Swissup modules have updates
      available - one had to open Module Manager to find out.

      Add a counter badge to the Swissup tab of the config navigation, and a
      Modules group in Swissup > Core listing every installed module with its
      installed and latest version, outdated ones first.

      Neither of them may slow down the config page, so both render from the
      data stored in var/swissup/core (Loader::setOfflineMode()), and the
      versions, the counters and the badge itself are then refreshed by a
      single ajax request shared by all the consumers. `Check for Updates`
      re-runs that request with refresh=1, which drops the version check
      throttle and re-reads the remote source.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6

    • Move core config to the top as it will display outdated modules 115f37

    • Added ability to immidiately re-check remote source for updates `-r` 357d33

    • Keep the remote packages data in files instead of the cache (#24) cbeecc

    • Trim the comments down to what is not in the code

      The two lock docblocks carried the same paragraph, and save() explained
      the reasoning that belongs in the commit that introduced it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9

    • Serialize the writes with a lock taken before the entry is opened

      openFile() truncates whatever it opens, so a lock taken afterwards keeps
      the writes apart but not the truncations. Two saves could interleave into
      one file: the second one truncates, the first one writes a whole entry,
      and the second one then overwrites its beginning - leaving the head of
      one entry on the tail of another. The length line does not catch that
      when both entries encode to the same size, as fixed width values like a
      version hash always do.

      Take the lock before opening, on a file of its own - the entry cannot be
      opened before the lock is held, and the file lock() uses is likely held
      by the caller already, which flock would deadlock against.

      Failing to open a lock file is no longer reported as a lock held by
      somebody else. A lock left behind by another user cannot be opened at
      all, and reporting that as contention kept the caller away from the
      entry for good - silently serving a list that could never be refreshed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28

    • Don't download the list that another process has just stored

      The version is fetched before the download lock is taken, so a process
      that was still asking for it when the lock was released would go on to
      download the very list the lock holder had just stored. Look at the
      storage once more before downloading.

      Also correct what the save() lock claims to do: it serializes the
      writes, but the truncation happens in openFile(), outside of it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0

    • Reject an entry whose expiration time is not a number

      A corrupt header such as `expires:invalid` was cast to zero, which reads
      as "never expires" - so an entry that should have gone stale could be
      served indefinitely instead of being treated as missing. Validate the
      field the same way the length line is validated.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16

    • Keep the remote packages data in files instead of the cache

      The packages list was kept in the Magento cache, so every cache flush
      threw it away and the next admin request had to download it again. It is
      now stored under var/swissup/core, where it outlives the flush.

      FileStorage writes the expiration time and the data length ahead of the
      contents, so an entry knows itself when it goes stale, and a reader can
      tell a half-written entry from a complete one - openFile() truncates the
      file before the write lock is taken, so a reader can catch the entry
      mid-save. An incomplete entry is reported as missing and refetched,
      rather than served as if it were whole.

      Remote revalidates against the version hash from packages.json, at most
      once an hour, and only downloads the full list when that hash moves. The
      download itself is guarded by a non-blocking lock, so several admins
      hitting the page at once produce one request instead of one each - the
      others serve the stored copy, or wait for the download when there is
      nothing stored yet. fetch() now reports connection errors and 4xx/5xx
      responses by returning an empty body, letting the caller fall back to
      the stored data instead of decoding a failure page.

      The admin notification feed keeps its last-update timestamp in the same
      storage, replacing its own copy of the file handling.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd

    • Small fixes f7eef6

    • Single source of truth for is_outdated, is_installed 37ad01

    • Fixed `Undefined array key "type"` warning b2a9c1

    • Don't show non installed modules when asking for outdated bf5fb1

    • Version 1.12.27 ac70c7

    • Magento 2.4.9 fix (CLI execute command) d224be

    • Prevent news retrieval after each cache flush ac96b7

    swissup/module-image-optimizer — 1.0.12 (was 1.0.11)
    • No commits found

    swissup/module-theme-editor — 1.12.0 (was 1.11.6)
    • Version 1.11.7 762ece

    • M249: Fixed Unable to get content for '_theme-editor.less' 7a98c9

breezefront / breeze-enterprise

3.3.0 Aug
  • 3.3.0

    Report (rev.1)

    Package was generated and uploaded to GitHub on with the following dependencies:

    swissup/module-ajaxsearch:1.14.11

    swissup/module-breeze-ai:1.3.0

    swissup/module-breeze-content-builder:1.2.0

    swissup/module-breeze-content-builder-migration:1.0.1

    swissup/module-breeze-layout-builder:1.2.0

    swissup/module-easy-slide:1.8.18

    swissup/module-easycatalogimg:1.5.15

    swissup/module-navigationpro:1.19.3

    swissup/module-testimonials:1.4.2

    swissup/theme-frontend-breeze-enterprise-apollo:3.3.0

    swissup/module-core:1.13.1

    swissup/theme-frontend-breeze-enterprise-blank:3.3.0

    Changelog (autogenerated)

    swissup/module-ajaxsearch — 1.14.11 (was 1.14.10)
    • Version 1.14.11 0db900

    • Fixed layout shift when result list is long 8715cc

    • Fixed overflowed content in Firefox (Apollo theme) de660b

    • Merge pull request #57 from swissup/phpunit12-test-api-56

      Migrate Test/Unit off removed PHPUnit APIs (PHPUnit 12 / PHP 8.5) 095093

    • test: migrate removed test APIs for PHPUnit 12 / PHP 8.5

      The CI runner targets PHP 8.5, which requires PHPUnit 12.x. Migrate the
      unit tests off APIs removed in PHPUnit 10-12:

      - setMethods() (removed v10) -> onlyMethods(); all mocked methods verified
      to exist on the target class via reflection. Dropped dead duplicate mock
      builders (Magento CollectionFactory immediately overwritten by the
      Swissup one).
      - getMockForAbstractClass() (removed v12) -> createMock().
      - will($this->returnValue()/returnSelf()/onConsecutiveCalls()) (removed
      v12) -> willReturn()/willReturnSelf()/willReturnOnConsecutiveCalls().
      - Non-static data providers (hard error v12) -> public static function.

      Also fixed two removed APIs not listed in the issue but caught while
      running the suite:

      - $this->at() (removed v10) in DataTest and ProductTest -> willReturnMap()
      / atLeastOnce() / consecutive-call stubs.

      InitTest was stale independently of PHPUnit: it exercised a removed
      getClassNames() method and the old constructor/getSettings signature.
      Realigned it to the current Init block and dropped the redundant
      getClassNames test (already covered by Helper\DataTest).

      Verified: 41 tests, 99 assertions, 0 errors, 0 deprecations on
      PHPUnit 10.5 / PHP 8.3 (`bin/test`).

      Refs swissup/module-ajaxsearch#56

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 20854c

    • test: add module-local PHPUnit runner

      Add bin/test and phpunit.xml.dist so the unit suite can be run in
      isolation via `bin/test` (runs vendor/bin/phpunit inside the container),
      mirroring the setup already present in module-askit. .gitignore keeps the
      PHPUnit result cache out of the repo.

      Refs swissup/module-ajaxsearch#56

      Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> a4e899

    swissup/module-breeze-ai — 1.3.0 (was 1.0.12)
    • Version 1.3.0 050f99

    • Merge pull request #83 from breezefront/fix/ai-buttons-overlap-name-82

      fix(admin): stop AI buttons overlapping long field values (#82) 3348b6

    • fix(admin): lay AI actions beside field, not over it (#82) 3eb55f

    • fix(admin): stop AI buttons overlapping long field values (#82) 1a4854

    • Merge pull request #81 from breezefront/feat/editor-chat-panel

      feat(content-builder): chat panel in the editor for building and refining a page (#64) 0932fb

    • fix(content-builder): give the message textarea an accessible name b48efb

    • fix(content-builder): ground every request in the live working copy

      pageContext() only supplies the page's title and id, so a first chat
      message had no representation of the page's actual content, and manual
      edits made between AI replies were silently overwritten by the last
      frozen AI document. Every request now seeds or refreshes its document
      turn from contentBuilder.getComponents() instead. Also drops historical
      image attachments from the request — maxBytes is sized for one
      screenshot under post_max_size, and replaying every prior attachment on
      each follow-up can push the aggregate body past that limit. 9ca7e3

    • fix(content-builder): drop a stale AI response after a page switch (#64) 371c91

    • refactor(content-builder): share the chat loop between the modal and the editor panel (#64)

      Extracts turn storage, chat-bubble rendering, and copy-to-clipboard into
      chat-core.js, used by both chat.js's field-targeted modal and
      bcb-chat-panel.js's docked panel. The request/response cycle stays local
      to each caller — they talk to different skills with different payload
      and response shapes, and forcing that through one function would trade
      duplication for a worse, branchier abstraction.

      Fixes two bugs the duplication had introduced in bcb-chat-panel.js: the
      Copy button was never bound (only chat.js's copy of the handler existed)
      and a stale "type a message" error could survive a valid follow-up
      submission. 1ab342

    • fix(content-builder): keep the generated document in follow-up history, guard double-submit a48469

    • feat(content-builder): chat panel in the editor for building and refining a page (#64)

      Docks a conversational panel into the Content Builder editor's tree panel
      via a mixin on bcb-panel.js. Every reply is applied straight into the
      working copy through content-builder.js's applyAiComponents(), and every
      request is sent persist:false so nothing reaches the draft until the
      admin uses the editor's own Save/Publish. 9e66ae

    • feat(content-builder): add non-persisting build endpoint and editor chat config (#64)

      Adds options.persist=false to Controller/Adminhtml/Page/Build so a request
      can return generated components without writing the Content Builder draft.
      Extracts vision-model selection out of BuildFromScreenshot into a shared
      VisionModelProvider, and adds Block/Adminhtml/Editor/AiChatConfig plus its
      layout/template to seed the editor route with the same config. 6eca5e

    • Merge pull request #80 from breezefront/claude/focused-payne-b60751

      fix(content-builder): reproduce every band the screenshot shows (#74) a84559

    • fix(content-builder): word the band warnings for the request that was sent (#74)

      Four defects found reviewing the review fixes.

      The count-mismatch warnings hardcoded "The reference design was read as ...",
      but the mismatch branch runs whether or not a screenshot was attached — the
      prompt asks for a band count on every build, not only on the image path. A
      text-only brief that came back over- or under-segmented therefore told the
      merchant about a reference design they never sent, on the build note and in
      the Content Builder editor. The flag that decides whether to warn now decides
      what the warning calls the thing the page was counted against.

      The band-wrapper warnings borrowed ComponentValidator's "root[N]" label while
      numbering a different list. The validator indexes the raw model output, where
      a dropped node still consumes its index; the audit sees the list after
      validation. One dropped band was enough to put two warnings saying "root[0]"
      in one list, pointing at two different bands. They are numbered as bands now.

      readScreenshot() read and base64-encoded the file before ImageValidator got to
      apply MAX_IMAGE_BYTES, so an oversized design export cost about 2.3x its size
      in memory to reach a verdict available from filesize(). The admin path is
      unaffected: the browser posts base64 and no file is read.

      FiveBandMock::drawProductRow() took $bottom and never used it, so that band
      alone would not follow a change to the band split in draw() — in the one file
      whose docblock claims the band boundaries are readable in source. 685502

    • fix(content-builder): keep the band count when the reply carries prose (#74)

      Three defects found reviewing the band-audit change.

      The prompt says "no prose" and models add it anyway, which is why
      jsonCandidates() exists at all. It tries the widest "[...]" run before
      "{...}", correct while a bare list was the asked-for shape. With an envelope
      whose "unsupported" is omitted and "components" last, that run is exactly the
      components array: it parses, wins, and the band count is thrown away. The
      audit then reported "the AI did not report how many bands it saw" for a reply
      that did report it. A list is now held as a fallback and the remaining
      candidates are still tried for an envelope, so a reply that really is a bare
      list still resolves to the first list.

      spreadStackedSlots() decided a band was stacked from the count of the first
      slot alone. "children" written as a flat list of nodes rather than a list of
      slots lands there with the node itself as the first slot, and its own keys are
      counted as sibling blocks — {"type": ..., "props": ...} counts 2 in a
      two-column band and was shredded across the slots, replacing the accurate
      short-slot warning with a repair that never happened. Every entry now has to
      look like a node.

      render.php ignored the file_put_contents result and printed "(0 bytes)" with
      status 0 on an unwritable path, sending whoever reproduces a measurement to
      look at the build command instead. ee671e

    • fix(content-builder): reproduce every band the screenshot shows (#74)

      Shown a five-band reference design, gpt-4o-mini returned a document whose
      columns bands were fakes: the container had the right type and the right
      column count, but every card sat in its first slot, so the band rendered as
      one column with the rest empty. Measured over ten runs of the committed mock,
      that happened in nine of them.

      Three changes, in order of how much they hold:

      - ComponentValidator spreads a container whose whole band was written into
      its first slot, when the arithmetic leaves no other reading. Prompting
      against this only half worked; the repair is unambiguous.
      - The prompt asks for {"bands", "unsupported", "components"} instead of a
      bare array, spells out the slot shape, and names the columns-versus-
      product_grid choice. A bare array is still accepted.
      - BandAudit compares the band count the model reports to what it emitted and
      reports the difference, plus any band the catalog cannot express. It
      reports and does not retry: a second round trip would double the cost of
      every build to re-roll an answer a small model is as likely to get wrong
      again, and the missing band cannot be synthesised locally.

      Measured with the mock and expected shape now under Test/Fixture, driven
      through the new --screenshot option. Ten runs each, gpt-4o-mini:

      before: 5 bands x5, 6 bands x4, 1 hard failure; 9/9 mis-slotted
      after: 5 bands x10, 0 mis-slotted, 8/10 with both columns containers

      gpt-4o gets the slots right with either prompt, so the mis-slotting was
      model capacity and the prompt closes the gap on the small model. 0e5f35

    • Merge pull request #79 from breezefront/fix/build-note-page-id-type

      fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7faf89

    • fix(content-builder): match cms_page.page_id column type in the build note table (#72) 7f6aaa

    • Merge pull request #78 from breezefront/claude/priceless-turing-a344ac

      fix(config): offer model names the providers still serve (#73) 21eee6

    • Merge pull request #77 from breezefront/claude/jovial-chatelet-3447d7

      feat(content-builder): report what the validator repaired (#72) d23f45

    • feat(content-builder): report what the validator repaired (#72) 4b47e4

    • fix(content-builder): drop a corrupt build note instead of reporting a clean build (#72) e3994a

    • fix(gemini): redact api key from error, notice for retired models (#73) 922d51

    • fix(config): offer model names the providers still serve (#73) b1963c

    • Merge pull request #76 from breezefront/claude/lucid-kepler-a7ce73

      fix(content-builder): keep authoring placeholders out of generated copy (#71) 6a5b87

    • fix(content-builder): keep required sample copy on the malformed-value path (#71)

      fallbackValue() blanked any authoring placeholder, so a required copy prop the
      model sent as an array — quote.text, in the fixture — came out empty, which is
      the broken component the omitted-prop path deliberately avoids. The rule now
      lives in one predicate used by both paths.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8a6461

    • fix(content-builder): keep authoring placeholders out of generated copy (#71)

      The catalog's "default" attribute does two jobs: the value a renderer needs
      when a prop is unset, and the sample text the editor drops into a fresh
      component. The validator filled both, so "Discover Something New" shipped as
      the heading of three FAQ answers. Copy-typed props whose default reads as
      prose are now stored blank — blank, not absent, because the templates read
      `$props['heading'] ?? '<sample>'` — and the prompt marks them VISIBLE-TEXT
      instead of quoting the sample text that taught the model to echo it.

      A list-valued prop is blanked as an empty list, so an unusable faq_items no
      longer falls back to the two sample questions. Tabs keeps its declared titles:
      the container counts its slots from them.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 00e837

    • Merge pull request #75 from breezefront/claude/amazing-lichterman-a86181

      fix(content-builder): keep the list props the model wrote (#70) 819b38

    • fix(content-builder): keep every tab title paired with its slot (#70)

      Dropping an unusable title compacted the list while normalizeChildren()
      truncates slots from the end, so ["Shipping", "", "Returns"] over three
      populated slots rendered "Returns" above the middle slot's content and threw
      the last one away.

      A list of strings is positional — its container pairs slot N with entry N —
      so an unusable entry is now replaced where it stands, taking the declared
      default's title for that position ("Tab 2"), which is what the container's
      renderer pads a short title list with anyway. Entries that carry their own
      content, like the FAQ items, have no slot to stay paired with and are still
      dropped.

      An empty list now says so rather than reporting that none of its zero
      entries was usable. efb774

    • fix(content-builder): read a keyed object as one list entry (#70)

      decodeList() accepted any array, so a keyed object counted as a list: an FAQ
      item the model sent unwrapped — {"q": ..., "a": ...} instead of [{...}] —
      was iterated field by field, turning the question and the answer into two
      bogus items, each with the other's text as its question.

      Only a real list is a list now. A keyed object carrying at least one declared
      field is wrapped as the single entry it is, so the question the model wrote
      survives; one carrying none falls back to the default with a warning naming
      the fields that were expected. 71d179

    • fix(content-builder): keep the list props the model wrote (#70)

      The catalog asks the model for the Tabs titles and the FAQ items as JSON
      arrays, then ComponentValidator coerced every non-scalar prop back to its
      default — so a generated FAQ arrived with its questions replaced by "Tab 1"
      / "Tab 2" / "Tab 3", and the Tabs slot count fell back to the default three,
      dropping any further answer with it.

      A prop whose declared default is a JSON array is now validated as a list
      instead of discarded: entries are cleaned one by one and written back in the
      JSON-string shape the editor stores and the renderers decode, so the slot
      count follows the titles the model actually wrote. The default is only
      reached when nothing usable is left, and the warning then names the reason.

      Recognised by the shape of the declared default rather than by prop name, so
      the FAQ accordion's question/answer items are covered by the same path. a4765b

    • Merge pull request #69 from breezefront/claude/stoic-yonath-58797a

      fix(content-builder): drop fake "spacing" prop from catalog prompt 7db871

    • fix(content-builder): drop fake "spacing" prop from catalog prompt

      The margin/padding summary line started with the group label instead of
      prop names, so the model read "spacing" as a prop and emitted it on every
      component that had one. Render one line per value range, leading with the
      real names, which also stops claiming -200 as the minimum for paddings.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> beb335

    • chore: drop stray .b64 scratch files from screenshots dir

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> e5e228

    • docs: add screenshots to test guide, correct scenarios after live testing

      Vision-model dropdown pre-filters server-side (10.6 unreachable via UI as
      written), Remove+re-attach confirmed clean (10.9). Found and filed a real
      draft-persistence bug in module-breeze-content-builder (#31) — re-run builds
      report success but don't survive a fresh page reload.

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8062ec

    • docs: add test scenarios and how-to-test guide for Content Builder screenshot import

      Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> 8e6e8d

    • Merge pull request #67 from breezefront/feat/value-backup

      feat(backup): record values before AI writes 70f7cc

    • fix(backup): skip ambiguous staging rows 27e298

    • fix(backup): guard capture reads, batch pruning 19f666

    • feat(backup): record values before AI writes 21d8f6

    • Merge pull request #66 from breezefront/test/bulk-selection-regression

      test(bulk): pin the scope a run resolves to b69352

    • test(bulk): seed the products the scope cases run against

      Copilot was right on both counts: the cases borrowed whatever the
      catalogue happened to hold, and the exclusion case skipped itself whenever
      no two SKUs shared a five-character prefix. A regression check that goes
      quiet when the surrounding data does not suit it is not a guard.

      Three products with a prefix unique to the run are created in setUp and
      removed in tearDown, so every case is deterministic and none can skip. The
      prefix also gets its own case, since the two filtered cases rest on it
      selecting this test's products and nothing else.

      Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour now turns five
      of the six red, up from four. The unfiltered select-all stays green, which
      is correct - that path is the legitimate one and behaved the same before.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> e82c74

    • test(bulk): pin the scope a run resolves to

      The selection fault has landed three times. 15c1e7c introduced an
      all_selected flag driven by allSelected(), #31 closed it in June, and
      c13907e swapped in excludeMode() and brought it straight back - Magento
      raises either flag by itself once the checked rows cover the rows the grid
      is showing. Both attempts only ever argued about the client-side flag,
      while the resolver went on loading the collection with no filters at all.

      The E2E spec covers the request the wizard posts. Nothing covered what
      that request then resolves to, which is the number that decides how many
      products get rewritten. These cases do, against the live catalogue: an
      explicit selection, a genuine select-all, a select-all narrowed by a
      filter, exclusions inside a filtered set, and an empty selection.

      Reverting BulkEntityIdResolver to the pre-33d5e97 behaviour turns four of
      the five red, the exception being the unfiltered select-all - which is
      correct, since that case is the legitimate path and behaved the same
      before.

      Refs #61

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 8e8b71

    • Version 1.2.0 634a46

    • Merge pull request #63 from breezefront/feat/build-page-from-screenshot

      Build a Content Builder page from a screenshot 680ede

    • fix(vision): close the second round of review points on #63

      The one that mattered: `options['brief']` skipped the fix from the previous
      commit entirely. It leaves the history untouched by design, so an
      alternating conversation that ended on a user turn got our prompt appended
      after it — the exact pair Anthropic answers 400 to, reachable from REST
      where the caller supplies both a brief and a history. The prompt is now
      folded into a trailing user turn instead of following it, which keeps that
      turn's words rather than dropping them. The class docblock claimed "a
      history that already alternates keeps alternating"; it does now.

      `testSendsTheHistoryFollowedByTheGeneratedPrompt` was pinning the broken
      shape — two user turns, asserted as correct — so its history now ends on
      an assistant turn and the folding case has tests of its own.

      The block hid itself for every reason a build could fail except the two
      the controller actually enforces. A role holding `Magento_Cms::page` but
      not `Swissup_BreezeAi::index` or `Magento_Cms::save` saw a working-looking
      button whose every click came back as the login page, which the modal can
      only report as an expired session.

      Also from the review: the modal kept the previous brief when reopened, so
      it would have ridden the next screenshot unseen; an in-flight FileReader
      could re-arm a screenshot the admin had already removed, now invalidated
      by a token that `clearScreenshot()` moves on; the drop zone gained
      `role="button"` and the brief and model controls gained real labels, since
      a placeholder is not one and the select had no accessible name at all.

      A warning-free build no longer redirects on its own. The draft is saved by
      then but the CMS form behind the modal is not — this request never touched
      it — so a title or URL key typed before opening the modal was silently
      lost. The editor is offered as a link in both outcomes.

      635 unit, 18 integration, 30 e2e green.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3072d9

    • fix(vision): answer the three points Copilot raised on #63

      The docs described a cap the code stopped using. `BuildFromScreenshot`
      reads `post_max_size`, and its own comment explains why `getMaxFileSize()`
      is the wrong ceiling for a base64 string in a text field — the doc still
      named `getMaxFileSize()`, which is exactly the sort of mismatch someone
      debugging a 413 would be misled by.

      The drop zone carries `tabindex="0"` but only answered to a click, so it
      could be tabbed to and not opened. Enter and Space now trigger the picker,
      with the same guard the click handler needs: the remove control sits
      inside the zone and answers to both keys, and swallowing them there would
      leave it dead to the keyboard.

      `getVisionModels()` is asked for four times while one CMS page form
      renders — `canShow`, `getConfigJson`, and `getSelectedModelId` from inside
      it — and each ask loads every model to read its metadata. Memoized for the
      life of the block, which is one request.

      631 tests green; 29 e2e, the new keyboard case among them.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2e6454

    • test(e2e): assert the selection contract the wizard actually posts

      33d5e97 replaced the all_selected flag with Magento's own mass-action
      contract — one of selected or excluded, plus the grid namespace and the
      filters that were on screen — and updated SaveTest, but the Playwright spec
      kept asserting the flag that no longer exists. Red on master since then.

      The assertion now names what the fix was for: the filters travel with the
      selection, which is what stops an exclude-mode Select All from widening past
      the rows the grid was showing. f34845

    • feat(vision): build a Content Builder page from a screenshot

      PR #55 landed the whole image path — MessageInterface::getImages(),
      ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
      nothing used it. Every caller of build_page passed an empty history, and no
      UI produced an images list. This gives the admin a way in.

      A button in the Content Builder fieldset of the CMS page form opens a modal:
      attach a screenshot, optionally type a brief, pick a vision-capable model.
      The file is read with FileReader and posted as a data URI inside
      history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
      runs the skill, saves the result through DraftWriter, and answers with the URL
      of the editor the admin reviews it in.

      Synchronous rather than an option on the bulk wizard: a reference screenshot
      is a per-page input, and one screenshot over N pages produces N near-identical
      layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
      all three providers are used exactly as PR #55 built them, unchanged.

      BuildPage now lifts images off the user turns they arrived on and puts them on
      the prompt turn it builds, consuming the last user turn as the brief. That
      fixes an existing fault: Model\Provider\Claude passes the message list through
      unmerged and Anthropic refuses two user turns in a row, so the old
      array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
      fallback was the thing being used.

      PromptBuilder gains a conditional Reference design block. Two of its five
      rules carry the weight: without the band-per-row line the model answers with a
      flat component list and the row-wrapping rule loses to the picture, and
      without the ignore-brand-and-colours line it copies the screenshot's hex
      values into every colour prop and the draft clashes with the merchant's theme.

      Verified end to end against gpt-4o-mini on a real storefront screenshot:
      six row-wrapped bands, no validator repairs, draft readable in the editor.
      With a brief added, the structure holds and the copy follows the brief rather
      than transcribing the screenshot.

      540 -> 621 unit tests, all green. 9d8854

    • test(mcp): list build_page among the tools the server exposes

      McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
      with four tools. build_page joined the skill pool in 1bf01f6, and every skill
      in the pool is exposed as a tool, so the integration suite has been red since
      then. Nothing about the server changed — only what the test expects. f8588c

    • test(e2e): cover the build-from-screenshot modal

      The one surface unit tests cannot reach. It exists because of a bug that got
      all the way to review: the file input is a child of the drop zone, so the
      synthetic click jQuery sends it bubbles back into the zone's own handler and
      recurses until the stack blows — the picker never opens and only drag and drop
      works. "clicking the drop zone opens the file picker" is the guard for that.

      Also covers the pre-flight refusal of a non-image, that the refusal sends no
      request, the preview and remove cycle, and that a reopened modal carries none
      of the previous run's state.

      Stops short of pressing Build draft: that would spend a real provider call.
      Skips itself where the control legitimately hides — no Content Builder, or no
      configured model that reads images. 10934b

    • test(mcp): list build_page among the tools the server exposes

      McpTest asserts a hardcoded tool list written when the MCP endpoint shipped
      with four tools. build_page joined the skill pool in 1bf01f6, and every skill
      in the pool is exposed as a tool, so the integration suite has been red since
      then. Nothing about the server changed — only what the test expects. 60d8c7

    • feat(vision): build a Content Builder page from a screenshot

      PR #55 landed the whole image path — MessageInterface::getImages(),
      ImageBuilder, ImageValidator, VisionSupport, per-provider encoding — and
      nothing used it. Every caller of build_page passed an empty history, and no
      UI produced an images list. This gives the admin a way in.

      A button in the Content Builder fieldset of the CMS page form opens a modal:
      attach a screenshot, optionally type a brief, pick a vision-capable model.
      The file is read with FileReader and posted as a data URI inside
      history[0].images — nothing is written to disk. Controller/Adminhtml/Page/Build
      runs the skill, saves the result through DraftWriter, and answers with the URL
      of the editor the admin reviews it in.

      Synchronous rather than an option on the bulk wizard: a reference screenshot
      is a per-page input, and one screenshot over N pages produces N near-identical
      layouts. GenerateService, ImageBuilder, ImageValidator, AiRequest, Message and
      all three providers are used exactly as PR #55 built them, unchanged.

      BuildPage now lifts images off the user turns they arrived on and puts them on
      the prompt turn it builds, consuming the last user turn as the brief. That
      fixes an existing fault: Model\Provider\Claude passes the message list through
      unmerged and Anthropic refuses two user turns in a row, so the old
      array_merge($history, [prompt]) answered 400 whenever the lastUserMessage()
      fallback was the thing being used.

      PromptBuilder gains a conditional Reference design block. Two of its five
      rules carry the weight: without the band-per-row line the model answers with a
      flat component list and the row-wrapping rule loses to the picture, and
      without the ignore-brand-and-colours line it copies the screenshot's hex
      values into every colour prop and the draft clashes with the merchant's theme.

      Verified end to end against gpt-4o-mini on a real storefront screenshot:
      six row-wrapped bands, no validator repairs, draft readable in the editor.
      With a brief added, the structure holds and the copy follows the brief rather
      than transcribing the screenshot.

      540 -> 621 unit tests, all green. c53c71

    • Version 1.1.2

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3bc1cd

    • Merge pull request #60 from breezefront/fix/bulk-selection-scope

      fix(bulk): scope a run to what the grid was actually showing 49b5f5

    • fix(bulk): scope a run to what the grid was actually showing

      A client selected one product and the wizard queued a run over all 714.
      The submit path decided "everything is selected" from the grid
      excludeMode flag, and core turns that flag on by itself as soon as the
      checked rows cover the rows the grid currently holds - filtering down to
      a single product and ticking it is indistinguishable from pressing Select
      All. The server then made it worse: the all_selected branch loaded the
      entity collection with no filters at all, so even a deliberate select-all
      meant the whole catalogue rather than the filtered set. Either fault
      alone produces the wrong count.

      Both go away by speaking Magento's own mass-action contract. The wizard
      now posts selected or excluded alongside the grid namespace, filters and
      search, exactly as Magento_Ui/js/grid/massactions does, and
      BulkEntityIdResolver runs the collection through MassAction\Filter. An
      exclude-mode selection resolves against the filters that were on screen,
      so the accidental flag can no longer widen the scope.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 33d5e9

    • Version 1.1.1

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f29385

    • Merge pull request #59 from breezefront/fix/model-form-provider-options

      fix(admin): serve the model form's options from the option sources 72aff2

    • fix(admin): serve the model form's options from the option sources

      The model select is repopulated in the browser whenever the provider
      changes, and the map it read from was a hand-maintained copy inside
      swissup_breezeai_model_form.xml. Adding GPT-5.6 updated OpenAiModel and
      the switcher rules but not that copy, so Sol, Terra and Luna could not be
      picked at all - and the run wizard's gpt-5.6-terra default could never
      match a saved row.

      ProviderModels builds the map from the same option sources the form
      already declares, injected through FormDataProvider::getMeta(). One list
      per provider, and registering a provider in di.xml is enough for its
      models to appear.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 936696

    • Merge pull request #58 from breezefront/fix/temperature-zero

      Stop dropping a temperature of 0 d79542

    • fix(providers): stop dropping a temperature of 0

      Claude and Gemini read the value with an assignment inside the condition:

      if ($temperature = $config->getExtra('temperature')) {

      which is a truthiness test, and `(bool) 0` is false. An admin setting 0 — the
      value you pick precisely to make output deterministic — had it silently discarded
      and got the provider's own default instead, 1.0 on Claude. No error, no warning,
      just different output than asked for.

      Compared against null and '' now, so 0 reaches the request and an unset field
      still sends nothing. OpenAI was already correct via `?? DEFAULT_TEMPERATURE`.

      Found by exercising the admin form rather than by reading: the field only became
      settable when the extra_config registry landed, and both existing provider tests
      covered temperature at 0.5 and 0.7, never at 0. Tests now cover 0 and unset for
      both providers, and fail on the old code.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> da9be6

    • Merge pull request #57 from breezefront/refactor/extra-config-registry

      One registry for the extra_config fields 40cddc

    • Merge pull request #55 from breezefront/feat/vision-image-input

      Vision foundation: let a message carry images, encode them per provider fd09a0

    • refactor(config): one registry for the extra_config fields

      Which fields live in the extra_config JSON was written down three times: in the
      save controller, in the form data provider, and in the form XML. Nothing failed
      when they disagreed — a field missing from the save list is silently not stored,
      one missing from the read list comes back blank on every edit. Adding `vision`
      meant three coordinated edits with no test that would catch a missed one.

      `temperature` is what that cost. All three providers read it; neither list
      carried it and the form had no field, so it could not be set from the admin at
      all. It has a field now, and the notice says what the ranges are and that GPT-5
      and the o-series ignore it.

      Model/ModelConfig/ExtraFields owns the keys and how each normalises. Types
      matter here: a blank submission clears the key, an int at zero clears it — no
      token cap means anything at zero — but a float at zero is kept, because zero is
      a meaningful temperature and dropping it would silently restore the provider
      default for someone asking for deterministic output.

      The form XML still declares its own fields: a field needs a label, a notice and a
      control that no list can supply. What moved is which keys travel in and out.

      Needs setup:di:compile — both consumers gained a constructor argument.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1dc4b

    • refactor(vision): cut ImageBuilder to the shape callers send

      Five accepted spellings of the same input for one call site: fromBinary had no
      caller at all, and fromArray took `data_uri` beside `url`, `mimeType` beside
      `mime_type`, and a data URI hidden in `data`. Each was a guess about a caller
      that does not exist — no skill attaches an image yet, and no JS sends one.

      What is left is what GenerateService passes: `{mime_type, data}`, or a data URI
      under `url` — the key OpenAI's own image_url part uses, so it is the one a caller
      already has a name for. fromDataUri and the base64 check became private; the
      public surface is fromArray, which is the only method anything calls.

      94 lines to 90, and 4 public methods to 1. Adding a shape back is cheap once a
      caller wants it; carrying four unused ones is not.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 66ac12

    • fix(vision): address the review findings on this PR

      VisionSupport keeps only the `vision` config key and reads ModelMetadata for
      what the name means, so its two prefix tables and its copy of matchesPrefix are
      gone — 116 lines to 70. The o-series can no longer be listed here as text-only
      while being listed there as reasoning.

      ImageValidator collects every image, checks the count, and only then decodes.
      The cap was the last rule tested, so an over-cap request paid for N base64
      decodes to be refused, and was refused for whichever image happened to be
      malformed first rather than for being over cap.

      ImageBuilder accepts and discards parameters between the mime type and
      `;base64`. A `charset` an exporter felt like adding says nothing about image
      bytes, and refusing the URI over it refused a readable image.

      GenerateService::toMessage() no longer claims Web API hands history over as
      arrays. It does not: /V1/breezeai/generate declares MessageInterface[] and
      ServiceInputProcessor dies with "Cannot instantiate interface" before the service
      runs, because the interface has no DI preference. The caller that really passes
      arrays is the admin controller, which decodes the body itself. History over REST
      predates image input and is not fixed here; docs/DEVELOPMENT.md says so instead
      of implying the route works.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> dd6371

    • feat(providers): let a message carry images, and encode them per provider

      A message gains getImages(): ImageInterface[] alongside its string content,
      and each provider encodes those images into its own wire format — an
      Anthropic base64 image block, an OpenAI image_url part holding a data URI,
      a Gemini inline_data part. Content stays a string, so a text-only turn
      produces the same request body it did before and every existing caller of
      `new Message($role, $content)` keeps working.

      Two things guard the send. ImageValidator enforces the limits the three
      providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
      request, user turns only — and VisionSupport answers whether the model a
      call is routed to reads images at all, from the model name plus a `vision`
      override on the model row (the new Image Input field). AiRequest runs both
      before reaching the provider, so an image sent to a text-only model fails
      with one sentence naming the model rather than a provider 400. Callers that
      want to know in advance ask AiRequest::supportsVision().

      ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
      a decoded request payload — into an ImageInterface, and GenerateService
      uses it so history posted over the wire can carry images too.

      No skill attaches an image yet; this is the layer under that.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ad9747

    • fix(data): document every Message constructor parameter

      Magento reads @param tags positionally — TypeProcessor::getParamDocBlockTag()
      indexes the tag list with the parameter's own offset — so a block documenting
      only $images typed $role from the images tag and had no tag to return for
      position 2. Reflecting the constructor to build a Message from request data
      died there with a TypeError before any of our own validation ran.

      The two missing tags are the whole fix. A test pins the invariant, since the
      next parameter added to this constructor would silently reintroduce it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> b793a0

    • Merge pull request #56 from breezefront/refactor/model-metadata

      One table for what a model name means eb79b5

    • fix(model): correct the model facts the merge exposed

      Putting both axes in one table made three disagreements visible:

      o1 reads images. It was absent from the image-input list while present in the
      parameter-set one, which is the drift this table exists to stop. o1-mini and
      o1-preview genuinely do not, so they say so as longer prefixes.

      gpt-4-vision-preview — where vision on GPT-4 started — matched nothing and read
      as text-only, because the list carried gpt-4-turbo and gpt-4o but not the model
      named for the capability.

      claude-4 was dead weight: Claude 4 and later put the tier first, as in
      claude-opus-4-5, and those already match claude-opus.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f113ee

    • refactor(model): read model facts from one table

      RequestBody carried three prefix lists and VisionSupport (in #55) carried two
      more, each with its own copy of matchesPrefix. Nothing tied them together, so
      they drifted: the parameter-set list knew about o1 and the image-input list did
      not, and no test could catch it because neither list knew the other existed.

      ModelMetadata holds the facts once, keyed by provider and name prefix. Every
      prefix a name starts with contributes, shortest first, so a longer prefix
      overrides a shorter one and states only what differs — o3-mini says it takes no
      image and inherits its parameter set from o3. That ordering is what removes the
      separate lists of exceptions each consumer used to keep beside its main list.

      A per-row sampling or vision value stays with the consumer that reads it: the
      row overriding the name is config, not a fact about the model.

      Behaviour is unchanged — every existing RequestBody test passes untouched, and
      the table reproduces the old lists name for name, o1's missing image support
      included. Correcting that is the next commit, kept separate so this one reads
      as the move it is.

      supportsVision() has no caller on master yet; #55 is the caller and rebases
      onto this.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 891642

    • feat(providers): let a message carry images, and encode them per provider

      A message gains getImages(): ImageInterface[] alongside its string content,
      and each provider encodes those images into its own wire format — an
      Anthropic base64 image block, an OpenAI image_url part holding a data URI,
      a Gemini inline_data part. Content stays a string, so a text-only turn
      produces the same request body it did before and every existing caller of
      `new Message($role, $content)` keeps working.

      Two things guard the send. ImageValidator enforces the limits the three
      providers share — JPEG/PNG/GIF/WebP, 5 MB decoded per image, 10 per
      request, user turns only — and VisionSupport answers whether the model a
      call is routed to reads images at all, from the model name plus a `vision`
      override on the model row (the new Image Input field). AiRequest runs both
      before reaching the provider, so an image sent to a text-only model fails
      with one sentence naming the model rather than a provider 400. Callers that
      want to know in advance ask AiRequest::supportsVision().

      ImageBuilder turns the shapes an image arrives in — raw bytes, a data URI,
      a decoded request payload — into an ImageInterface, and GenerateService
      uses it so history posted over the wire can carry images too.

      No skill attaches an image yet; this is the layer under that.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 659e74

    • Version 1.1.0 9aeb4e

    • Merge pull request #54 from breezefront/feat/bulk-run-model-and-system-prompt

      feat(bulk): pick the model and the framing per run, and pin them to it cab8ed

    • fix(bulk): keep the closing script tag out of a JS comment

      The comment warning about a prompt body holding a closing script tag
      contained one, which ended the block early and dumped the rest of the
      script onto the page as text.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d1f118

    • feat(bulk): pick the model and the framing per run, and pin them to it

      The bulk wizard ran on whatever the configuration said at the moment each
      operation was picked up, with no way to steer the model or tell it what
      kind of writing was wanted. The client rated the German output 4/10 and
      asked for both: a model per run, and a free-text instruction that reaches
      the model as framing rather than as part of the product text.

      Two things had to be true for that to mean anything.

      The framing has to arrive in the system channel, ahead of the content.
      It does: the skills put it in a system message, and the providers already
      agree that a caller's system message wins over their built-in default.
      Where a run brief is present the translate prompt drops its own "you are
      a professional e-commerce translator" opening — that line answers the one
      question the brief exists to answer — while keeping the mechanical rules
      about numbers, SKUs and HTML, which are output format rather than
      persona.

      And both have to hold still. A batch of a few thousand products drains
      over hours, so resolving configuration per operation means editing the
      default model halfway through silently splits the batch across two
      models with nothing in the result to say which got which. The model and
      the prompt are now decided when the run is scheduled and travel in the
      operation payload, which is already immutable once written — no queue
      schema change. Even leaving the dropdown alone pins a concrete row:
      "the default model" is a moving target.

      The model ID is a pointer and the row behind it stays editable, so the
      provider and model name ride along as a snapshot and the consumer stops
      rather than bill a model nobody chose. The API key is deliberately not
      snapshotted — rotating one has to reach the runs already queued.

      For Generate this reverses an existing precedence: a model passed in now
      beats the one bound to the prompt. Step 5 tells the operator which model
      the run will use, and a per-prompt model overriding that would make the
      screen lie. Nothing outside the wizard passes one, so the prompt's own
      model still decides everywhere else.

      Two small stores back the UI. Prompts gain a type, so a run brief can be
      saved in the existing library without turning up in the per-field
      dropdowns; rows written before the column are field prompts, which is all
      the module could store then. And one row per admin remembers what the
      wizard was last run with, so it opens on that instead of an empty form —
      a preference, not a reference, which is why it carries no foreign key to
      the model table: a model since deleted is simply not offered.

      Operations queued before any of this carry none of the new keys and keep
      running on the default model.

      setup:upgrade applies the schema; Claude, Gemini and the bulk consumer
      take new constructor arguments, so setup:di:compile is needed as well.

      ./bin/test unit — OK (454 tests, 901 assertions)
      phpcs --standard=Magento2 — 0 errors on every touched file
      Schema verified against a real database, not only declared.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> fcb9d8

    • fix(providers): agree on what a caller's system message means (#53)

      * fix(providers): agree on what a caller's system message means

      The three providers disagreed. OpenAI lets a caller-supplied system message
      replace the built-in framing; Claude and Gemini filtered it out of the
      conversation and then sent their own constant anyway, so the instruction was
      read, dropped, and never reached the model. The same prompt therefore meant
      different things depending on which model it happened to be assigned to — and
      silently, because nothing errors when a system message is discarded.

      Nothing sends one today, so this fixes no live failure. It removes a trap: the
      first skill that reaches for its own framing would have got it on OpenAI and
      lost it on the other two, and the symptom is bad output rather than an error.

      `Model\Provider\SystemMessage` now resolves the framing for all three — the
      caller's, or the built-in default when the caller supplied none. Where it goes
      still differs per API: inline in `messages` for OpenAI, the top-level `system`
      field for Claude, `system_instruction` for Gemini. More than one system message
      is joined in order rather than reduced to the first, since Claude and Gemini
      have a single field for it and dropping the rest would lose instructions.

      The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
      points at the shared constant so it keeps working for anything referencing it.

      Claude and Gemini take a new constructor argument, so this needs
      `setup:di:compile`.

      ./bin/test unit — OK (409 tests, 810 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      * fix(openai): resolve the framing through the shared rule, not a copy of it

      The previous commit gave Claude and Gemini a shared SystemMessage but left
      OpenAI deciding for itself in buildMessages(). Only the default string was
      shared, so the two paths could still drift: several system messages were
      joined on Claude and Gemini and left scattered through the conversation on
      OpenAI, and any later change to resolve() would have reached two providers
      out of three.

      OpenAI now calls resolve() as well. The framing still travels as the first
      entry of `messages` because that is what the endpoint takes; the caller's
      system messages are lifted out of the conversation and folded into it, so
      the instruction is sent once rather than twice.

      resolve() also stops treating a blank system message as one supplied. An
      empty string is not framing, and passing it through meant Gemini receiving
      an empty `system_instruction` part — which it refuses — where before the
      constant always went out.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      ---------

      Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 2c716b

    • fix(openai): resolve the framing through the shared rule, not a copy of it

      The previous commit gave Claude and Gemini a shared SystemMessage but left
      OpenAI deciding for itself in buildMessages(). Only the default string was
      shared, so the two paths could still drift: several system messages were
      joined on Claude and Gemini and left scattered through the conversation on
      OpenAI, and any later change to resolve() would have reached two providers
      out of three.

      OpenAI now calls resolve() as well. The framing still travels as the first
      entry of `messages` because that is what the endpoint takes; the caller's
      system messages are lifted out of the conversation and folded into it, so
      the instruction is sent once rather than twice.

      resolve() also stops treating a blank system message as one supplied. An
      empty string is not framing, and passing it through meant Gemini receiving
      an empty `system_instruction` part — which it refuses — where before the
      constant always went out.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7ddc5f

    • fix(providers): agree on what a caller's system message means

      The three providers disagreed. OpenAI lets a caller-supplied system message
      replace the built-in framing; Claude and Gemini filtered it out of the
      conversation and then sent their own constant anyway, so the instruction was
      read, dropped, and never reached the model. The same prompt therefore meant
      different things depending on which model it happened to be assigned to — and
      silently, because nothing errors when a system message is discarded.

      Nothing sends one today, so this fixes no live failure. It removes a trap: the
      first skill that reaches for its own framing would have got it on OpenAI and
      lost it on the other two, and the symptom is bad output rather than an error.

      `Model\Provider\SystemMessage` now resolves the framing for all three — the
      caller's, or the built-in default when the caller supplied none. Where it goes
      still differs per API: inline in `messages` for OpenAI, the top-level `system`
      field for Claude, `system_instruction` for Gemini. More than one system message
      is joined in order rather than reduced to the first, since Claude and Gemini
      have a single field for it and dropping the rest would lose instructions.

      The default string lived in three copies; `RequestBody::SYSTEM_MESSAGE` now
      points at the shared constant so it keeps working for anything referencing it.

      Claude and Gemini take a new constructor argument, so this needs
      `setup:di:compile`.

      ./bin/test unit — OK (409 tests, 810 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 435eb3

    • fix(openai): send the parameter set the model family accepts (#52)

      * fix(openai): send the parameter set the model family accepts

      Every request carried temperature, frequency_penalty and presence_penalty,
      and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
      family and the o-series refuse all four on the same endpoint: the two
      penalties are rejected outright even at 0, temperature takes nothing but
      its default, and the token cap was renamed to max_completion_tokens. A
      request built the old way comes back 400, so those models did not degrade
      on this provider — they failed every call.

      Nobody hit it because the model dropdown stopped at gpt-4o-mini and
      reaching a newer one meant typing it into the custom field. The client on
      the translation project asked for gpt-5.6-terra, which is where this
      starts to matter.

      The body is now built by a small class of its own. The provider builds its
      HTTP client inline and cannot be exercised without a network, which left
      the part that has to be right per model family with no test at all; that
      part is now pure and covered.

      Family is decided by the model name, with a sampling extra-config key to
      override it: an Azure deployment or a proxy can serve GPT-5 under a name
      that says nothing, and prefix matching would send it what it refuses.
      reasoning_effort and verbosity are passed through where they are
      understood, so the effort knob is reachable without another release.

      A caller-supplied system message now replaces the built-in one instead of
      being appended after it — two system messages in one request left the
      model to pick. No caller sends one yet.

      The three GPT-5.6 tiers are in the model dropdown, so the client's model
      is a selection rather than a hand-typed string.

      phpcs --standard=Magento2 reports 0 errors on the touched files.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      * fix(openai): gate the reasoning knobs per family and expose them in admin

      The first pass treated GPT-5 and the o-series as one family. They are not:
      `verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
      rejects `reasoning_effort` as well. Sending either to a model that refuses it
      reproduces the 400 this branch set out to remove, so the knobs are now offered
      per family while the token-cap rename and the dropped sampling parameters stay
      common to both.

      The knobs themselves had no way in. `sampling`, `reasoning_effort` and
      `verbosity` were readable only by hand-editing the extra_config JSON, which
      made the escape hatch for unnamed Azure and proxy deployments unreachable in
      practice. All three are now fields on the model form, saved into and read back
      out of extra_config alongside max_tokens.

      Also:

      - honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
      matching what the Claude and Gemini providers already do
      - read `sampling` without casting, so a non-string value in the JSON cannot
      raise an array-to-string conversion
      - note in the class docblock that an unrecognised model name falls back to the
      GPT-4 parameter set, so the next family will need its prefix added here

      ./bin/test unit — OK (401 tests, 802 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

      ---------

      Co-authored-by: Claude Opus 5 <noreply@anthropic.com> 37217f

    • fix(openai): gate the reasoning knobs per family and expose them in admin

      The first pass treated GPT-5 and the o-series as one family. They are not:
      `verbosity` shipped with GPT-5 and the o-series rejects it, and `o1-mini`
      rejects `reasoning_effort` as well. Sending either to a model that refuses it
      reproduces the 400 this branch set out to remove, so the knobs are now offered
      per family while the token-cap rename and the dropped sampling parameters stay
      common to both.

      The knobs themselves had no way in. `sampling`, `reasoning_effort` and
      `verbosity` were readable only by hand-editing the extra_config JSON, which
      made the escape hatch for unnamed Azure and proxy deployments unreachable in
      practice. All three are now fields on the model form, saved into and read back
      out of extra_config alongside max_tokens.

      Also:

      - honour a configured `temperature` on the GPT-4 path instead of pinning 1.0,
      matching what the Claude and Gemini providers already do
      - read `sampling` without casting, so a non-string value in the JSON cannot
      raise an array-to-string conversion
      - note in the class docblock that an unrecognised model name falls back to the
      GPT-4 parameter set, so the next family will need its prefix added here

      ./bin/test unit — OK (401 tests, 802 assertions)
      phpcs --standard=Magento2 — 0 errors on all touched files

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7d1b03

    • fix(openai): send the parameter set the model family accepts

      Every request carried temperature, frequency_penalty and presence_penalty,
      and max_tokens when configured. That is the GPT-4 parameter set. The GPT-5
      family and the o-series refuse all four on the same endpoint: the two
      penalties are rejected outright even at 0, temperature takes nothing but
      its default, and the token cap was renamed to max_completion_tokens. A
      request built the old way comes back 400, so those models did not degrade
      on this provider — they failed every call.

      Nobody hit it because the model dropdown stopped at gpt-4o-mini and
      reaching a newer one meant typing it into the custom field. The client on
      the translation project asked for gpt-5.6-terra, which is where this
      starts to matter.

      The body is now built by a small class of its own. The provider builds its
      HTTP client inline and cannot be exercised without a network, which left
      the part that has to be right per model family with no test at all; that
      part is now pure and covered.

      Family is decided by the model name, with a sampling extra-config key to
      override it: an Azure deployment or a proxy can serve GPT-5 under a name
      that says nothing, and prefix matching would send it what it refuses.
      reasoning_effort and verbosity are passed through where they are
      understood, so the effort knob is reachable without another release.

      A caller-supplied system message now replaces the built-in one instead of
      being appended after it — two system messages in one request left the
      model to pick. No caller sends one yet.

      The three GPT-5.6 tiers are in the model dropdown, so the client's model
      is a selection rather than a hand-typed string.

      phpcs --standard=Magento2 reports 0 errors on the touched files.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3b7370

    • Merge pull request #51 from breezefront/fix/category-translate-source-language

      fix(translate): name the source language when translating categories db336b

    • docs(translate): tighten the source-store comment

      The docblock read like a changelog entry for 1.0.11. Keep the reason the
      source store is handed to the skill, drop the history around it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> f63c1b

    • fix(translate): name the source language when translating categories

      1.0.11 changed the translate prompt to name both languages ("translate
      from Danish to German") because a weak model, told only the target, left
      a good part of the text in the source language. The bulk consumer was
      only half-converted: translateProducts() passes source_store_id, but
      translateCategories() still calls the skill with the attribute and the
      target store alone.

      The source store was already used to load the category, so the values fed
      to the model were right — only the prompt was missing the "from" half.
      Category translations therefore kept running on the pre-1.0.11 prompt and
      came back partly untranslated, the exact symptom 1.0.11 was released to
      cure. Nothing surfaced it: the skill treats source_store_id as optional
      and silently omits the clause when it is absent.

      The unit test now asserts the whole options array rather than accepting
      any call, so the next field added to it cannot go missing the same way.

      phpcs --standard=Magento2 reports 0 errors on the touched files, the same
      as before.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6871f1

    • Merge pull request #50 from breezefront/fix/bulk-empty-field-panels-v2

      fix(bulk): do not offer a task whose field panel has nothing in it acd541

    • fix(bulk): do not offer a task whose field panel has nothing in it

      The wizard decides what step 2 may offer by asking whether a step 4 panel
      exists for the pair. A panel could exist and still be empty: Generate
      renders a row per attribute only when a prompt is bound to it, so with no
      prompts configured the panel is a bare fieldset. The check said yes, the
      operator picked Generate, and step 4 came up blank with a Run button that
      never enabled — the dead end the panel check was meant to close, reached
      from inside the panel instead of around it.

      Prompts are now resolved once up front and an attribute without any is
      dropped, which makes "the panel has fields" knowable before rendering.
      A panel with no fields is not rendered, so the existing check reports the
      truth for it. Translate gets the same treatment for symmetry, though its
      field list is only empty on entity types nobody can select today.

      That leaves step 2 able to come up with nothing at all in it, so it now
      says why instead of showing an empty box.

      phpcs --standard=Magento2 reports 0 errors on the touched file, the same
      as before.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 392330

    • Merge pull request #48 from breezefront/fix/bulk-save-skill-entity-validation

      fix(bulk): refuse a skill the queue cannot run on the chosen entity type 24f2e1

    • Merge pull request #49 from breezefront/fix/bulk-empty-field-panels

      fix(bulk): do not offer a task whose field panel has nothing in it ecfa84

    • Merge pull request #46 from breezefront/fix/bulk-wizard-skill-filter

      fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word 87738f

    • fix(bulk): refuse a skill the queue cannot run on the chosen entity type

      BulkConsumer::execute() routes on (skill, entity type) pairs and throws on
      anything it has no handler for, but the scheduling controller accepted
      whatever was posted. A request for a pair the consumer cannot route —
      generate on cms_page, say, which the wizard hides but the form will still
      carry — was answered with "has been scheduled", split into operations, and
      failed one by one where only the bulk log would show it.

      Save now checks the pair before it schedules anything, so the operator is
      told no while still looking at the screen. The check runs before the entity
      ids are resolved, which also spares a full-catalog id query for a request
      that was never going to run.

      QueueRoutes holds the pairs rather than inlining them, because the
      consumer's routing table is the real authority and the two must not drift:
      QueueRoutesTest reads the consumer's match arms and asserts they are
      exactly the declared list.

      It is deliberately not SkillSupport. That asks whether a skill can act on
      an entity type at all and answers from getSupportedEntityTypes(); Generate
      claims cms_page for the inline editor and is right to, but there is no
      generate/cms_page route in the consumer. What a skill can do and what the
      queue can carry out are separate facts, and only the second one decides
      what may be scheduled.

      370 unit tests pass. phpcs --standard=Magento2 reports 0 errors on the
      touched files, and Save.php carries the same 10 warnings as before.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 600476

    • Merge pull request #47 from breezefront/fix/cms-page-translate-scope

      fix(translate): stop writing CMS page translations into a category 219f60

    • fix(bulk): do not offer a task whose field panel has nothing in it

      The wizard decides what step 2 may offer by asking whether a step 4 panel
      exists for the pair. A panel could exist and still be empty: Generate
      renders a row per attribute only when a prompt is bound to it, so with no
      prompts configured the panel is a bare fieldset. The check said yes, the
      operator picked Generate, and step 4 came up blank with a Run button that
      never enabled — the dead end the panel check was meant to close, reached
      from inside the panel instead of around it.

      Prompts are now resolved once up front and an attribute without any is
      dropped, which makes "the panel has fields" knowable before rendering.
      A panel with no fields is not rendered, so the existing check reports the
      truth for it. Translate gets the same treatment for symmetry, though its
      field list is only empty on entity types nobody can select today.

      That leaves step 2 able to come up with nothing at all in it, so it now
      says why instead of showing an empty box.

      phpcs --standard=Magento2 reports 0 errors on the touched file, the same
      as before.
      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 1fe13b

    • fix(translate): refuse an unsupported entity type in the skill itself

      Narrowing getSupportedEntityTypes() to product and category told the truth
      but gated nothing. Three paths still reached a CMS page:

      - McpServer::executeSkill() passes entityType straight to GenerateService;
      the schema enum is a hint to the client, not a check. translate +
      cms_page + targetStore>0 went through Translate::execute(), which took
      the attribute list from the input field pool — and the pool still knows
      the cms_page fields — translated the text, saved nothing, and reported
      success. A silent no-op instead of an error.
      - The CMS page form kept its "AI Translate" buttons. Block\Adminhtml\Js
      hands translate.js every input field of the entity type, and the form
      has no store switcher, so currentStore is always 0: every click ran
      translateForAllStores() and died on the new save guard.
      - That death cost one AI request. translateForAllStores() calls the skill
      first and saves after, so the provider was paid before anything refused
      the entity.

      Translate::execute() now asserts the entity type against its own
      getSupportedEntityTypes() right after resolving it — before the provider
      call, so a refused entity costs nothing, and for every caller at once.
      saveAttributeForStore() keeps its throw as the last line of defence.

      SkillSupport answers "can this skill act on this entity type" for UI code;
      Js publishes skills.translate and translate.js renders no buttons when it
      is false. Generate still gets the full field list.

      Js gained a constructor argument — setup:di:compile.

      364 unit tests pass. phpcs --standard=Magento2 reports no errors on the
      touched files.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 551992

    • refactor(bulk): give the step 4 panel lookup one home

      The panel query behind the wizard's "can this task run here" check was
      written out three times over. wizardCanRun() and hasFieldSelection() ran
      byte-identical selectors, each with its own empty-argument guard, so a
      change to how a panel is addressed had to be made in more than one place
      to hold.

      findPanel() is now that one place, and both callers read as what they
      ask: does a panel exist, and does the panel that exists have a selection.

      The Build Landing Page panel also rendered unconditionally, though step 1
      offers CMS pages only when the Content Builder is installed. Without it
      the panel was unreachable markup in every admin; it now follows the same
      condition as the content type it serves.

      No behaviour change. 358 unit tests pass; phpcs --standard=Magento2
      reports the same 0 errors as before on the touched file.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6d13ac

    • fix(translate): stop writing CMS page translations into a category

      translateForAllStores() routes every non-product entity through
      saveAttributeForStore()'s else branch, which loads a *category* by the entity's
      id. For a CMS page that id is a page_id, so translating page 8 for all store
      views wrote its translated meta_title onto category 8 — on this install, "New
      Luma Yoga Collection". A silent write into an unrelated record, no error, no
      trace.

      Extending the branch is not the fix either: cms_page has nowhere to put a
      per-store-view value. The table holds one row per page — title, meta and content
      are not store-scoped — and the store assignment lives in cms_page_store. A
      translated page in Magento is a separate page. So Translate stops claiming
      cms_page, which also narrows the MCP tool's entityType enum, and
      saveAttributeForStore() raises on anything it does not explicitly handle instead
      of guessing: entity ids collide freely across tables, and guessing corrupts.

      Generate keeps cms_page — that path is real. GenerateService::loadEntity()
      already resolves pages through PageRepository, so the AI button works on the SEO
      fields of the CMS page form. content_heading joins them: plain text, same
      mechanics as product-name, and the one obvious gap. title and identifier are
      left out on purpose (identifier drags url rewrites behind it), and content
      belongs to the Build Landing Page skill, which writes the whole document.

      Bulk is unaffected: BulkConsumer never handled generate/cms_page and still does
      not, so the wizard offers Build Landing Page alone for CMS pages.

      Verified against the compiled DI:

      cms_page fields content_heading, meta_title, meta_keywords, meta_description
      generate [product, category, cms_page]
      translate [product, category]
      build_page [cms_page]

      360 unit tests pass, including one that pins the corruption: translating a CMS
      page for all store views now raises and never touches the category repository.
      phpcs --standard=Magento2 reports no errors on the touched files. afe08f

    • fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word

      Picking CMS Pages still listed Generate Content and Translate: both declare
      cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
      in the previous commit let them through. Choosing either landed on an empty step
      4 with no step 5 and no Run button — the exact dead end this branch set out to
      remove, just reached by another route.

      A skill's own entity list says nothing about whether this screen can drive it.
      The wizard now asks the only thing that matters: does a step 4 panel exist for
      this (content type, task) pair. That is the condition for not dead-ending, and it
      stays true as panels are added or removed.

      getSkillEntityTypes() existed only to feed the old check and is dropped with it.

      Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
      skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
      schedules "AI Build Landing Page for 1 CMS page". 92afa6

    • fix(bulk): decide the offered tasks by the step 4 panel, not the skill's word

      Picking CMS Pages still listed Generate Content and Translate: both declare
      cms_page in getSupportedEntityTypes() for the inline editor, so the filter added
      in the previous commit let them through. Choosing either landed on an empty step
      4 with no step 5 and no Run button — the exact dead end this branch set out to
      remove, just reached by another route.

      A skill's own entity list says nothing about whether this screen can drive it.
      The wizard now asks the only thing that matters: does a step 4 panel exist for
      this (content type, task) pair. That is the condition for not dead-ending, and it
      stays true as panels are added or removed.

      getSkillEntityTypes() existed only to feed the old check and is dropped with it.

      Verified in the admin: CMS Pages now offers Build Landing Page alone, step 3 is
      skipped, step 4 reads "Describe the page", step 5 lists the 84 pages and Run
      schedules "AI Build Landing Page for 1 CMS page". 7413c9

    • Merge pull request #45 from breezefront/feat/bulk-cms-landing-page

      feat(bulk): build landing pages from the AI Bulk Action wizard eeabe7

    • fix(bulk): file the generated page draft under the scheduling admin

      BCB drafts are per user: Content/Load reads getDraft($pageId, 0, $currentUserId)
      and the collection filters on equality, so a draft written for anyone else is
      invisible in the editor. bcb_page_content.user_id is also a foreign key on
      admin_user, so the placeholder 0 the consumer was passing failed the insert
      outright — every build_page operation died with an integrity violation and no
      draft was ever stored. The unit tests missed it because DraftWriter is mocked.

      The scheduling admin's id now travels in the payload and DraftWriter requires it,
      rejecting 0 before touching the repository. Save refuses to schedule a page build
      when the current user cannot be identified, rather than queueing work that cannot
      land.

      Alongside that:

      - CMS Pages is only offered when the Content Builder is installed. Without it
      nothing can store the document, so the wizard used to run the whole batch
      through the AI only to fail on save. Step 1, the grids and the LISTING map are
      all derived from ViewData::getEntityTypes() now.
      - Step 4's heading follows the picked task: "Describe the page" for Build Landing
      Page instead of the field-list copy, which still said "Select attributes such
      as name, description, meta title".
      - Operator messages use real names: "AI Build Landing Page for 3 CMS pages"
      rather than "AI Build_page for 3 cms_page(s)".
      - Step 4 warns that the run replaces the current draft on every selected page.
      - Dropped the unused is_active column from the CMS page grid's select.

      Tests: DraftWriterTest covers both guards and the write, SaveTest covers the
      payload and the messages, BulkConsumerTest asserts the user id reaches the
      writer and that a payload without one fails the operation. 359 pass; phpcs
      --standard=Magento2 reports no errors. e8f294

    • feat(bulk): build landing pages from the AI Bulk Action wizard

      Step 2 listed every registered skill, so Build Landing Page was offered for
      products and categories. It declares cms_page and no attributes, leaving step 4
      empty and step 5 unreachable — the wizard dead-ended with no error.

      Tasks are now filtered by getSupportedEntityTypes() against the content type
      picked in step 1, and CMS Pages joins that step so the skill has a real path:
      a brief instead of the per-attribute field list, a CMS page grid, and no store
      view step (Content Builder documents are store-agnostic).

      The generated document is stored as the page's Content Builder draft. The BCB
      page flag is left alone: turning it on with nothing published renders a blank
      storefront page, so enabling the editor and publishing stay manual. The BCB
      dependency stays soft — the repository is resolved by name behind
      interface_exists(), like the component catalog reader.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> afe7f0

    swissup/module-breeze-content-builder — 1.2.0 (was 1.0.10)
    • Version 1.2.0 7f774f

    • Changes for support of the contact us page 812853

    • Added h1 for the Text component and switched to tags instead of CSS classes e054e6

    • Added new Map component c2d71b

    • Improve Columns vertical stretch 6b5da8

    • Version 1.1.0 d0ef07

    • Merge pull request #35 from breezefront/feat/ai-components-bridge

      feat(content-builder): apply AI-generated components to the working copy (#34) fa5587

    • Added new Video component 94bb8d

    • fix(content-builder): clone the incoming array before installing it as the working copy d02528

    • fix(content-builder): stay a no-op until sub-modules finish initializing 2c2289

    • fix(content-builder): commit a pending dirty edit before pushing the AI snapshot 3a1ce7

    • feat(content-builder): apply AI-generated components to the working copy (#34) 030d4c

    • Added 5 columns layout for the Columns component 4adc04

    • Version 1.0.12 ccc005

    • fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)

      Three review findings on the H1 work:

      1. The guard counted only Banner components, so it stayed silent on the most
      common duplicate: a CMS page with a Content Heading, where
      Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
      module-theme's html/title.phtml emits <h1 class="page-title">. Load now
      reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
      same flag from its opts so BLB can declare the product/category page title,
      and the guard adds that H1 to the count.

      2. The guard only ran on a headline_type change, so pasting an H1 Banner never
      warned. It now runs after paste and after content load as well, which also
      covers a tree that arrived from an AI build.

      3. getDraft() reloaded the draft by id without checking the load succeeded. If
      the row was deleted between the collection query and the reload, it returned
      an empty model and the caller saved a row with no page_id — rejected by
      MySQL, since page_id is NOT NULL with no default. It now falls back to the
      seeded-draft path, which is extracted into createSeededDraft().

      Load's constructor gained a dependency, so setup:di:compile is needed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> abc266

    • feat(banner): allow H1 for the banner headline (#32)

      The Banner headline_type prop only offered h2-h6, so a page built entirely
      in Content Builder shipped without a primary heading — the document had no
      h1 at all, which is an on-page SEO defect.

      Add h1 to the option list and to the template's tag whitelist. The default
      stays h2, so existing pages and newly inserted Banners render exactly as
      before.

      Because a page should carry at most one h1, the editor now shows a soft
      notice when a headline is switched to h1 while another Banner already uses
      it. It warns rather than blocks — a legitimate layout may keep its h1
      outside the Content Builder bands.

      The Text component's heading_type is deliberately left alone: it renders a
      div with a heading class, not a real heading tag, so an "h1" there would be
      visual only and misleading.

      Fixes #32

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> ced844

    • fix(content): reload draft through singular load so updated_at advances

      getDraft() returned a collection-hydrated item with empty storedData,
      so save()'s update-diff wrote every column back unchanged, pinning
      updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.

      Related to #31 (does not close it — content-loss symptom unconfirmed). b1759b

    • fix(banner): make the duplicate-H1 guard see the theme's own H1 (#32)

      Three review findings on the H1 work:

      1. The guard counted only Banner components, so it stayed silent on the most
      common duplicate: a CMS page with a Content Heading, where
      Magento\Cms\Block\Page::_prepareLayout() feeds page.main.title and
      module-theme's html/title.phtml emits <h1 class="page-title">. Load now
      reports pageHasH1 (Content Heading non-blank), setExternalContent takes the
      same flag from its opts so BLB can declare the product/category page title,
      and the guard adds that H1 to the count.

      2. The guard only ran on a headline_type change, so pasting an H1 Banner never
      warned. It now runs after paste and after content load as well, which also
      covers a tree that arrived from an AI build.

      3. getDraft() reloaded the draft by id without checking the load succeeded. If
      the row was deleted between the collection query and the reload, it returned
      an empty model and the caller saved a row with no page_id — rejected by
      MySQL, since page_id is NOT NULL with no default. It now falls back to the
      seeded-draft path, which is extracted into createSeededDraft().

      Load's constructor gained a dependency, so setup:di:compile is needed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> a6d73f

    • feat(banner): allow H1 for the banner headline (#32)

      The Banner headline_type prop only offered h2-h6, so a page built entirely
      in Content Builder shipped without a primary heading — the document had no
      h1 at all, which is an on-page SEO defect.

      Add h1 to the option list and to the template's tag whitelist. The default
      stays h2, so existing pages and newly inserted Banners render exactly as
      before.

      Because a page should carry at most one h1, the editor now shows a soft
      notice when a headline is switched to h1 while another Banner already uses
      it. It warns rather than blocks — a legitimate layout may keep its h1
      outside the Content Builder bands.

      The Text component's heading_type is deliberately left alone: it renders a
      div with a heading class, not a real heading tag, so an "h1" there would be
      visual only and misleading.

      Fixes #32

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 620e7f

    • fix(content): reload draft through singular load so updated_at advances

      getDraft() returned a collection-hydrated item with empty storedData,
      so save()'s update-diff wrote every column back unchanged, pinning
      updated_at and defeating ON UPDATE CURRENT_TIMESTAMP.

      Related to #31 (does not close it — content-loss symptom unconfirmed). 5c797e

    • Remove custom accordion script d8cb73

    • Added new Accordion/FAQ component 3135bd

    • Version 1.0.11 17c28e

    • Use text color for Heading when set in the Text component 0335a1

    • Added font-size field and source button to WYSIWYG editor dfc235

    • Improve grid component styles on 640px-768px screen sizes fe4e91

    • Use banner text color for headings inside banner if set eb8b24

    • Added Feature Left/Right layouts to Grid component b1b27c

    swissup/module-breeze-layout-builder — 1.2.0 (was 1.1.1)
    • Version 1.2.0 24fff8

    • Added Contact Us page support d64e46

    • Config option for upcoming TaxVAT switcher in theme header 7f31bd

    swissup/module-navigationpro — 1.19.3 (was 1.19.2)
    • Version 1.19.3 421267

    • Do not use `is_active` from remote entity when creating new item
      Since we have new option `use_remote_include_in_menu`, use it instead
      of syncing `is_active` on item creation. 7c215a

    • Fixed not working ribbon for vertical menu 8fdb50

    • LESS 5.3.0 compatibility 922d05

    swissup/module-testimonials — 1.4.2 (was )
    • Version 1.4.2 4c12e4

    • Fixed wrong output when rating is not set 64ce08

    • Added show image config and more options for visible slides dropdown 283b05

    • Version 1.4.1 8ca632

    • Slider widget: added arrows/dots configuration 68ea17

    • Version 1.4.0 2ca286

    • Customizable frontend route, closes #12 e38c8c

    • Removed trailing slash in the canonical tag, see #13 109094

    • Fixed captcha for save controller after removing extend from deprecated class 2b4718

    • Fixed saving store relation bc50ab

    • Use escape in admin notification email b334aa

    • Small code fixes 29bd71

    • Rewrote front controllers without extending deprecated class 0d78e2

    • Updated code to use repository 16b611

    • Remove unnecessary setup_version ffbc53

    • Fixed wrong escaping 4ee69c

    • Fixed undefined $avgRating and division-by-zero 5e68bf

    • Removed deprecated call of $this->helper() in template 99e032

    • Replace deprecated code 94f1f1

    • Replace deprecated code db0ca7

    • Do not translate user-configured string 214f9a

    • Remove unused code 4a1b4f

    • Fixed wrong existing customer detecting 098a18

    • Fixed showing email regardless of config 09aacb

    • Fixed N+1 DB writes in mass status action 86330d

    • Added Testimonial Repository and updated controllers to use it 9a2a35

    • Fixed docblocks and const declarations a73587

    • Version 1.3.17 4e27d1

    • Fixed unescaped output detected error, #14 c764e3

    • Version 1.3.16 4f0b8b

    • Fixed wrong Swiper breakpoints config 7f13f2

    • Version 1.3.15 73865b

    • Fixed implicitly marking parameter as nullable error in PHP 8.4 061815

    • Version 1.3.14 95035c

    • Fixed accessibility error: Can't use h5 without all of the previous levels 127197

    • Version 1.3.13 37d4ce

    • Breeze: scroll reveal animations 9f8c52

    • Version 1.3.12 0f70e6

    • Merge branch 'breeze-dynamic-js' 3afab9

    • Lazy load for profile image in widget b5bb4a

    • Version 1.3.11 f70f2c

    • Breeze dynamic js. WIP d10c53

    • Breeze: remove unused file c5460e

    • Improve breeze intregration 63c3a6

    • Version 1.3.10 4eea58

    • Reduce css size 801626

    • Installer command added 026e30

    • Remove empty spaces around text when adding small quotes around (::before, ::after) fed341

    • Revert "Prevent layout shift when using swiper"

      This reverts commit 50793e261167585b5dae3151c03fb28acdb02994. edbf60

    • Prevent layout shift when using swiper 50793e

    • Version 1.3.9 27495e

    • PHP Stan fixes 0d973f

    • Fixed access to undefined constant 81023b

    • Do not use return in _construct ddfadb

    • Fixed not-working slides_to_show widget config, closes #11 b369c9

    • Version 1.3.8 436aa7

    • Added email validation in CreateTestimonial Resolver b355ee

    • Removed unused CreateTestimonial Resolver arguments 703253

    • Added email validation in admin form 0e254c

    • Version 1.3.7 cb93be

    • Fixed file upload in Magento 2.4.5+ 4b15a7

    • Version 1.3.6 b7f56e

    • Replaced Zend_Validate usage that was removed in Magento 2.4.6 5065f1

    • Version 1.3.5 fef03f

    • PHP 8.2: fixed dynamic properties creation e84a87

    • Version 1.3.4 594bef

    • Fixed creation of dynamic properties (php 8.2 compatibility) acc604

    • Version 1.3.3 a41d69

    • Use theme's font-family for author name aae183

    • Version 1.3.2 468b50

    • Prevent error when image input is removed from the submit form a7922d

    • Version 1.3.1 706c48

    • Fix: email is exposed regardless of "Show user email" (close #9) 794a57

    • Breeze compatibility for slide-list-widget d726e4

    • MCS fixes 96b6eb

    • Fixed invalid avatar alignment in testimonials list b5fa96

    • Breeze integration for list widget d7da46

    • JSCS fixes b1dcb3

    • Do not open new tab after double click on "load more" button c80881

    • No need to load content with post request. (Fixes sections reload after testimonials load) a4ce90

    • Version 1.3.0 4d01a6

    • Improve graphql StoreConfig extended config data e00c0a

    • Add graphql StoreConfig extended config data 5f05b5

    • add createTestimonial graphql mutation 4483cf

    • add single Resolver/DataProvider/Testimonial 10de83

    • Hide/show resovler fields depends config e64f9d

    • add date format resolver c1309d

    • add image url testimonial resolver b4020e

    • move company html to template d8d833

    • use dataprovider insteadof collection factory 641877

    • rm customerSession injection in resolver 31680d

    • Improve templates e559cc

    • add getTestimonials graphql request 49108a

    • Version 1.2.18 1b301f

    • Fixed incorrect date on non-US locale, closes #8 9310e3

    • Updated get identities code fb6870

    • Controllers code refactored 192ba7

    • Version 1.2.17 9dd35e

    • Fixed bad-looking testimonial link in customer dropdown c22bfb

    • Version 1.2.16 1e96b6

    • Automatically clean page cache when testimonial was changed 3d41b8

    • Added canonical url on testimonials page 833b4a

    • Use short testimonials page url 9ae78c

    • Version 1.2.15 b294a7

    • Added space between slides 10px in slider widget 17e88b

    • Disabled freeMode in slider widget d02ad1

    • Version 1.2.14 ea1293

    • Added image size parameters for slider widget 504de5

    • Fixed php error when uploading image in Magento 2.4.1 1098be

    • Version 1.2.13 9f141b

    • Use swiper instead on slick 93fc69

    • Version 1.2.12 ae5503

    • Fixed error filtering template in M2.4 6622bc

    • Version 1.2.11 2bbedd

    • Translations updated e965c4

    • Translations updated 5d1802

    • Version 1.2.10 ed5056

    • Removed unnecessary status change confirmations 2631fb

    • Version 1.2.9 8ab314

    • Fixed error when admin notification email is empty 262407

    • Improved slider widget bb7e7c

    • Version 1.2.8 df8489

    • Update uk_UA.csv (POEditor.com) 685cfe

    • Update sw_SE.csv (POEditor.com) c87b14

    • Update es_ES.csv (POEditor.com) 6f795a

    • Update ru_RU.csv (POEditor.com) 085215

    • Update pt_PT.csv (POEditor.com) 17c07b

    • Update pl_PL.csv (POEditor.com) 9dabf0

    • Update nb_NO.csv (POEditor.com) ae2a98

    • Update ko_KR.csv (POEditor.com) e25751

    • Update ja_JP.csv (POEditor.com) 6f92d3

    • Update it_IT.csv (POEditor.com) 51b62f

    • Update he_IL.csv (POEditor.com) 5f1aa8

    • Update de_DE.csv (POEditor.com) 827d5b

    • Update fr_FR.csv (POEditor.com) 72e587

    • Update nl_NL.csv (POEditor.com) 45037c

    • Update zh_CN.csv (POEditor.com) c70cd9

    • Update ar_SA.csv (POEditor.com) 572ad5

    • Add files via upload 05afc6

    • Version 1.2.7 c62689

    • Marketplace data added 1c280f

    • Version 1.2.6 b9fd6d

    • Installation instructions for clients added 684aad

    • Version 1.2.5 d1bd25

    • Resize images only by width when height is empty 09e85e

    • Version 1.2.4 a42213

    • Fixed file uploading in Magento 2.3 92c29e

    • Version 1.2.3 e0224a

    • Fixed wrong php doc aa15ac

    • Version 1.2.2 4cf186

    • Up required module-core version
      Since 1.8.0 module-core has Model/Media/FileInfo. Module uses it in `di.xml`. 1c4d4f

    • Use module-core\Model\Media\FileInfo to get file info f4877d

    • Version 1.2.1 d7e6dd

    • SlickCarousel update 50f3b8

    • Rename composer package to 'swissup/module-testimonials'; ver 1.2.0 cae583

    swissup/theme-frontend-breeze-enterprise-apollo — 3.3.0 (was 3.2.0)
    • Version 3.3.0 7f1f00

    • Added Contact Us page installer a791e7

    • Improve switcher label alignment inside mobile slideout a6415d

    • Fixed transparent menu icon for few seconds on slow web connection 7b8ab8

    • Sync gallery slider arrows with theme settings 6ce6f4

    • USP block content updated 96047c

    • Updated footer_top_content block content 946d00

    • Updated BCB homepage: moved to json; updated benefits block content 62bfe1

    • Fixed listing styles on Magento < 2.4.8
      LESS ^3.2.0 bugfix: This css block moved to the start of the styles. a38c1c

    • Firefox: Fixed horizontal scrollbar inside search slideout 613e59

    swissup/module-core — 1.13.1 (was 1.12.27)
    • Version 1.13.1 5b7b24

    • Fixed invisible update instructions when Mollie Payments is installed 72aab6

    • Version 1.13.0 f34c7b

    • Fill the links after ajax refresh b6a316

    • Show loading state on the "Check for Updates" button 1ab16e

    • Use optimized repo to fetch latest releases 1aa8fb

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 88915a

    • Check theme source files in Virtual Theme Check (#19)

      Making a virtual theme physical while its source files are unavailable is
      what breaks the storefront with "Required parameter 'theme_dir' was not
      passed", so the check now reports the state of the files and the fixer
      refuses to touch the themes it cannot read.

      - Virtual Theme Check gets Path and Status columns
      - Status names the files Magento can't read. Unreadable file is never
      reported as a removed one - a failed stat cannot tell them apart
      - Fix All opens up the permissions of unreadable source files and skips
      the themes that are still unreadable, listing them in the response
      - Readability check extracted into Swissup\Core\Model\Theme\SourceFiles,
      shared by the block and the controller
      - Config table styles shared between Modules and Virtual Theme Check

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 6e39e2

    • Mark core section with badge when outdated modules found 9a3764

    • Outdated modules badge and Modules config group (#25) 7df026

    • MCS fix 3b9121

    • Update instructions 0e686f

    • Read links info from marketplace meta 21e95b

    • Keep the last check time when a refresh fails

      Removing the stored time was what made the next load re-check the feed.
      An unreachable feed left no time at all: the config page had nothing to
      display, and every page load kept re-checking until one request made it
      through.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 3437f9

    • Post the modules refresh to an action of its own

      The refresh drops the version check throttle and re-reads the remote
      source, and a get carries no form key validation - Magento checks it for
      post requests only, and the url secret key is optional. Reading the list
      stays a get, so the two live in separate actions now.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 4a95c9

    • Keep the module links out of the response

      The links cell is rendered by the server and never touched by js.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 7556db

    • Send only the module metadata the browser renders

      The merged Loader record carries the local filesystem path and the
      download, license and purchase-code fields of the remote feed. None of
      them is rendered by the config page, so keep them on the server.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 2bd425

    • Too much red 2e307e

    • Use the same `outdated` class name everywhere

      The version span was already renamed, while the row and the js still said
      `_outdated` - a row that stopped being outdated kept its red version pill.

      Since the row now wears the very same class, the pill rule had to be
      scoped to the version cell, or the whole row would turn red.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> c21086

    • Report the check time with the modules, and keep outdated rows first

      Two things the clients were guessing at:

      The `Check for Updates` handler stamped the label with the current time,
      but the request may have re-used the stored packages after a failed feed
      request - `just now` was not always true. And an automatic load did check
      the remote source, yet nothing updated the label at all. The endpoint now
      returns `['items' => ..., 'last_check' => ...]`, and the label follows the
      time the server reports for every load.

      The table was re-rendered cell by cell, so a module that became outdated
      kept its old position while the counter above it grew. Mark the rows and
      move the outdated ones back to the top - both groups stay sorted by name.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 70f271

    • Keep the last check time once the check is due

      The timestamp was stored with a one hour lifetime and the throttle was
      `is the entry still there?`. Handy, but it also meant the config page
      could no longer tell how old the data was as soon as the check was due -
      exactly when that is worth showing next to `Check for Updates`.

      Store it without a lifetime and compare the age instead. Same hour
      boundary, and the label now reads `Last checked 2 h ago` instead of
      disappearing.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 9e4c2a

    • Don't update the modules on refresh. Wait for isVersionCheckRequired 129e66

    • Hovered color for outdated badge 0a0016

    • Show outdated modules count in config and add Modules group

      The admin had no hint that installed Swissup modules have updates
      available - one had to open Module Manager to find out.

      Add a counter badge to the Swissup tab of the config navigation, and a
      Modules group in Swissup > Core listing every installed module with its
      installed and latest version, outdated ones first.

      Neither of them may slow down the config page, so both render from the
      data stored in var/swissup/core (Loader::setOfflineMode()), and the
      versions, the counters and the badge itself are then refreshed by a
      single ajax request shared by all the consumers. `Check for Updates`
      re-runs that request with refresh=1, which drops the version check
      throttle and re-reads the remote source.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> bf1ad6

    • Move core config to the top as it will display outdated modules 115f37

    • Added ability to immidiately re-check remote source for updates `-r` 357d33

    • Keep the remote packages data in files instead of the cache (#24) cbeecc

    • Trim the comments down to what is not in the code

      The two lock docblocks carried the same paragraph, and save() explained
      the reasoning that belongs in the commit that introduced it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 09d6b9

    • Serialize the writes with a lock taken before the entry is opened

      openFile() truncates whatever it opens, so a lock taken afterwards keeps
      the writes apart but not the truncations. Two saves could interleave into
      one file: the second one truncates, the first one writes a whole entry,
      and the second one then overwrites its beginning - leaving the head of
      one entry on the tail of another. The length line does not catch that
      when both entries encode to the same size, as fixed width values like a
      version hash always do.

      Take the lock before opening, on a file of its own - the entry cannot be
      opened before the lock is held, and the file lock() uses is likely held
      by the caller already, which flock would deadlock against.

      Failing to open a lock file is no longer reported as a lock held by
      somebody else. A lock left behind by another user cannot be opened at
      all, and reporting that as contention kept the caller away from the
      entry for good - silently serving a list that could never be refreshed.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 73cf28

    • Don't download the list that another process has just stored

      The version is fetched before the download lock is taken, so a process
      that was still asking for it when the lock was released would go on to
      download the very list the lock holder had just stored. Look at the
      storage once more before downloading.

      Also correct what the save() lock claims to do: it serializes the
      writes, but the truncation happens in openFile(), outside of it.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> d557a0

    • Reject an entry whose expiration time is not a number

      A corrupt header such as `expires:invalid` was cast to zero, which reads
      as "never expires" - so an entry that should have gone stale could be
      served indefinitely instead of being treated as missing. Validate the
      field the same way the length line is validated.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 12db16

    • Keep the remote packages data in files instead of the cache

      The packages list was kept in the Magento cache, so every cache flush
      threw it away and the next admin request had to download it again. It is
      now stored under var/swissup/core, where it outlives the flush.

      FileStorage writes the expiration time and the data length ahead of the
      contents, so an entry knows itself when it goes stale, and a reader can
      tell a half-written entry from a complete one - openFile() truncates the
      file before the write lock is taken, so a reader can catch the entry
      mid-save. An incomplete entry is reported as missing and refetched,
      rather than served as if it were whole.

      Remote revalidates against the version hash from packages.json, at most
      once an hour, and only downloads the full list when that hash moves. The
      download itself is guarded by a non-blocking lock, so several admins
      hitting the page at once produce one request instead of one each - the
      others serve the stored copy, or wait for the download when there is
      nothing stored yet. fetch() now reports connection errors and 4xx/5xx
      responses by returning an empty body, letting the caller fall back to
      the stored data instead of decoding a failure page.

      The admin notification feed keeps its last-update timestamp in the same
      storage, replacing its own copy of the file handling.

      Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> 617dbd

    • Small fixes f7eef6

    • Single source of truth for is_outdated, is_installed 37ad01

    • Fixed `Undefined array key "type"` warning b2a9c1

    • Don't show non installed modules when asking for outdated bf5fb1

    swissup/theme-frontend-breeze-enterprise-blank — 3.3.0 (was 3.2.0)