# [0.2.3] - 2026-10-27

The Configurator lands - options with rules, templates, pictures and a step-by-step picker on the product page - along with a full media picker and a faster dashboard. One thing to do when you upgrade: migrations now run as the backend starts, so the `migrate`, `signals-migrate` and `clickhouse-migrate` services are gone from the compose files. Drop any `docker compose run --rm migrate` from your deploy scripts and any override that names those services; the server brings the schema up to date on its own before it serves.

### Added

- **Configurator templates: a set of options and rules kept once and used on many products.** Templates live in a
  product's Configurator section - "Use a template" adds one, "Manage templates" lists them all to create, use or open
  one. A template gets its options, rules and map in the same editor a product has, a currency its flat charges are
  in, and a "Used by" list to add products to. A product can use several, in an order you pick; their options show
  first on the product page and in the cart, before the product's own, and an edit to a template reaches every product
  using it while orders keep what was picked. On the product, template options show read-only under the template's
  name, the product's own options and rules can read their answers, and "Detach" copies a template onto the product as
  its own when one product needs it changed. A template still in use can't be deleted, and one the product's own
  options still read can't be taken off.
- **Product types can hand templates to all of their products.** A product type's new Configurator templates section
  puts its templates on every simple and configurable product of the type, ahead of the ones a product picks itself,
  and a product shows them read-only with the way to the type. A template on both the type and the product shows once,
  and a configurable and its variants are asked and charged for it once. The type's list can't drop a template one of
  its products still reads unless that product picked it itself too.

- **Paged gift card and subscription queries.** The admin API gains `giftCardsPaged(search)` and
  `subscriptionsPaged(status)`, each returning one page with its `totalCount`, and
  `PromotionRule.codes` takes `search` and `isActive`.

- **⌘S / Ctrl+S saves a dashboard page.** It opens the same review dialog as the Save button, and does
  nothing while there's nothing to save.

- **Options and their choices can have pictures.** In the option editor, each choice gets a small picture slot and the
  option one of its own - pick from the media library or upload. On the product page, an option whose choices have
  pictures shows them as tiles to pick from, and an option's own picture sits under its name, like where an engraving
  goes. Pictures load with the option rules in a query of their own, so the main product query stays under its
  complexity cap; deleting the picture from the media library just removes it from the option.

- **Products get a Configurator: options plus rules, "when these answers, then these effects".** The product options
  section is now the Configurator, and its options can carry rules that react to what the customer picked - make
  another option required or optional, fix and lock the cart quantity, or move the price by an amount or a percentage
  (per store, like option charges). The cart applies them on add, a price change lands on the line as its own row
  named by the rule ("Gift set discount"), the storefront previews required options, quantity and price live, and the
  cart console follows them too. Rules are edited in a dialog next to the options, and a new map view lays the options
  out top to bottom the way customers meet them, with conditional options branching off the answers that show them and
  rules pointing at what they change.

- **Step-by-step configurator on the product page.** A product's Configurator has an "On the product page" setting:
  all options at once, as before, or step by step. Step by step walks customers through the visible options one at a
  time with a progress bar, keeps their earlier answers above the current step with a link to change each, holds them
  on a required option until it's answered, lets them skip an optional one, and ends on a review before adding to the
  cart. Steps follow the conditions, so an answer that shows or hides a later option changes the count as they go, and
  the add button's "missing option" jump brings that step up.

- `PaymentMethodOption.isOffline` on the storefront API says whether a payment method has no third
  party behind it (cash on delivery, bank transfer, pickup, purchase order).

### Changed

- **The dashboard starts faster and idles quieter.** The sign-in page downloads less than half as
  much (576 KB to 260 KB gzipped), and a signed-in page 381 KB instead of 591 KB. Hidden workspace
  tabs stop polling, and a mutation refetches only the queries on screen.

- **A refused delete in the dashboard now says why, in its confirmation.** Deleting a collection, a
  collection translation or a CMS page used to close the dialog and drop the server's reason; it now
  stays open and shows it. Rotating or deleting a webhook, removing a product download, image or URL
  redirect, and clearing a segment show their failure in the dialog instead of behind it or in a toast.

- **The dashboard settles into place instead of popping in.** A reload paints the shell's frame in
  its own colours straight away rather than a white page. The sidebar and the home page hold
  skeletons until your permissions and saved layout are in, then appear once, so sections no
  longer reorder under you. Stat tiles keep their place while loading instead of showing 0, and
  page loaders too brief to read no longer flash.
- The header is a little shorter, 48px instead of 56.

- **Dashboard confirmations use the dashboard's own dialog.** Deleting a workspace, a media asset or
  directory, a shipping method, an email or email group, or a promotion code, and changing a promotion
  condition's type, no longer pop up the browser's native confirm box.

- **A full media picker everywhere the dashboard picks from the library.** CMS image fields and attributes,
  promotion badges and the email builder now open one large modal with the library's folder tree, search
  across names, titles, alt text and captions, sorting (newest, oldest, name, largest), grid or list view with two tile
  sizes, and a details panel to fix alt text (or write it with AI), title, caption and the crop focal point without
  leaving. Files upload by drag and drop, paste or the Upload button into the open folder, several at once with
  progress, and land already picked. Fields that take several assets pick several at a time, and the arrows and Enter
  work the grid. It keeps loading as you scroll, however big the library is. Media search takes a sort order to back
  it, with a stable order so paging never skips or repeats a file.

- **Migrations run as the backend boots, with no migrate containers.** The server applies the
  commerce schema, the plugins' migrations and the data patches before it serves; the signals
  and ingest processes do the same for the Signals database and the ClickHouse spine. Processes
  booting together take turns on a lock, so each migration runs once, and a boot refuses to start
  if a migration it applied has since been edited. `skelet migrate` runs the commerce step by hand
  for a deploy that wants it first, and the prod images no longer ship dbmate.

- **Configurator edits no longer re-index products in search.** Changing a product's options, a template or a product
  type's templates used to re-index every product it reached, though search holds none of it - a template on a
  product type with thousands of products meant thousands of re-indexes. Caches and webhooks still hear about each
  product, now from batched reads.

- **Product options are edited in one dialog.** Each option opens a wide editor with its name,
  how customers answer (picked from described cards), its choices or charge, and when it shows -
  instead of separate inline forms for the option and each value. The list reads as a summary:
  choices with their charges, character and file limits, and conditions in words ("Only when
  Wrapping is Gift"). Charges are typed in money ("3.50 USD", per-store amounts in each store's
  currency) rather than cents, file limits in MB, options reorder with arrows instead of sort
  numbers, and a choice can be linked to a product - or several products added as choices at
  once - through the shared product picker. A staged delete stays visible with an Undo.

- **Product recommendations get the full width.** Related, upsells and cross-sells are tabs over
  one sortable table (image, name, SKU, reorder and remove), each tab shows its count and flags
  unsaved changes, and products are added through the shared product picker. The inline search
  box is gone - the picker searches too. A list past ten products scrolls inside its own frame
  and gets a filter box; reordering pauses while a filter is on.

- **Product and category pages make about 40% fewer database queries.** An uncached product page
  went from 32 queries to 19 and a category page from around 35 to 22. Stock is read once per
  product, the per-question review breakdown is only built when a query asks for it, and
  attribute, product type and translation metadata is shared across requests for up to 30
  seconds. Edits to translations, product type names and attribute groups can take up to 30
  seconds longer to reach the storefront.

### Fixed

- **Every shopper cart, gift card, subscription and promotion code can be reached in the dashboard.** These
  lists used to load the first 100 (1000 for codes) and page or search only those; they now page
  and search on the server.

- The sidebar's resize line sits on the content's edge and stays on top all the way down, instead of
  running 8px into the page and disappearing under the blurred page title.

- **⌘K finds every dashboard page**, not only the ones in the sidebar, still limited to what the admin
  may open. Signup forms, web forms and the two Segments pages no longer share titles.
- **The category and media folder trees work from the keyboard**, icon-only buttons have names, and
  form labels point at their inputs, so screen readers announce what each control is.

- The nightly sweep of abandoned customer option uploads no longer removes an asset that a product option or choice
  uses as its picture, and option pictures upload to their own `option-pictures` library folder.

- Saving product options writes in an order the backend accepts: a reorder together with a new
  condition, or removing a condition and the choice it named, no longer fails partway through.
  Moves that would put a condition above the option it reads, and deletes of an option or choice
  a condition still uses, are refused up front instead of at save.

- `productsByIds` returned every product without a name, and the admin cache spread that blank over
  names other queries had loaded - recommendations showed SKUs where names belonged.
- Opening a product picker on a product page no longer reloads the page and closes the picker. The
  picker's type filter replaced the page's cached type attributes with a narrower copy.
- Recommendations past the first 100 linked products (across all three lists) showed as
  "Unavailable product" - `productsByIds` answers at most 100 ids, and the editor asked for all of
  them at once. It now asks in batches.

- **A struggling OpenSearch no longer takes checkout down with it.** When OpenSearch keeps failing,
  the storefront stops asking it for a few seconds and lets a single request check whether it's
  back. Postgres still answers in its place, but for at most a quarter of `DB_MAX_CONNS` at a
  time, so carts and checkout keep their connections. Past that, a search comes back empty
  instead of queueing.
- A browse that fails on every search tier is no longer cached as an empty catalogue for a minute.

- **The section index on long detail pages no longer covers buttons.** While you scroll it shows only its ticks, which
  fit in the page's right margin, and its labels open over the page only when you point at it or tab to it - before,
  they spread over buttons at the right edge, like a section's "Use a template", and could catch the click meant
  for them.

- **The backend's response cache now actually serves the storefront.** The storefront never sent
  an operation name and the cache only recognises queries by name, so no storefront read was ever
  cached there. It sends one now, and a few more public queries joined the cacheable list:
  load-more on listings, product compare, blog authors and taxonomy, the store product lookup,
  the free-shipping threshold and channel locales.
- **A storefront purge can't be refilled with the old answer any more.** Each copy the storefront
  caches names its purge surface, and a purge gives that surface a fresh epoch in the backend's
  response cache before the storefront hears about it, so the refetch always reaches current data.
- **Settings → Cache now controls the storefront.** `cache/<surface>/enabled` and `ttl_seconds`
  showed in the admin but were never read. A disabled surface now refreshes every second while
  staying purgeable, and an enabled one keeps its data for the TTL you set.
- Cached responses are stored as they were sent rather than base64 inside JSON, so the response
  cache needs 15-30% less memory per entry.

- **Vetrina edits the shopper's own cart, and no other.** Signed-in shoppers can change, remove,
  save and estimate cart lines without a cart cookie, because every cart action now resolves
  through the customer session or the guest cookie. Only adding to the cart creates a guest cart,
  so a coupon link without a cart waits for the first addition. A backend failure fails the
  action instead of switching to another cart. The cart in the browser also reloads after sign-in
  merges the guest cart, and after an edit fails.

- **Vetrina shows and reports money at each currency's own precision.** Prices for a currency with
  zero or three decimals (JPY, BHD) were divided by a hundred everywhere. Product structured data,
  `view_item`, `begin_checkout` and `purchase` events and code-only price formatting now use the
  store's configured decimals, or ISO 4217's when only a currency code is known. Order pages
  convert with the order's own currency.

- **Vetrina account actions keep the shopper's language.** Signing in or registering, password
  resets, verification emails, addresses, subscriptions, saved cards, loyalty redemptions, reviews
  and logout redirected to unprefixed paths, which sent a German shopper back to English. They now go
  to the localized path. Return destinations keep their query, and a path that already carries the
  locale, such as `/de?welcome=1`, no longer gets a second prefix.

- **Vetrina only takes orders it can actually get paid for.** The demo storefront ships no payment
  gateway SDK, so checkout now offers only offline methods, and it re-checks before placing the order
  that the chosen method is still eligible. If a gateway still asks for a payment action, the shopper
  lands on the order marked "created, payment not complete". Before, the storefront tried to finalize
  the payment itself and redirected as if it had succeeded. Orders fully covered by gift cards or
  store credit still need no method.

- **A Vetrina coupon link applies whichever way the shopper adds to the cart.** A code held from a
  promotion link was applied only after simple and configurable-product additions. Bundles, grouped
  products and quick order now apply it too. It applies exactly once, and a grouped or quick-order
  batch where only some items were added still gets it.

- **Vetrina's search suggestions always match what was typed.** A slow response for an earlier query
  could replace the results for the current one, and a response that arrived after the search dialog
  closed showed up when it reopened. Only the response for the current query in the open dialog is
  shown now.

- **Sorting Vetrina search results keeps the search.** Choosing a sort order on `/search?q=shirt`
  dropped the search term and any query-string filters. Sort links now keep them and go back to the
  first page.

- **Videos play in Vetrina CMS pages and product content.** The storefront's own CSP blocked YouTube
  and Vimeo players (there was no `frame-src`) and video files (there was no `media-src`). Embeds now
  always use YouTube's privacy-enhanced `youtube-nocookie.com` host or Vimeo's player, and the CSP
  allows exactly those two players plus same-origin, upload-preview and HTTPS video (HTTP too in
  development). Embed links for any other host are no longer rendered. Who may frame the storefront
  is unchanged.

- **An item moved from the Vetrina wishlist to the cart stays out of the wishlist.** The browser's own
  copy of the wishlist kept the moved item, and the next guest-wishlist import added it back. The
  local entry is now removed before the page refreshes.

### Security

- **`golang.org/x/net` v0.62.0.** Fixes quadratic-time text coalescing in its HTML parser
  (GO-2026-6860), which the SEO link crawler runs on every page it fetches, so a crafted page
  could stall a crawl. The other `golang.org/x` modules move with it.

- **No `pnpm audit` findings in either app.** body-parser 2.4.0 (a denial of service on compressed
  request bodies) and fast-uri 3.1.9 (four URI parsing advisories), both already inside their
  parents' ranges. Only dev tooling reaches them - the shadcn CLI and Sentry's webpack plugin -
  so nothing that ships was exposed.

- **Frontend lockfiles refresh weekly.** Renovate's lockfile maintenance re-resolves the
  dashboard's and each storefront's transitive dependencies within their ranges, so a patched
  release reaches them without an override. Both lockfiles are refreshed now, which clears the
  markdown-it advisory and drops every audit override but `@graphql-tools/utils`, whose parent
  still excludes the fix.

- **Go 1.27.3.** Distrusts the compromised intermediate certificates in `crypto/x509`
  (GO-2026-6809).

- **Next.js 16.4.2 in Vetrina.** Clears the high-severity advisory against Next.js 16.0.0
  through 16.4.0 that `pnpm audit` reports.

- **The demo storefront no longer records shoppers' private pages.** Session replay and heatmap
  snapshots skip account, cart, checkout, order, order-tracking, gift-card, email-verification,
  shared-wishlist and preview pages, with or without a locale prefix, and the signed-in name in
  the header, the masquerade banner and the sign-in drawer are blanked everywhere. Page views and
  click heat still count those pages. Replay pauses on a private page and resumes with a fresh
  snapshot on the next public one. A storefront test fails when a component renders a
  customer's name, email or phone outside those pages without blanking it.

[0.2.3]: https://github.com/skeletg/skelet/compare/v0.2.2...v0.2.3

---

From the Skelet changelog: https://skelet.dev/changelog/0.2.3/
