Skip to content

Vue 3 / Bootstrap 5 / Vite migration with dark mode, performance & checkout fixes - #749

Merged
KrzysztofPajak merged 80 commits into
developfrom
feature/vue3-bootstrap5-migration
Aug 5, 2026
Merged

Vue 3 / Bootstrap 5 / Vite migration with dark mode, performance & checkout fixes#749
KrzysztofPajak merged 80 commits into
developfrom
feature/vue3-bootstrap5-migration

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Aug 2, 2026

Copy link
Copy Markdown
Member

Overview

Migrates the GrandNode 2 storefront from Vue 2 / BootstrapVue / Bootstrap 4 to Vue 3 / Bootstrap 5, replaces the vue-cli build pipeline with Vite, and ships a series of performance, accessibility and correctness fixes developed on top of the migration.


What changed

Vue 3 + Bootstrap 5 migration

  • Rewrote all storefront components (Grand.Web) for the Vue 3 Composition API / <script setup> model and Bootstrap 5 markup conventions.
  • Removed BootstrapVue (a Vue 2-only library); replaced b-sidebar, b-icon, b-modal etc. with plain Bootstrap 5 offcanvas / modal / icon equivalents.
  • Replaced the vue-cli / webpack build with Vite; theme CSS is now generated by the Vite pipeline instead of being maintained by hand.
  • Committed the Vite build output so the storefront runs without Node being installed.

Dark mode

  • Colour scheme is resolved in <head> (before paint) and follows the OS prefers-color-scheme setting.
  • Theme.Modern gains a full dark-mode palette keyed on data-bs-theme.
  • All hardcoded hex colours in Theme.Modern CSS replaced with Bootstrap CSS custom properties (--bs-body-color, --bs-body-bg, --bs-secondary-bg, semantic badge colours, etc.), so dark mode flips automatically.
  • Added a .sr-only compatibility shim (Bootstrap 5 renamed it to .visually-hidden).

Performance

  • Response compression: registered Brotli and Gzip providers explicitly and enabled over HTTPS for Grand.Web; text/javascript added to the MIME list (static JS was served with that type and bypassed the default compressor).
  • Deferred script loading: every script that depends on the bundle (including public.checkout.js emitted from the Checkout view) is now defer-ed, unblocking the HTML parser.
  • FCP / v-cloak: the root #app no longer carries v-cloak; the full-viewport loading overlay now keys off .axios-request (XHR only) instead of blanking the page until the 400 kB bundle has run.
  • animate.css removed from the shared bundle (95 kB); Theme.Modern ships only the fadeIn subset it actually uses, reducing libs.css from 392 kB to 319 kB.
  • Server-side catalog listing: the first page of catalog results is rendered on the server so it is visible before JavaScript runs.

Cart & sidebar

  • Fixed Vue 3 reactivity bugs in the cart drawer (item additions/removals were not reflected).
  • Cart actions moved from inline onclick to data- attributes consumed by a single delegated handler.
  • Cart drawer body guard moved to the drawer model (was previously only on item count).
  • Sidebar IDs unified (#sidebar-cart, #sidebar-wishlist, #sidebar-compare).

Checkout

  • CheckoutController.Start now validates checkout attributes on every GET entry point (previously only Index checked; a cart with an unanswered required attribute reached the order-placement step before failing).
  • OPC accordion selector updated from .not-collapsed (BS4) to :not(.collapsed) (BS5).
  • Checkout heading stays on screen when a step opens.
  • Deferred public.checkout.js fixed a blank-page regression introduced by the parser-blocking fix.

Accessibility

  • Added <main>, <header> and <nav> landmarks to the layout.
  • Added a skip-to-content link (common.skiptocontent resource string).
  • wishlistQty count now reads from a data-qty attribute instead of localized display text (fixes a mounted() crash on stores with wishlist disabled).

Business logic — product reservations

  • Extracted duplicated reservation-group-finding logic into ProductReservationExtensions.FindGroupToBook and InRentalPeriod.
  • Fixed day-reservation booking outside UTC: comparisons now use .Date (date part only) so reservations stored at local-midnight survive round-tripping through UTC.

Other fixes & improvements

  • Server-side star ratings rendered through a single overridable partial.
  • Auction countdown layout and units restored.
  • LinkTagHelper version-stamping applied consistently.
  • Unreferenced minified theme scripts deleted.
  • ShowQty dead branches removed from catalog views.
  • Catalog action URLs built in the handler, not in the views.
  • Mobile footer overlap and stray empty Filters button fixed.
  • Mobile header layout and blog stacking fixed.
  • Navbar logo max-width/max-height constraints removed.
  • Footer columns stack full-width on mobile.
  • Mobile currency/language selector hidden correctly on scroll.

NuGet & tooling updates

  • Microsoft.AspNetCore.OpenApi → 10.0.10
  • MessagePack → 3.1.8
  • Microsoft.FeatureManagement.AspNetCore → 4.6.0
  • Scalar.AspNetCore → 2.16.16
  • Azure.Monitor.OpenTelemetry.AspNetCore → 1.6.0
  • Azure.Storage.Blobs → 12.29.1
  • AWSSDK.Core / AWSSDK.S3 → 4.0.100.x / 4.0.101.x
  • Microsoft.OpenApi 2.11.0 added (required by updated OpenApi package)
  • global.json pinned to .NET 10 SDK; README prerequisites table updated.

Migration DB

  • Added MigrationUpdateResourceString (version 2.4) that imports en_240.xml resource strings (includes common.skiptocontent).

Testing

  • All existing unit tests pass.
  • Storefront renders without JavaScript (server-side HTML visible before hydration).
  • Checkout flow: all six steps render; shipping address, shipping method and payment method each submit and advance correctly.
  • Dark-mode toggle verified in Theme.Modern.

KrzysztofPajak and others added 30 commits July 29, 2026 08:25
…ue 3/Bootstrap 5

Replaces bootstrap-vue with an in-house Vue 3 compatibility layer
(vueapp/src/compat) that reimplements the b-* components, $bvToast/$bvModal
services, v-b-toggle/modal/tooltip directives and vee-validate 3's
validation-observer/provider API used throughout the storefront views, so
existing Razor templates keep working largely unchanged. Mechanically
updates Bootstrap 4 classes/attributes to their Bootstrap 5 equivalents
across views and theme CSS, and fixes several Vue 3 template
incompatibilities uncovered along the way (bare <template> fragments,
v-slot+v-if/v-else combinations, Vue.extend usage, stray v-if-else typo).

Also fixes a handful of storefront bugs surfaced during testing:
- checkout "Go to cart" / view-cart buttons not navigating (Vue 3 proxy
  intercepting bare `location = ...` assignments)
- missing checkout button on the cart page (v-if-else typo hid the section)
- add-to-cart popup layout/styling and quick view modal sizing/overflow
- country -> state dropdown repopulation now driven by reactive data
  instead of raw DOM manipulation, which Vue 3 could wipe out on re-render

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t button spacing

- ValidationProvider no longer listens to 'input' on <select> controls: native
  selects fire input before change, and Vue's v-model syncs on change only, so
  validating on input reactively re-rendered the select with a stale (empty)
  model value and silently discarded the user's country/state pick.
- Selectors.cshtml: drop the stray Bootstrap 5 grid ".row" class from the
  category toolbar wrapper (BS5's ".row > *" forces width:100% on non-.col
  children, which stacked the sort/display/pagination controls vertically),
  and fix the mismatched "grid3x2-gap" icon name to the real bootstrap-icons
  "grid-3x2-gap".
- b-icon compat: support the "scale"/"rotate"/"flip"/"shift" props BootstrapVue
  exposed, not just "font-scale".
- b-card compat: render the "sub-title" prop (was silently dropped).
- Register.cshtml: same BS5 ".row" grid regression fixed for the gender radios.
- Checkout: add spacing between the Back/Confirm and "order details"/Continue
  button pairs, and fix a bare `location = ...` handler that no longer
  navigates under Vue 3.
- Enable Razor runtime compilation in Development for faster iteration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dark mode (:dark-theme) mechanism:
- Vue 3 never manages the root #app mount container's own attributes (only
  its innerHTML is compiled as the template), so the existing
  :dark-theme="darkMode" binding on #app was permanently inert. app.js now
  sets the dark-theme attribute imperatively on #app and body on toggle and
  on load from localStorage; added body[dark-theme] background.

Missing text-color overrides (Bootstrap 5 sets explicit component colors via
CSS variables instead of relying on inheritance, unlike BS4):
- .table cells set color via --bs-table-color per BS5's
  ".table > :not(caption) > * > *" rule; added the missing --bs-table-color/
  --bs-table-striped-color/--bs-table-hover-color/--bs-table-active-color
  dark overrides (fixes cart totals, product attributes table, and any other
  plain .table sitewide).
- .list-group-item and .card set their own explicit color; added dark
  overrides for both.
- .attributes .custom-control-description / .form-check-label (product
  attribute checkbox/radio option text) had no dark color at all.
- .b-sidebar (cart/wishlist/compare side panels) had zero dark styling
  (background, close button, borders, .text-muted).

Corrupted compound selectors from the BS4->BS5 rename script:
- The blanket "custom-switch" -> "form-check form-switch" text substitution
  inserted a literal space inside compound class selectors (e.g.
  ".contrast-switch.custom-switch.b-custom-control-lg" ->
  ".contrast-switch.form-check form-switch.b-custom-control-lg"), turning
  one compound selector into an unrelated descendant selector that matched
  nothing. Restored the correct dot-joined selectors in common.css/.rtl.css.
- BootstrapVue's switch used label ::before/::after pseudo-elements to draw
  the track/thumb (Vue 2); Bootstrap 5 draws the switch via a background-image
  on the input itself, so those pseudo-element rules and the icon's negative
  offset positioning no longer applied to anything real. Rewrote
  .contrast-switch to style the native BS5 switch input directly and lay the
  sun/moon icons out via flex order instead of absolute positioning tied to
  the old pseudo-elements.
- .badge-primary/secondary/success/danger/warning/info/light/dark (removed in
  BS5, replaced by text-bg-*) are still emitted by admin-entered category
  "flag style" content; added compat aliases mapping them to the BS5 theme
  colors so existing data keeps rendering instead of going invisible
  (transparent bg + inherited text color).
- .breadcrumb relies on BS5's default flex align-items: stretch; the enlarged
  home icon stretched its <li> taller than sibling items, misaligning the
  "Home" text baseline. Added align-items: center.
- .block-account-navigation ul li svg targeted BootstrapVue's old inline-SVG
  icon output; the icon compat shim renders <i class="b-icon">, so the
  spacing rule never matched. Added the .b-icon selector alongside it.

Also fixes cart sidebar item rows (name/price touching the thumbnail image)
and the sidebar close button being unstyled (invisible on dark backgrounds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Move mobile header icons back to the top row instead of a fixed bottom
bar (pre-existing behavior, not overflow-safe under BS5 defaults), and
tighten spacing/icon sizes so nothing wraps below ~320px width.

Fix HomePageBlog referencing New Products' column-width property
(Np_Col) instead of its own (Pb_Col), causing blog posts to render two
per row on mobile instead of stacking like the News widget.

Hide the mobile "Filters" toggle button when a category has no
specification/rating filters, since its only content would otherwise
be the category-navigation block, which is intentionally hidden below
the lg breakpoint - previously this left the sidebar opening empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…vity bugs

Bring the Theme.Modern plugin (now the default storefront theme) up to
the same Vue 3/BootstrapVue-3/Bootstrap 5 baseline as Grand.Web's
default theme: replace broken Vue.use(VueAwesomeSwiper) usage with a
small swiper-vue3.js wrapper, fix template/prop-casing issues, and
rework the product gallery mount so it survives #app's re-render
instead of getting wiped.

Fix category page dropdown/pagination/sorting/breadcrumb layout,
sidebar close button visibility, quick-view attribute table layout
(BS5's 3-level table selector needs a real <tbody>), wishlist sidebar
item stacking, "GO TO CART" navigation from both the add-to-cart popup
and the side cart, and grid/list view toggle spacing and behavior.

Fix mobile blog posts rendering as bare "details" buttons, product
image swipe/thumbnail-click for 2-image products, and category
breadcrumb alignment on mobile.

Fix the shopping cart REMOVE button: removing a row via its own delete
button reliably crashed deep inside Vue's Fragment/keyed-list unmount
internals, so the row is now hidden directly and the cart/totals state
is refreshed from the server response instead of relying on the
in-place reactive patch.

Fix the on-hold toggle in the cart items table rendering outside the
table: its label's ps-0 class removed the padding that Bootstrap's
form-switch negative margin depends on, pulling the switch out past
the column's left edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The mobile header-nav (currency/tax/language selector strip) was still
using the old fixed-bottom-bar pattern, independently of its
.left-side-container parent's own positioning - it stayed pinned to
the viewport bottom at all times, including once the user scrolled
into the real page footer, permanently overlapping footer content.
Drop the fixed positioning so it flows normally with the rest of the
header instead.

Hide the mobile "Filters" toggle (#mobile-filters-toggle) when
.generalLeftSide has no visible content besides the always-present
close button - previously it stayed visible even when a category has
no specification/rating filters, whose only child (category
navigation) is d-none below the lg breakpoint, so opening it revealed
an empty panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Removing its fixed-bottom positioning (previous commit) put it back in
normal flow as a sibling of the icon row inside the sticky mobile
header, whose flex column is bottom-anchored - so the icon row got
pushed down by the selector strip's height instead of staying at the
very top like before.

There's no other place for this strip on mobile, and it was already a
barely reachable fixed corner widget, so just hide it below the lg
breakpoint: restores the icons to the top and keeps the footer clear.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The footer's three <b-col> blocks only specified md/lg/xl breakpoint
props, with no bare cols prop - Bootstrap's col-md-*/col-lg-*/col-xl-*
classes only apply at those breakpoints and up, so below md (768px)
these divs had no grid class at all, shrinking to their content width
and packing to the left instead of stacking full-width. Their text was
centered via text-align, but the narrow column box itself sat off to
one side, making the whole About Us/Information/My Account blocks look
uncentered on phones.

Add cols="12" so they stack full width below md, same as the
footer-bottom row already does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The drawer footer renders flycart.SubTotal behind v-if="flycartindicator > 0",
but the count and the model are loaded independently - flycart only arrives when
the drawer is first opened. That held together only as long as every writer set
both at once; setting just the count made the footer render against a null
model, and because a Vue render error tears down the whole app, changing a
quantity on the cart page blanked the entire page.

Require the model as well, matching what the wishlist drawer already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every add-to-cart, compare, quick-view and bid button carried a hand-written
onclick="AxiosCart.someFunction('url', 'id', ...)" string, with routes and
localized text interpolated into JS source at each of 64 call sites. The
Vue-rendered variants were worse: they built that source by concatenation inside
a :onclick binding, so a single click went through two levels of escaping.

Buttons now declare what they do - data-cart-action plus data-url /
data-product-id / data-form / data-show-qty - and one delegated listener in
public.axios.js maps that onto the AxiosCart API. Delegation also covers markup
Vue renders later. The compare link text and the compare page URL moved to
window.grandRes / window.grandRoutes, emitted once by Partials/JsResources.

Fixed along the way:
- addbid built invalid JS; the template literal swallowed the form argument
- updateitem always read the quick-view form, which is a null lookup unless the
  modal is open, and ignored the selector it was given
- ScriptTagHelper had no asp-append-version support, so a cached theme script
  could keep running against markup that had already changed. The property is
  bool? to match the built-in ScriptTagHelper, which binds the same attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each product box worked out its own add-to-cart, wishlist and quick-view URLs,
and did it twice: once as a 20-line Razor prelude in CatalogProductView, and
again as string concatenation inside the Vue templates that render the catalog
listings. The ShowQty and ForceRedirectionAfterAddingToCart branching was
duplicated across both themes.

ProductOverviewModel now carries AddToCartUrl, AddToWishlistUrl and QuickViewUrl,
filled in once by GetProductOverviewHandler after the price model is known. That
removes 40 URL expressions from the views, including all URL building from the
client side.

Link generation goes through the current HttpContext so the {language} segment
used by SEO-friendly URLs is carried over from ambient route values. Without it
every link falls back to the default language - the existing Url property had
that bug too and is fixed here. The same pattern is still present in the
cart/wishlist handlers, which build ProductUrl without a context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each catalog listing wrapped its add-to-cart and wishlist buttons in a
v-if="!Model.ShowQty" / v-else pair whose only purpose was to pick between two
hardcoded URLs. Now that the model carries the URL, both arms render the same
thing, so eight of the ten pairs collapse to a single button.

The two pairs left in Theme.Modern stay: there the ShowQty arm really does
render an extra text label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The read-only rating repeated the same 200-character <b-form-rating> tag in four
views, twice with id='rating-inline2' inside a loop over reviews - so a customer
with several reviews got duplicate ids. Partials/Rating.cshtml now owns that
markup; the id is dropped because nothing referenced it.

A partial rather than a ViewComponent: the rating needs no services, and a
partial is both cheaper (measured on /sitemap with 5000 invocations: 1.6us per
partial against 2.7us per ViewComponent) and equally overridable by a theme,
which is what Theme.Modern already does for CatalogProductView itself.

Ratings bound to Vue state are untouched - a partial renders once on the server
and cannot follow a reactive value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vue-cli and eslint 8 are both end-of-life and accounted for all 28 npm audit
findings (18 high); production dependencies were already clean. The toolchain is
now vite + eslint 9 flat config: 164 packages instead of 581, and 6 findings, all
from one brace-expansion advisory whose latest published 1.x is still flagged and
which minimatch@3 under eslint pins us to.

Two constraints the config has to honour, neither obvious:
- the `vue` alias must resolve to vue.esm-bundler.js. Templates are the Razor
  markup itself, compiled out of the DOM at runtime; the default runtime-only
  build renders nothing.
- output must be IIFE. The bundle is loaded by a plain <script src> and assigns
  window.Vue / window.bootstrap / window.axios. IIFE cannot code-split, so the
  vendor chunk is gone and both themes' Head.cshtml now load a single file - the
  entry is only 15 KB, so little caching value is lost.

Also fixes a live bug found while measuring: `vue-cli-service build` emptied
wwwroot/bundles first, deleting style.min.css and style.rtl.min.css - the
production stylesheets Head.cshtml serves outside Development. Nothing generated
them, so they were hand-maintained and had already drifted once (bc5b81b
changed header.css and left them behind). They are now built from the same six
sources, in Head.cshtml's cascade order, and emptyOutDir is off. The regenerated
file differs from the committed one only in minifier style (:first-child vs
:nth-child(1), selector order inside groups); no rule was lost.

Verified: bundle boots with all globals set and no uncompiled templates, icon
fonts resolve under /bundles/fonts/, both production stylesheets serve, matrix
38/38 across both themes and three viewports.

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

Dark mode was applied from app.js on Vue's `mounted`, so every page painted light
and then flipped, and it only ever worked in the default theme - Theme.Modern
stayed light even with an explicit choice stored, because its #app carried no
dark-theme binding and it had no switch at all.

Partials/Rating aside, this is the first shared partial both themes render:
Partials/ColorScheme runs inline in <head> and resolves the scheme before the
first paint. localStorage now holds only an *explicit* choice; while it is absent
the OS preference wins and is followed live, which is what a visitor running a
dark desktop expects on a first visit. body and #app do not exist that early, so
a short-lived MutationObserver tags them as soon as they are parsed - without it
the ~330 hand-written [dark-theme=true] rules would still flash.

`darkMode` is seeded in data() rather than mounted() in both themes' app.js: the
watcher must not fire for the initial value, or it would persist a choice the
visitor never made and silently stop following the system.

Bootstrap 5.3's data-bs-theme is now driven too, so its own components theme
themselves - that is what makes Modern dark without writing dark CSS for it.

Verified with the matrix (56 checks): every "colour scheme is light, expected
dark" failure is gone in both themes, for an explicit choice and for an emulated
prefers-color-scheme: dark with nothing stored. Clicking the switch flips the
scheme, persists it, and keeps Vue in sync.

Still to do: Modern hardcodes light colours that now fight Bootstrap's dark
surfaces (black text on #212529, #dee2e6 text on white); the matrix lists them.

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

Modern hardcoded #fff backgrounds and #000/#1c1b1b text in 90 rules. Once
data-bs-theme started theming Bootstrap's own surfaces, those fixed values fought
it: black text landed on Bootstrap's dark #212529, and Bootstrap's light #dee2e6
text landed on backgrounds Modern forced to white.

They now read var(--bs-body-bg) / var(--bs-body-color) (and var(--bs-secondary-bg)
for the #eaf0fa panels), so they follow the scheme instead of needing a dark twin
rule. Light rendering is unchanged - those variables resolve to the same values
there, which the matrix's light passes confirm.

Deliberately left alone: black text on a coloured button (.btn-info, .btn-light),
which Bootstrap keeps light in both schemes, and white decorative shapes drawn
over an overlay (page loader, back-to-top arrow, parallax banner).

Matrix is 56/56 across both themes, three viewports and three colour schemes
(light, explicit dark, dark from the OS preference). The contrast probe catches
both directions, so a rule that had gone too dark in light mode would have failed
the light passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported: quantity fields and dropdowns showed values that were barely visible.
Two separate causes, both invisible to the verification matrix because its
contrast probe only sampled elements with their own text node - <input> and
<select> render a value instead, so form controls were never measured.

Theme.Modern: .custom-select used the `background` shorthand for its arrow, which
resets background-color to transparent. A <select> paints its option list with the
UA default rather than any ancestor backdrop, so the themed light text sat on
white in the popup. It now carries var(--bs-body-bg), a border that follows
--bs-border-color instead of a near-black #1c1b1b, and a light arrow under
data-bs-theme=dark since the SVG is a data URI and cannot inherit currentColor.

Default theme: `[dark-theme=true] .form-select, input.form-control` forced a
#efefef field. That paired a light field with the dark text the old hand-written
rules supplied; once the text colour came from --bs-body-color it turned
light-on-light at 1.13:1. Bootstrap 5.3 themes these controls itself, so the
override is gone - in the RTL twin too, which is generated into style.rtl.min.css.

The matrix now measures form controls, and flags any <select> whose own background
is transparent - a structural check, because walking up to an ancestor background
would have called the broken popup fine. 56/56.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Advancing a checkout step called scrollToSection, which scrolled to the top of
#checkout-steps. The steps begin below the "Checkout" heading, so every step
change pushed that heading off the top of the screen.

It now targets .checkout-page, which wraps both the heading and the steps, and
subtracts the header height when .header-nav is actually sticky or fixed - below
991px it is meant to be, and a static header overlaps nothing. The position is
read from getBoundingClientRect + scrollY rather than offsetTop, which was
measured against .cart-view (a positioned ancestor) and so was not a document
coordinate at all.

public.checkout.js also gained asp-append-version; without it the cached copy
keeps running against changed markup, which has cost debugging time before.

Verified at 1440 and 414: the heading ends 12px below the top instead of
scrolling away. Matrix 56/56.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
public.axios.min.js and public.common.min.js were last touched in December 2023
and are stale minified copies of the scripts next to them. Nothing loads them:
no view, no C# and no JavaScript mentions them, and no code builds a ".min" path
at runtime the way the stylesheets have a Development/Production split.

The generated style.min.css / style.rtl.min.css are a different thing and stay -
Head.cshtml serves them outside Development, and the Vite build now produces them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Views have written asp-append-version="true" on <link> for a while and no
stylesheet was ever stamped. The attribute was not ignored by accident: the
built-in LinkTagHelper binds it too, but it only stamps a plain href, and these
links carry asp-src, so there was nothing for it to act on. Our helper never had
the property at all.

It now mirrors ScriptTagHelper - IFileVersionProvider against the request
PathBase, bool? because the built-in helper binds the same attribute and a plain
bool breaks Razor compilation. The theme stylesheet links in both themes' Head
now pass the attribute, so a CSS change reaches a returning visitor instead of
losing to a cached copy. Verified: /theme/css/common/common.css?v=... serves 200.

Known limit: plugin content is not stamped. IFileVersionProvider resolves against
WebRootFileProvider, while /Plugins is served from a separate PhysicalFileProvider
(UseGrandStaticFiles), so those paths are returned unchanged - Theme.Modern's own
stylesheets still rely on cache headers. The attributes added there are harmless
and become effective if plugin paths are ever made resolvable. Composing the
plugin provider into WebRootFileProvider would fix it but would also expose the
content root to the default static-file middleware, so it is deliberately not done.

Matrix 56/56.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The badges on the orders list painted a status name onto a pale tint - #d1ecf1,
#fff3cd, #d4edda, #f8d7da - while Bootstrap's .badge sets --bs-badge-color: #fff.
White on those tints is roughly 1.2:1, which is why the statuses were hard to read.
Dark mode had a separate override forcing #212529 on top, so only dark looked right.

They now use Bootstrap's subtle/emphasis variable pairs (info, warning, success,
danger), which ship an accessible foreground and flip with data-bs-theme; the dark
override is deleted as redundant. Measured in both themes and both schemes: every
badge is now between 7.15:1 and 10.8:1.

The class comes from the order status *name*, which is editable in admin, so a
renamed or custom status matches none of the four rules and previously rendered
white on nothing. The base .status.badge rule now carries its own secondary
background and foreground, so an unrecognised status stays readable (13.0:1
light, 8.8:1 dark).

Applied to both themes and to the RTL twin that feeds style.rtl.min.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reservations for day products are generated at the store local midnight and
persisted as UTC, so their stored time of day carries the UTC offset, while the
dates posted by the datepicker are always midnight. Booking compared the two for
exact DateTime equality, so in any timezone other than UTC no period could ever
be matched and every add-to-cart failed with "No free bookings in this period".

Compare on the date part instead, which is what the calendar already shows the
customer - the storefront derives the free days from the date part of the same
UTC values. This also stops the period filters from dropping the last day of a
range. The loop was copied in three places, so it now lives in one helper.

Also guard the reservation datepicker callback: it wrote the response into
#sku/#mpn/#gtin and .actual-price without checking they exist, throwing
"Cannot set properties of null" on products that render none of them, and
never updating the price when it has no discount markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Vue 3 countdown shim rendered its slot as a bare fragment, but
@chenfengyuan/vue-countdown always wrapped it in a span and the theme hangs
the whole countdown on that wrapper: ".countdown-box > span" lays the parts out
in a row and "> span span" puts the ":" between them. Without it the numbers
were five separate spans, so the row collapsed and the separators disappeared
entirely. Render the slot inside a span again.

Bare numbers also read poorly, so each one now carries its unit. The separator
no longer trails the "Time left" label, and the rtl sheet no longer emits a
fourth separator for three gaps.

The units are resources rather than literals: added to DefaultLanguage.xml for
fresh installs and to en_240.xml with a migration for stores coming from 2.3.
Stores already recorded as 2.4 are past that migration and need them imported
by hand, since GetCurrentMigrations only runs versions strictly newer than the
database's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KrzysztofPajak and others added 26 commits August 5, 2026 11:30
core.js conflated two things under one misleading name. The island
machinery is how the storefront runs today; only the rest is owed to
Vue 2 era templates. Separating them makes the remaining debt countable.

runtime/islands.js gets what is architecture: mountIslands,
createStorefrontApp, registerComponent, getRootVm/getIslands, the
onAppCreate/onBeforeRootMount/onRootReady hooks and bringUpIslands.

compat/ keeps only what Razor templates force, each stating what would
retire it: globals.js (the window fall-through Proxy that 68 .cshtml
files depend on, plus foldProps), view-model.js (Options-API shaped
reactive state, and the cross-island $refs proxy), vue-global.js (the
window.Vue surface the theme script files call). Dependencies run one
way, no cycles.

new LegacyVue({...}) becomes createViewModel({...}) at all 27 call
sites, and LegacyVue.component becomes registerComponent. Drops $mount,
$watch and $bvModal, none of which had a caller - window.$bvModal was
never assigned, so that getter always returned undefined. The
new Vue({ el }) guard stays: it is a diagnostic for plugin authors, not
dead code.

createViewModel now applies foldProps, which makeStateVm did not, so
root-level props are no longer silently dropped - Theme.Modern's
home.js declares its whole home-page model that way and was getting
undefined.

Also retires a BootstrapVue leftover: both SearchBox views sent
$root.$emit('bv::hide::tooltip') into a $root that has no $emit, so
every touch on the voice-search button threw. They now call
hideTooltip($event.currentTarget), published next to the delegated
Tooltip in main.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `window` fall-through Proxy in compat/globals.js exists for one reason: 68
.cshtml files address their view-model by bare global name, and Vue 3 has no
`with(this)`. Retiring it by moving those templates into components would mean
giving up server-rendered markup and the @loc strings baked into it - the
opposite of the direction the catalog listings took.

An island can now declare what it needs instead: `vue-island="contactus"` mounts
with the shell plus that view-model layered on top, so the template resolves the
name as ordinary component data and needs no fall-through. The markup stays in
Razor, unchanged apart from the attribute.

The layering is a Proxy over the shell rather than a copy. `{ ...shell }` would
hand each island a private snapshot and the drawers would stop agreeing on the
cart; reads fall through to the shell and writes still land on it, so sharing is
exactly as before. Verified with the global deleted: the binding kept working
from island data, and a darkMode write still reached window.vm.

Degrades safely in both directions. An island that declares nothing behaves as
it always did, and a name that is not in the registry logs a warning and falls
through to the window Proxy - i.e. to today's behaviour - because the data
proxy's `has` trap reports the key as absent and Vue moves on to
globalProperties.

Registration goes through views/state.js, which builds 26 of the 47
[data-grand-vm] islands, so most of the remaining view-models become declarable
without further changes. Contact and apply-vendor declare theirs here; the rest
follow one at a time.

Verified on /contactus: island mounted, binding proven independent of the
global, form submits, and home, category and product pages - none of which
declare anything - still mount every island with a clean console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five more names off the window fall-through Proxy, using the mechanism from
87a7302. Which island roots to mark was not guessed: the raw server HTML was
scanned for attributes mentioning each global and walked up to the nearest
[vue-island], because Vue strips the directives from the DOM once it compiles an
in-DOM template, so the live page cannot answer this.

Each is proven the same way - delete the global on a live page, write through
the island's data and watch the input follow:
  searchbox               #searchBoxContainer      (on every page)
  askquestion             #contact-us-product
  productreviewsoverview  .product-review-box
  productreviews          .product-details-page and #ModalProductReview
  vmwishlist              #wishlistPage            (both themes)

productreviews is the interesting one: it is declared on two islands, which
share the one registered object rather than getting a copy each. Submitting a
review from inside the modal moved the count on the page behind it, 1 -> 2.

Verified beyond the bindings: search autocomplete still returns results and
renders its dropdown; review submission succeeds end to end; home, category,
cart, compare and wishlist mount every island with a clean console. Theme.Modern
shares the wishlist page, so it is marked too.

Note the shape of the search-box change - the marker sits on the container, not
on the <validation-observer> below it. A component tag cannot be a mount point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scan in 8f79cb8 walked the parsed document with querySelectorAll, which
never enters a <template>: its children live in a DocumentFragment, not in the
tree. Every reference written inside `<template v-for=...>` was therefore
invisible, and that is most of the compare page.

Rescanned recursively, descending into template.content. Two findings:

- compare products reference `specificationAttributes` from inside nested
  <template v-for> blocks, on `.compare-products-page` in both themes;
- `.product-review-box` reads `productreviews` as well as the overview model it
  already declared, and had been resolving that one through the window Proxy.

Both are declared now. Verified on the compare page with the global deleted:
trimming the model through island data collapsed the specification rows 5 -> 1,
so the table renders from the island, not from window.

Not touched, deliberately: `voicenavigator` and `vendorreviewsoverview` render
nothing in this store - voice navigation is off in the settings and the vendor
review block did not appear - and neither partial carries its own island marker,
so their enclosing island would have been a guess. `merchandisereturns` sits on
a page behind the login. Left on the Proxy until they can be checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Left on the window Proxy in cc19042 because the page sits behind a login and
behind a delivered shipment - IsMerchandiseReturnAllowedQueryHandler only lets an
order through once a shipment carries a DeliveryDateUtc, so none of the seed
orders offered the button. With a returnable order to hand, the page renders and
`.return-request-page` is the only island holding the seven references.

Proven the same way as the rest: with the global deleted, writing through the
island's data moved the quantity select "" -> "1" -> "0". The first attempt read
as a failure and was not - the model took the value, but the item's dropdown only
offers 0 and 1, so setting "2" correctly left nothing selected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_Layout renders <vc:voice-navigation> straight into <main>, which deliberately
carries no island so that a broken block takes down itself rather than the whole
page body. The component's root is a bare <template v-if="voicenavigator.allowed">,
and markup no island compiles renders as *nothing*: no error, no element, nothing
in the DOM to show for it. The voice button has been dead since <main> was split.

It stayed invisible because the feature is off in the seed store. With
VoiceNavigation enabled the block still did not appear, which is what exposed it.

Wrapped in a plain div carrying the island, because the <template v-if> cannot be
the mount point itself - Vue reads a mount point's innerHTML, so a directive on it
is ignored. The [data-grand-vm] script stays outside the wrapper: initViews() has
consumed it before this mounts, and Vue would otherwise re-render it.

The view-model is declared on the wrapper rather than left to the window Proxy,
so this lands as part of the same migration. Verified with the global deleted:
toggling `recording` through island data opened the alert, put the focus class on
the button and closed it again. #voicePageButton is now in the DOM on every page,
where before there was no element at all; home, category, product, cart and
contact all still mount every island with a clean console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same failure as 13ceb5c, found the same way. Nothing on the vendor page header
carries an island, so this partial was markup no island compiled: its
<template v-if> rendered nothing and <b-form-rating> stayed an unknown element -
no error, no stars, no review count.

It hid behind a per-vendor field rather than a store setting. The block only
renders when a vendor has AllowCustomerReviews set, and both seed vendors had it
off, so nothing on the page hinted the markup was dead.

The partial's own root takes the marker: its itemprop attributes are Razor's and
it carries no Vue directive, so it is a valid mount point. Both themes use this
one file - Theme.Modern's Vendor.cshtml resolves the same partial.

Verified with the global deleted: writing the model through island data moved the
block from "0.00 Be the first to review this vendor" to "5.00 4 review(s)" and
recomputed `rating` 0 -> 5. The component resolves now - five stars in the DOM
and no leftover <b-form-rating> tag. Vendor, category and home mount every island
with a clean console.

That closes the small view-models: only the catalog and vmorder clusters, and the
shell itself, still reach through the window Proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The biggest consumer of the window Proxy, 35 files, and it needed none of the
architectural work I had started and reverted earlier. Taking `catalog` off the
fall-through is a declaration like any other; rebuilding how the listing renders
is a separate question that this does not touch.

The earlier scan reported `catalog` in "NO ISLAND" on every page, which sent me
looking for a problem that was not there - plain substring matching also hits
URLs and class names containing "catalog.". Matching /\bcatalog\.[A-Za-z]/
instead gives a clean answer, the same on all five listing pages: the listing
island and the left column, plus the vendor page's tabs block.

Declared on `#catalog-products` in all six layouts, on the vendor page's
`.col-12` tabs island (it renders catalog.Model.Description), and conditionally
on `_TwoColumns`' left column. Conditionally because that column is shared with
every other two-column page and only the catalog layouts define the `filters`
section that reads the view-model; declaring it unconditionally would ask for a
view-model that is not there and log a warning per page. Verified on /blog: the
attribute renders empty, no warning, all islands up.

Verified with the global deleted: the listing and the sidebar resolve the *same
object*, not copies - which is what keeps filters and products in step - and the
client takeover ran entirely through island data, flipping clientRendered and
rendering the products. Through the UI: sorting A-Z then Z-A reversed the five
notebooks exactly, and a specification filter cut the list 5 -> 2 with the
"currently filtered by" panel appearing. Brand, vendor and search pages all mount
every island with a clean console.

Theme.Modern is not covered here - its #catalog-products divs carry no island
marker at all, so its roots are elsewhere and it is not the active theme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Modern's roots are not Grand.Web's: its #catalog-products divs carry no island
marker at all, and the markers sit on the page wrapper instead - .catalog-island
for brand, collection and tag, .page.category-page, .page.search-page,
.page.vendor-page - plus the left column in its own _TwoColumns, conditional on
the `filters` section for the same reason as the default theme.

Modern also builds its view-model in a separate module, views/catalog-modern.js,
which the first pass missed. The warning that produced is worth keeping in mind
as evidence the fallback works: both islands reported an unknown view-model and
then rendered correctly anyway, through the window Proxy, with all twelve islands
up and ten products on screen.

Verified with the global deleted: the page island and the sidebar resolve the same
object, and sorting Z-A to A-Z through the select reordered the model and the DOM
matched it exactly. Brand, vendor and blog pages mount every island with a clean
console; blog renders the sidebar's declaration empty, as intended.

Modern has no `clientRendered` - it draws every product through Vue and has no
server-rendered first page to hand over from. That difference is unchanged here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking a date for a reservation product in the quick-view modal threw
"Failed to construct 'FormData': parameter 1 is not of type 'HTMLFormElement'"
from Reservation.onDatePickerSelect, and the exception landed before the axios
call - so the price, SKU and availability refresh that the handler exists to do
never ran at all.

The cause is the pair of ids. A product page renders
`product-details-form-<productId>`; quick view renders the same form under the
bare `product-details-form`. onDatePickerSelect only ever looked for the
suffixed one, found nothing in the modal, and handed null to FormData.

AxiosCart already knew this rule and applied it in two places, so the fix is to
stop keeping three copies of it: theme/product-form.js now owns
activeProductForm(), and addproducttocart_details, updateitem and
onDatePickerSelect all call it. The reservation path also gets the null guard the
cart paths already had, so a missing form skips the refresh instead of throwing.

Same family as the bug in the memory notes: the modal is always in the DOM now -
BootstrapVue's b-modal only rendered its content while open - so code written
then can be wrong about which form is on screen.

Verified in the quick view: both dates selected, no console errors, and
productdetails_attributechange now returns 200 where nothing was sent before. On
the full product page: date set and the same request fires. Add to cart still
works from both the modal and the product page, which is what shares the helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Takes the last big name off the window fall-through Proxy in
compat/globals.js. All 18 views that address `vmorder` are partials
rendered inside two roots, and both already carried a bare `vue-island`,
so naming those roots covers the lot.

The checkout `vmorder` is built by public.checkout.js, which is not part
of the bundle, so registerViewModel is exposed on the window.Vue facade
for it. That script is asp-order 300 and the theme app.js that mounts the
islands is 900, so the registration always lands first.

Verified by deleting window.vmorder and mutating a rendered property on
both pages: the DOM still updates, so the templates resolve the name from
the declaration rather than the Proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
displayPopup did `this.$refs[el].show()`, a BootstrapVue `<b-modal ref>`
reaching for a component instance. The partials it renders carry no `ref`
at all and a Bootstrap 5 modal is a plain element with no `.show()`, so
the lookup was undefined, the `if (modal && modal.show)` guard swallowed
it, and the privacy-preference and newsletter-category popups mounted
into the DOM without ever opening.

Query the modal out of the container rather than by id: displayPopup
gives the container the same id as the modal inside it, so
getElementById hands back the wrapper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These carried Vue directives but no `vue-island`, and `<main>` carries
none either, so nothing ever compiled them. Theme.Modern already had the
marker on its equivalents - only the default theme was missing it.

The damage was invisible, which is how it survived: an uncompiled
`<template v-if>` renders nothing at all. The blog post page advertised
"Comments (1)" and showed none - the view-model held the comment, the
list simply never rendered. News items had the same comment list.
Password recovery and the wishlist e-mail form lost their whole
client-side validation, with `{{ errors[0] }}` sitting raw in the DOM.

Same class of bug as the voice navigation and vendor rating fixes: a
component tag outside an island stays an unknown element and a
`<template>` disappears, neither of which raises anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every view-model name a template binds is now declared on the island that
renders it, so it resolves as ordinary component data instead of through
the window fall-through Proxy in compat/globals.js.

Most of it was naming islands that already existed: the account forms,
the modals for order notes, e-mail-a-friend and wishlist e-mail, and
Theme.Modern's twelve home-page sections, which had been sharing the one
#home-page island and now each carry their own.

Three view modules published to `window` without registering - comments.js
(blog, news), country-state-form.js (customer) and contact-form.js
(contactvendor, emailafriend). They register now; the console warning is
what found them.

The vendor tabs build their declaration conditionally, like _TwoColumns
does: the reviews and contact tabs are optional and declaring a
view-model that was never registered warns on every other vendor page.

`productGallery` is deliberately not declared - Theme.Modern's app.js
reads it off `window` and no template binds it.

This does not retire the Proxy yet: 23 template expressions still reach
it for `vm`, `window` and `bootstrap`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With AllowUsersToChangeEmail off, Account/Info renders the email input readonly
but still validated it with `required|email`. An address that fails the format
rule then locks the page: the rule rejects the value, and the visitor has no way
to change what it rejects, so the account can never be saved again.

That state is reachable today because the same field is validated differently
depending on where you meet it - Register.cshtml uses `required` alone, so an
address the account page will later reject can be registered without complaint.
This is what a customer hit with an address ending .pl1.

The `email` rule is now applied only while the field is editable. Three views had
the same shape and all three are fixed: Account/Info, Account/Partials/EditSubAccount
and Shared/Partials/CreateOrUpdateAddress (which keys off DisallowUsersToChangeEmail).
`required` stays - the value is server-supplied, so it never fires, and it still
means something on the editable path.

Not changed here: Register still validates with `required` alone. Adding `email`
there would make the client stricter than the server, which accepts these
addresses, so aligning the two is a separate decision.

Verified with the setting switched on, so the field is editable and the rule
applies: the markup renders rules="required|email", .pl1 shows "Wrong email", and
correcting the address clears it - which is the way out that did not exist before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pattern ended `\.[A-Z]{2,4}$`, so every address on a longer TLD failed -
.online, .travel, .museum, .company, .digital. Not a corner case: the rule backs
the contact form, ask-a-question, email-a-friend, addresses, the account page and
sub-accounts, so those customers simply could not submit any of them.

Found while tracking down a different report on the same field and confirmed in
the browser: someone@example.online was rejected with "Wrong email" before the
change and accepted after.

The cap is gone; the TLD is still letters-only, so a domain with a digit in it -
the .pl1 address from that report - is rejected exactly as before. Verified on
the account page: .pl1 still errors, .online and .museum pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by scanning every listing, account and catalog page for identifiers that a
template uses as an expression root but its island does not declare - the ones
still reaching through the window Proxy. Only two real gaps were left, both on
the vendor page: the rating summary, which declared the overview model but also
reads vendorreviews, and #ModalVendorReview, which declared nothing at all and
uses it ten times.

The view-model itself needed no work: it is built by the shared `reviews` module
under the name "vendorreviews", which already registers what it creates.

Verified with the global deleted: the modal resolves vendorreviews from island
data, the overview still renders its five stars - outlines, because the vendor
has no reviews yet - and all fifteen islands mount with a clean console.

Two things the scan flagged that are not Proxy users, recorded so the next sweep
does not chase them: `attribute` belongs to the component defined in
<template id="product-attribute-quickview">, which is registered through the
inDomComponents island and correctly lives outside every island; `$event` is
Vue's own inline-handler argument.

That leaves `vm` - the shell, referenced by name from templates - as the only
thing still resolved by the fall-through.

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

Everything a Grand.Web template names now comes from somewhere explicit, so the
fall-through resolves nothing on that theme. Three pieces:

`vm` - the shell's own name in the templates - is layered into every island's
data rather than being found on window. Islands with no declaration get it too,
which is why they all go through the layering wrapper now.

The globals templates genuinely call are installed on globalProperties in
main.js: document, window, location, bootstrap, hideTooltip. Vue's allowed-globals
list covers Math and JSON but not document or window, so `document.getElementById`
in a @click was reaching them through the fall-through like everything else.
hideTooltip became a declaration instead of a window assignment so it can be
handed over; it stays on window for markup outside a Vue expression.

`standardProductAttributes_<productId>` is registered and declared. It was the
last Grand.Web holdout and no fixed-page scan would have found it - the name
carries the product id, so it only appears on a product that has attributes. The
partial takes its own island because a grouped product renders it once per
variant, each needing its own name.

The fall-through itself stays, because Theme.Modern still needs it, and now warns
once per name with the island that wanted it. That instrumentation is the point:
removing it while a theme still depends on it is not a soft landing - an
undeclared view-model stops being silently fine and throws "Cannot read
properties of undefined" out of the render function, taking the island down. I
removed it first and found that out on Modern, which is why it is back.

Modern's remaining list, straight from the warnings: searchbox, productreviews,
vendorreviews, contactvendor, standardProductAttributes_*, plus subcatslider and
localStorage from a static scan - subcatslider is built in an out-of-bundle theme
script, so it needs Vue.registerViewModel, and localStorage belongs in main.js
with the other globals.

Verified: Default theme sweep over home, category, product, vendor, reservation
and the attribute-heavy product page reports nothing at all, and changing an
attribute still reprices $1,315.00 -> $1,300.00.

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

The console list from 4fe6cfa, cleared. Four of these are ordinary
declarations; two are not, and neither could have been guessed from Grand.Web's
shape.

localStorage is not a view-model. Modern's Selectors call
localStorage.setItem('catalogProdCols', ...) from a @click, so it joins document,
window and location as an installed global in main.js.

subcatslider is built by Content/script/catalog.js, which is not part of the
bundle, so nothing in views/ can register it. It calls Vue.registerViewModel
itself - the reason that was exposed on window.Vue - and the category page
declares the name. Ordering holds: the theme's scripts run before app.js, and
app.js is what calls Vue.shell() and mounts the islands.

Two roots could not take the marker and needed a wrapper instead:
- the search box is a <template id="searchBoxContainer">, and a template's
  children live in a DocumentFragment, so it cannot be a mount point - the marker
  goes on the <li> above it. Grand.Web uses a <div> here, which is why its copy
  looked simpler.
- the reviews view starts with <template v-if> and renders a modal whose root
  carries :dark-theme; Vue ignores bindings on a mount point, so putting the
  island there would have silently killed that binding. Both are wrapped in one
  .product-reviews div instead.

Product attributes get their own island per product, same as Grand.Web: the name
carries the product id and a grouped product renders the partial once per variant.

Verified on Grand.Web, which shares the bundle: localStorage is installed, the
attributes island mounts, and home, category, vendor and the attribute-heavy
product page report nothing.

**Theme.Modern itself is not verified yet** - its views compile into the plugin
DLL and the running IIS Express instance still has the old one. The fall-through
is still in place, so a wrong declaration here degrades to a warning rather than
a broken island; the console after a restart is the check that matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Proxy that forwarded any unresolved template identifier to `window` is gone.
It was the last thing standing between a Razor template and Vue 3's ordinary
scoping: with it, every global happened to work, so an island that never declared
what it rendered against looked exactly like one that did. Two blocks had been
rendering nothing for weeks behind that - the voice-search button and the vendor
rating summary - and neither showed a symptom until a feature flag was switched
on.

Everything a template names now comes from one of two places: a view-model
declared on its island, or an explicitly installed global (document, window,
location, localStorage, bootstrap, hideTooltip). globalProperties is a plain
object again, and Reservation, AxiosCart and the rest no longer leak into
template scope just because they exist on window.

Verified without the fall-through, both themes, after clearing the warnings it
had been reporting:
- Modern: category with a subcategory slider (3 slides, 12 products), product
  page with the review modal's :dark-theme binding still live, vendor page with
  its five rating stars, search, cart - every island mounts, console silent.
- Default: category, product, the attribute-heavy product, vendor and cart -
  same. Search autocomplete returns results, add to cart still moves the counter.

Doing this in one step would have been a mistake, and was: removing it before
Modern was declared turned silent misses into "Cannot read properties of
undefined" thrown from render, killing the islands. Restoring it with a warning
per resolved name turned the remainder into a list, and the list is now empty.

compat/globals.js is down to foldProps, which goes with the last root-level
`props` in an out-of-bundle theme script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were dormant - nothing on the storefront triggers either today - and both
would have given a wrong answer silently rather than failing, which is the kind
that gets found by a customer.

exact_length ignored its parameter. The body only tested for non-empty, so
`exact_length:3` behaved exactly like `exact_length:1` while the rule's own
message promised "Select at least {0} option". Harmless right now because all
four call sites - customer and address attributes - pass 1. It now measures
length, with the count taken from the parameter. Checked both ways: the existing
`:1` groups behave as before (unchecked errors, checked clears), and a probe with
`:2` correctly rejects one ticked box where it used to accept it.

veeGetMessage looked its field up with document.getElementsByName(...)[0] - the
whole document, first match wins - so two fields sharing a name would answer with
each other's message. The page and the quick-view modal render the same forms, so
this is the same shape as the FormData bug in 7276256; it does not misfire only
because no *validated* name is currently duplicated. The lookup is now scoped to
the provider's own element, with the document kept as the fallback for
window.vee_getMessage, which is called from outside the bundle. A probe with two
providers sharing a name now gives each its own message; before, both showed the
first one's.

The rest of the file is left alone deliberately. It reads the DOM rather than the
bound model, which is what makes it a shim rather than idiomatic Vue 3, but it is
carefully written, documented, and frozen by roughly 400 validation tags across
49 views. Replacing it with vee-validate 4 would trade a working 319 lines for
touching all of those, which is a bad deal at this point.

Verified on the real forms: contact reports its own "Enter email"/"Enter your
name" from data-val-*, login reports "Please enter your email", a malformed
address still says "Wrong email" and .online passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting it in 29b13b1 was premature. Two things broke in normal use and a
customer found both, not me: the address form threw 27 render errors on
/account/addressadd, and the checkout's payment step threw on "Continue".

The sweep that justified the removal had a hole. It matched identifiers followed
by `.` or `(` - things used as objects or called as functions - so a bare
identifier passed as an argument was invisible to it. That is exactly the shape
of `vmorder.setDisabled(event)`, where `event` had been resolving to the legacy
`window.event` through the fall-through. Nothing behind a login was swept either,
which is where the address form lives.

Three real fixes, all worth keeping regardless of the Proxy:

- Partials/CreateOrUpdateAddress declares its view-model on its own
  `.edit-address row`. The name carries the field prefix and six hosts use five
  different ones, so declaring it in the partial is automatically right for all of
  them - account, checkout billing and shipping, merchandise return, and
  Theme.Modern's account views, which render this same file. The marker goes on
  that existing row rather than a new wrapper, which would sit between .row and
  its .col-* children and break the grid.
- Checkout/Start uses `$event`, Vue's own inline-handler argument, instead of the
  bare `event` it inherited from Vue 2.
- `vm` is installed as a global property, not only layered into island data. The
  popups displayPopup renders are made with Vue.createApp and are not islands, so
  they never got the layering; a getter, because the shell does not exist when the
  first apps are created.

The fall-through is back in warning mode until a sweep that covers signed-in
pages, both themes, and identifiers in every position comes back empty. Verified
after restoring: quick view on Build your own computer reprices $1,315 -> $1,300
with a silent console, and the address form mounts with its 238 countries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marking .edit-address as an island in eefccce put the address fields in a
different Vue app from the <validation-observer> that wraps them in the host
view. provide/inject does not cross app boundaries, so every
<validation-provider> inside failed to register: the observer held zero fields
and handleSubmit answered "valid" no matter what the form contained.

Measured before the fix, on /account/addressadd with a required field emptied:
providers registered 0, observer.validate() -> true. An empty required address
went through. Individual fields still flagged themselves on blur, which is why
this looked like it worked when I checked that the island mounted and the inputs
rendered - the part that broke is only visible from the observer's side.

After: 13 providers registered, validate() -> false, and on checkout pressing
Continue with an empty first name keeps the step open and shows "First name is
required.", "Country is required.", "State / province is required.", "City is
required.".

The lesson is a rule, not a one-off: an island boundary is an app boundary, so it
must never be drawn between a component that provides and one that injects. The
view-model this partial binds to therefore has to be declared on whichever island
already contains the observer, in the host view - not here. Until that is done it
resolves through the window fall-through, which is why the fall-through is still
in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a vendor page, "Be the first to review this vendor" darkened the screen and
showed nothing. The modal was open - .show, display:block, one backdrop, body
.modal-open - and measured 0x0, because it is rendered inside the Reviews
<gn-tab> pane and an inactive pane is display:none. The backdrop is a sibling of
the page, so it covers everything; the modal it belongs to has no size.

The product page has the same markup and does not show the symptom only because
its trigger lives inside that pane too - you cannot reach it until the tab is
already open. The vendor page's trigger is in the rating summary, outside the
tabs, so it hit this every time.

addReview now shows the containing pane first when there is one and it is not
already active. Fixed in the shared review view-model rather than in either
view, because it is the modal's location that is unsafe, not the caller's: any
future trigger placed outside the tabs would have hit the same thing.

Pre-existing, not fallout from the island work - the only change this file has
had from me is the island declaration in 4be1b8f, and the modal has been inside
the tab since long before that.

Verified: on the vendor page the button now activates the pane and opens a
2560x1253 modal with the form visible, one backdrop, clean console; the product
page's own "Add your review" still opens its modal with the five rating stars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by a full regression sweep rather than by looking: Account/Reviews had no
island anywhere in its ancestry, so all 39 <b-form-rating> tags stayed unknown
elements. A component tag outside every island produces no error and no output -
the page simply showed no stars, and nothing on it hinted that anything was
missing. Same family as the voice-navigation button and the vendor rating
summary; this one was never behind a feature flag, it was just never looked at.

Now 195 star icons render and no raw <b-form-rating> is left.

The island also brings the review text into Vue's compiler, and one seeded review
contains "{{ dd}}". Without protection that gets read as an expression and
disappears - a customer's own words silently edited by the framework. The text
node carries v-pre, so it stays literal; verified both together, stars present
*and* the braces still on screen.

Theme.Modern has its own copy of this view and does not need the same change: it
renders no Vue construct at all, so an island there would be overhead. Checked
rather than assumed.

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

I reverted this change an hour ago after grepping Theme.Modern's Reviews.cshtml
for Vue constructs and finding none. The regression sweep then reported 39
unresolved <b-form-rating> tags on that exact page: the components arrive through
<partial name="Partials/Rating">, which the grep never opened. Checking the view
in isolation was the mistake - what matters is what the page renders.

Island plus v-pre on the review text, same as Grand.Web's copy in b5fa89c.

NOT verified in the browser: Modern's views compile into the plugin DLL and the
running instance still holds the old one. The Grand.Web equivalent was verified
(195 stars, no raw tags, "{{ dd}}" preserved), and this is the same edit to the
same shape of view, but it needs a restart plus a signed-in customer to confirm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 5, 2026 18:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@KrzysztofPajak
KrzysztofPajak merged commit ce5a54a into develop Aug 5, 2026
4 of 5 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/vue3-bootstrap5-migration branch August 5, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants