Sample record on the public demo pages of Shopify's Dawn theme. No person has tested these pages by hand. All 8 NVDA rows were run by a script on saved copies of the pages, and each shows what NVDA said.

Sample record on Shopify's Dawn theme

How this record was made

Made on 5 November 2026 by an AI assistant (Claude) working for Novruz Veliiev, using scripts. No person has tested these pages by hand. The issues and the patches are real. They come from the public demo store and from the Dawn source code, and the steps can be repeated.

A paid job is different. On a paid order a person tests your store by hand with a keyboard and screen readers, on your theme, and the record gives that person's results and dates.

I am a software tester, not a law firm. This is a technical record and not legal advice. It makes no promise about any case and makes no statement that any store meets any law or standard. It is not a statement about Shopify. Shopify did not make or endorse it.

What is on this page

Numbers from the scripts

What checked what
CheckDone by
WCAG A and AA rules at load, after scrolling, at 320 px and with menus openDone by: Tool: axe-core
Tab order, Tab stops, traps, menus and dialogs opening and closingDone by: Keyboard script in Playwright
Name, role and state at each stopDone by: Script reading the browser's accessibility tree
Focus ring, focus out of view, contrast over a photoDone by: Script comparing screenshots and reading positions
Sideways scroll at 640 px and 320 pxDone by: Script
What a screen reader saysDone by: Real NVDA, with a script sending the keys. No person listened.
Numbers from the scripts, public pages and local replay
MeasureHomeCollectionProduct
PageHome: /Collection: /collections/bagsProduct: /products/small-naomi-gummy
axe-core, WCAG A and AA rules, public page at load: rules failed (text nodes)Home: 1 (13)Collection: 1 (25)Product: 1 (9)
Same rules after scrolling through the page, local replayHome: 0 (0)Collection: 0 (0)Product: 0 (0)
axe best-practice rules failed, 1280 pxHome: 0Collection: 0Product: 0
axe WCAG rules failed at 320 pxHome: 0Collection: 0Product: 0
axe with the footer country selector openHome: aria-required-attr, aria-valid-attr-valueCollection: aria-required-attr, aria-valid-attr-valueProduct: aria-required-attr, aria-valid-attr-value
Keyboard stops by script, 1280 px and 320 pxHome: 38 and 35Collection: 47 and 41Product: 38 and 49
Stops with no accessible name, 1280 pxHome: 0Collection: 0Product: 0
Keyboard traps found by scriptHome: 0Collection: 0Product: 0
Sideways scroll at 640 px and 320 px, in pxHome: 0 and 0Collection: 0 and 0Product: 0 and 0
Images in the page and images with empty altHome: 25 and 9Collection: 33 and 16Product: 30 and 12

Six issues with patches

Each issue says how it was seen. "Scripted keyboard walk" means the Playwright script. None of these was found or confirmed by a person. The NVDA line under an issue says what NVDA spoke at that place when a script pressed the keys.

D-01: Enlarged product image has no keyboard route on a desktop

Place
Product page, gallery. Window 1280 by 900 for the missing route, width 320 for the focused image out of view.
What happens
A mouse user can click an image to magnify it. At 1280 px the script went from Skip to product information straight to the first color option, with 0 stops in the gallery. The button Open media 1 in modal is in the page for each of the 12 images, but has display: none on devices with a mouse. At 320 px the buttons can be reached, but the browser leaves a partly visible image where it is: 15 of 286 px of image 2 shows while its button has focus.
WCAG 2.1 criterion
2.1.1 Keyboard
How it was seen
Scripted keyboard walk, 38 stops at 1280 px. The CSS rule read in the Dawn source.
Fix
Keep the button in the tab order, let pointer events pass through it so hover zoom still works, size it like the image, and scroll a focused slide into view. Patch: 01-zoom-keyboard-route.patch.
After the fix
50 stops at 1280 px, 12 of them in the gallery. Enter opens the image dialog, focus moves into it, Tab stays inside it, Escape closes it and focus returns to the button. At 320 px, 286 of 286 px of image 2 shows while its button has focus. A scripted mouse click on the image still starts hover zoom and opens no dialog.
How the after state was checked
Local browser replay, keyboard script and axe with the dialog open (0 rules failed). Not checked on a real store.
NVDA
N-01 (after): Yes for the button and for Escape. Tab lands on the button and NVDA says Open media 1 in modal, button, opens dialog. Enter: NVDA says the name Media gallery and reads on from the Close button, but does not say the word dialog. Tab: Close, button. Escape returns to the button.
Show the code change for D-01
diff --git a/assets/global.js b/assets/global.js
index bde1d32..d4e6e35 100644
--- a/assets/global.js
+++ b/assets/global.js
@@ -749,6 +749,12 @@ class SliderComponent extends HTMLElement {
 
     if (!this.slider || !this.nextButton) return;
 
+    // The browser leaves a partly visible slide where it is when something inside it takes focus.
+    this.slider.addEventListener('focusin', (event) => {
+      const slide = event.target.closest('[id^="Slide-"]');
+      if (slide) slide.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+    });
+
     this.initPages();
     const resizeObserver = new ResizeObserver((entries) => this.initPages());
     resizeObserver.observe(this.slider);
diff --git a/assets/section-main-product.css b/assets/section-main-product.css
index c9dfc58..db10754 100644
--- a/assets/section-main-product.css
+++ b/assets/section-main-product.css
@@ -553,8 +553,11 @@ a.product__text {
   position: relative;
 }
 
+/* Sized like the image, so that focus scrolls the whole image into view. */
 .product__media-toggle {
   display: flex;
+  position: absolute;
+  inset: 0;
   border: none;
   background-color: transparent;
   color: currentColor;
@@ -1069,12 +1072,19 @@ a.product__text {
 }
 
 @media (hover: hover) {
-  .product__media-zoom-hover,
   .product__media-icon--hover {
     display: none;
   }
 }
 
+/* Keep the button in the tab order so keyboard users can open the image dialog.
+   Pointer events pass through it, so hover zoom works as before. */
+@media (hover: hover) and (min-width: 750px) {
+  .product__media-zoom-hover:not(:focus-visible) {
+    pointer-events: none;
+  }
+}
+
 @media screen and (max-width: 749px) {
   .product__media-zoom-hover,
   .product__media-icon--hover {

D-02: Keyboard focus can end up out of sight

Place
Product page at 1280 by 900: accordion buttons Dimensions and Care Instructions. Home page at 1280 by 900: product link Mini Eddy.
What happens
On the product page the information column is sticky. When it is taller than the window, scrolling cannot bring its lower controls into view: Dimensions sat at y=929 and Care Instructions at y=980 in a 900 px window while they had focus. On the home page, moving up to Mini Eddy put it at y=0 under the sticky header, and 0 pixels changed around it when it got focus.
WCAG 2.1 criterion
2.4.7 Focus Visible
How it was seen
Scripted keyboard walk. Position from getBoundingClientRect, the top element from elementFromPoint, and a pixel comparison of focused and unfocused screenshots.
Fix
Turn off the sticky position while keyboard focus is inside the column. Keep room for the header height when focus scrolls the page. Patch: 02-focus-stays-in-view.patch.
After the fix
Dimensions at y=570, Care Instructions at y=621, both in the window. Mini Eddy at y=484, not covered, with 574 pixels changing on focus at 4.74:1.
How the after state was checked
Local browser replay, same keyboard script. Not checked on a real store.
NVDA
No speech row. This is a visual issue.
Show the code change for D-02
diff --git a/assets/base.css b/assets/base.css
index 6ad490f..755e91f 100644
--- a/assets/base.css
+++ b/assets/base.css
@@ -231,6 +231,11 @@ body:has(.section-header .drawer-menu) .announcement-bar-section .page-width {
   overflow: hidden;
 }
 
+/* Keep room for the sticky header when focus or an anchor scrolls the page. */
+html {
+  scroll-padding-top: var(--header-height, 0px);
+}
+
 .skip-to-content-link:focus {
   z-index: 9999;
   position: inherit;
diff --git a/assets/section-main-product.css b/assets/section-main-product.css
index c9dfc58..cc5be32 100644
--- a/assets/section-main-product.css
+++ b/assets/section-main-product.css
@@ -27,6 +27,11 @@ product-info {
     z-index: 2;
   }
 
+  /* A sticky column taller than the window can hold the focused control below the fold. */
+  .product__column-sticky:has(:focus-visible) {
+    position: static;
+  }
+
   .product--thumbnail .thumbnail-list {
     padding-right: var(--media-shadow-horizontal-offset);
   }

D-03: Decrease quantity looks inactive but is not exposed as inactive, and its focus ring is faint

Place
Product page, quantity control, at quantity 1.
What happens
The button has the class disabled and looks dimmed. The browser's accessibility tree reports an ordinary enabled button, disabled=false. From the source, pressing it at the minimum changes nothing; the script does not press quantity buttons. Opacity 0.5 on the button also dims its focus ring, measured at 2.21:1.
WCAG 2.1 criterion
4.1.2 Name, Role, Value and 1.4.11 Non-text Contrast
How it was seen
Accessibility tree through the Chrome DevTools Protocol. Pixel comparison of focused and unfocused screenshots, highest contrast of any changed pixel.
Fix
Set aria-disabled together with the class, for both quantity buttons. Dim the icon only, not the whole button. Patch: 03-quantity-button-state.patch.
After the fix
The accessibility tree reports disabled=true. Focus ring measured at 4.74:1.
How the after state was checked
Local browser replay, same script. Row N-03 has what NVDA said.
NVDA
N-02 (before): Nothing shows it is unavailable. NVDA says the name and button.
N-03 (after): Yes. NVDA adds unavailable.
Show the code change for D-03
diff --git a/assets/base.css b/assets/base.css
index 6ad490f..e8c5834 100644
--- a/assets/base.css
+++ b/assets/base.css
@@ -1352,9 +1352,17 @@ shopify-accelerated-checkout-cart {
 .button.disabled,
 .customer button:disabled,
 .customer button[aria-disabled='true'],
-.customer button.disabled,
+.customer button.disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+}
+
+/* Dim the icon only. Opacity on the button would also dim its focus ring. */
 .quantity__button.disabled {
   cursor: not-allowed;
+}
+
+.quantity__button.disabled svg {
   opacity: 0.5;
 }
 
diff --git a/assets/global.js b/assets/global.js
index bde1d32..a5535b6 100644
--- a/assets/global.js
+++ b/assets/global.js
@@ -267,12 +267,16 @@ class QuantityInput extends HTMLElement {
     const value = parseInt(this.input.value);
     if (this.input.min) {
       const buttonMinus = this.querySelector(".quantity__button[name='minus']");
-      buttonMinus.classList.toggle('disabled', parseInt(value) <= parseInt(this.input.min));
+      const atMin = parseInt(value) <= parseInt(this.input.min);
+      buttonMinus.classList.toggle('disabled', atMin);
+      buttonMinus.setAttribute('aria-disabled', atMin);
     }
     if (this.input.max) {
       const max = parseInt(this.input.max);
       const buttonPlus = this.querySelector(".quantity__button[name='plus']");
-      buttonPlus.classList.toggle('disabled', value >= max);
+      const atMax = value >= max;
+      buttonPlus.classList.toggle('disabled', atMax);
+      buttonPlus.setAttribute('aria-disabled', atMax);
     }
   }
 }

D-04: Country search field has invalid ARIA and a repeated id

Place
Footer on all three pages, once the country selector is open. The same snippet is used in the header drawer.
What happens
The field has role=combobox with no aria-expanded, and aria-owns and aria-controls point at an id that does not exist on the page. The id country-filter-input is repeated in each copy on the page, so the label in the footer points at the copy in the drawer.
WCAG 2.1 criterion
4.1.2 Name, Role, Value and 1.3.1 Info and Relationships
How it was seen
axe-core with the selector open: aria-required-attr, aria-valid-attr-value, both critical, on all three pages. With the selector closed, axe only lists duplicate-id-aria as needing review.
Fix
Make it a plain search field with one id per position, a label tied to that id, and aria-controls pointing at the real list. Patch: 04-country-search-field.patch.
After the fix
axe with the selector open: none. duplicate-id-aria is no longer listed. The accessibility tree reports role searchbox, name Search; before it was role combobox.
How the after state was checked
Local browser replay with the rendered HTML changed to match the patch. LiquidJS render of the patched snippet with stub filters. Not checked on a real store.
NVDA
N-04 (before): NVDA says Search, combo box, has auto complete, editable, blank.
N-05 (after): NVDA says Search, edit, blank. The field is read as a plain edit field with its name.
Show the code change for D-04
diff --git a/snippets/country-localization.liquid b/snippets/country-localization.liquid
index 9efa9a6..7f75b51 100644
--- a/snippets/country-localization.liquid
+++ b/snippets/country-localization.liquid
@@ -48,22 +48,20 @@
         <div class="field">
           <input
             class="country-filter__input field__input"
-            id="country-filter-input"
+            id="{{ localPosition }}-country-filter-input"
             type="search"
             name="country_filter"
             value=""
             placeholder="{{ 'localization.search' | t }}"
-            role="combobox"
-            aria-owns="country-results"
-            aria-controls="country-results"
-            aria-haspopup="listbox"
-            aria-autocomplete="list"
+            aria-controls="{{ localPosition }}-country-results"
             autocorrect="off"
             autocomplete="off"
             autocapitalize="off"
             spellcheck="false"
           >
-          <label class="field__label" for="country-filter-input">{{ 'localization.search' | t }}</label>
+          <label class="field__label" for="{{ localPosition }}-country-filter-input">
+            {{- 'localization.search' | t -}}
+          </label>
           <button
             type="reset"
             class="country-filter__reset-button field__button hidden"

D-05: Product gallery images have empty alt text

Place
Product page, gallery, all 12 images.
What happens
All 12 gallery images have alt="" in the page, and the accessibility tree shows 12 empty list items. NVDA reads the list as one item with one Unlabeled graphic and leaves the other 11 images unspoken. The same images have the alt text Small Naomi in the image dialog, so the store has text for them. The gallery does not use it.
WCAG 2.1 criterion
1.1.1 Non-text Content
How it was seen
The saved page and the accessibility tree snapshot. axe reports nothing here, because alt="" is valid markup.
Fix
Pass media.alt to the image tag in product-thumbnail.liquid, as the image dialog already does. Patch: 05-gallery-alt-text.patch.
After the fix
0 gallery images have empty alt. The accessibility tree shows 12 images named Small Naomi on the page, up from 0.
How the after state was checked
Local browser replay with the rendered HTML changed to match the patch, using the text the store's own image dialog shows. LiquidJS render of the patched snippet with stub filters. Not checked on a real store. Twelve images with one text is not a good description: better text per image is store data, and it was not changed here.
NVDA
N-07 (before): One image out of 12. NVDA says list, with 1 item, Unlabeled graphic, clickable. The other 11 images are not spoken.
N-08 (after): Yes. NVDA says list, with 12 items, and reads all 12 images as graphic, Small Naomi, each next to its button Open media 12 to 1 in modal.
Show the code change for D-05
diff --git a/snippets/product-thumbnail.liquid b/snippets/product-thumbnail.liquid
index 4eefa39..42b5086 100644
--- a/snippets/product-thumbnail.liquid
+++ b/snippets/product-thumbnail.liquid
@@ -78,6 +78,7 @@
         | image_url: width: 1946
         | image_tag:
           class: image_class,
+          alt: media.alt,
           loading: lazy,
           sizes: sizes,
           widths: '246, 493, 600, 713, 823, 990, 1100, 1206, 1346, 1426, 1646, 1946'

D-06: Banner subtitle over the photo measures under 4.5:1

Place
Home page, image banner, the line Atypical leather goods, 1280 by 900.
What happens
Text at 75% alpha over a darkened photo. Measured from pixels: #d3d8dc on the most common background color #566978, 3.97:1. axe does not decide this one. It lists the text as needing review.
WCAG 2.1 criterion
1.4.3 Contrast (Minimum)
How it was seen
Pixel measurement of a screenshot of the text box: the most common color against the color furthest from it. The background of a photo varies, so this is one reading and not a full map.
Fix
Use the full foreground color for text in the banner box. Patch: 06-banner-text-contrast.patch.
After the fix
#fefefe on the same background, 5.65:1, same measurement.
How the after state was checked
Local browser replay, same measurement. A store with a lighter photo can still fall short.
NVDA
No speech row. This is a visual issue.
Show the code change for D-06
diff --git a/assets/section-image-banner.css b/assets/section-image-banner.css
index e446abe..3940907 100644
--- a/assets/section-image-banner.css
+++ b/assets/section-image-banner.css
@@ -399,6 +399,11 @@
   margin-top: 1.5rem;
 }
 
+/* Text over a photo gets the full foreground color, not the 75% default. */
+.banner__box .banner__text {
+  color: rgb(var(--color-foreground));
+}
+
 @media screen and (min-width: 750px) {
   .banner__box > * + .banner__text {
     margin-top: 2rem;

Before and after

Before and after, measured in the local replay
Issue and measureBeforeAfter
D-01: Tab stops in the product gallery, 1280 pxBefore: 0After: 12
D-01: Width of image 2 in view when its button has focus, 320 pxBefore: 15 of 286 pxAfter: 286 of 286 px
D-01: First 5 gallery images fully in view when focused, 320 pxBefore: 1 of 5After: 5 of 5
D-02: Product page stops outside the windowBefore: 2After: 0
D-02: Home page stops covered by the headerBefore: 1After: 0
D-03: Accessibility tree, Decrease button disabledBefore: falseAfter: true
D-03: Focus ring contrast on that buttonBefore: 2.21:1After: 4.74:1
D-04: axe rules failed with the country selector openBefore: aria-required-attr, aria-valid-attr-valueAfter: none
D-05: Gallery images with empty alt textBefore: 12After: 0
D-06: Banner subtitle contrast from pixelsBefore: 3.97:1After: 5.65:1

Seen, not patched here

Looked at and not counted

NVDA rows

A row says what real NVDA spoke, read from NVDA's own log. Both the "before" and the "after" rows ran on the local replay of the saved demo pages, not on the live store. A script sent the keys and no person listened.

NVDA rows and their status
RowPage and stateKeysWhat the row asksResult
N-01 (D-01)Page and state: Product page, afterKeys: Tab from Skip to product information. Then Enter. Then Tab. Then Escape.What the row asks: Is the next stop announced as Open media 1 in modal, button? Does Enter announce a dialog? Does Escape return to the button?Result: Yes for the button and for Escape. Tab lands on the button and NVDA says Open media 1 in modal, button, opens dialog. Enter: NVDA says the name Media gallery and reads on from the Close button, but does not say the word dialog. Tab: Close, button. Escape returns to the button.
Speech log: Open media 1 in modal, button, opens dialog / Media gallery / button, Close / graphic, Small Naomi / Close, button / Open media 1 in modal, button, opens dialog
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11
N-02 (D-03)Page and state: Product page, beforeKeys: Tab to Decrease quantity at quantity 1.What the row asks: Does NVDA say anything to show the button is unavailable?Result: Nothing shows it is unavailable. NVDA says the name and button.
Speech log: Decrease quantity for Small Naomi, button
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11
N-03 (D-03)Page and state: Product page, afterKeys: Tab to Decrease quantity at quantity 1.What the row asks: Does NVDA say unavailable?Result: Yes. NVDA adds unavailable.
Speech log: Decrease quantity for Small Naomi, button, unavailable
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11
N-04 (D-04)Page and state: Footer on the home page, beforeKeys: Tab to the country button in the footer. Press Enter.What the row asks: What name and role does NVDA say for the search field that takes focus?Result: NVDA says Search, combo box, has auto complete, editable, blank.
Speech log: Search, combo box, has auto complete, editable, blank
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11
N-05 (D-04)Page and state: Footer on the home page, afterKeys: Tab to the country button in the footer. Press Enter.What the row asks: What name and role does NVDA say for the search field that takes focus?Result: NVDA says Search, edit, blank. The field is read as a plain edit field with its name.
Speech log: Search, edit, Search, blank
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11
N-06 (O-01)Page and state: Collection page at 320 px, beforeKeys: Tab to Filter and sort. Press Enter. Press Tab.What the row asks: What does NVDA say at the stop after the button?Result: NVDA says button, expanded, and gives no name.
Speech log: button, expanded
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11
N-07 (D-05)Page and state: Product page, beforeKeys: In browse mode press Up Arrow from below the heading Small Naomi, through the heading and the gallery above it. The key 1 was tried first and NVDA said No next heading at level 1, because its reading position was already below the heading.What the row asks: Is anything spoken for the 12 gallery images?Result: One image out of 12. NVDA says list, with 1 item, Unlabeled graphic, clickable. The other 11 images are not spoken.
Speech log: heading, level 1, Small Naomi / Gallery Viewer, region, list, with 1 item, Unlabeled graphic, clickable, To get missing image descriptions, open the context menu. / out of list, same page, link, Skip to product information
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11
N-08 (D-05)Page and state: Product page, afterKeys: In browse mode press Up Arrow from below the heading Small Naomi, through the heading and the gallery above it. The key 1 was tried first and NVDA said No next heading at level 1, because its reading position was already below the heading.What the row asks: Is each gallery image spoken as a graphic with the text Small Naomi?Result: Yes. NVDA says list, with 12 items, and reads all 12 images as graphic, Small Naomi, each next to its button Open media 12 to 1 in modal.
Speech log: heading, level 1, Small Naomi / Gallery Viewer, region, list, with 12 items, button, opens dialog, Open media 12 in modal / graphic, clickable, Small Naomi / button, opens dialog, Open media 11 in modal / graphic, Small Naomi / and so on to Open media 1 in modal / graphic, clickable, Small Naomi / out of list, same page, link, Skip to product information
Run by an AI assistant (Claude), with a script sending the keys. No person listened, 5 November 2026, NVDA 2026.3, Chrome 156 on Windows 11

How the after state was checked

What was not checked

Patch files and license

Dawn is by Shopify. Its license is MIT-style with one added limit: the rights are for developing themes that work with Shopify. The patches contain short pieces of Dawn's code, so the license goes with them: LICENSE-dawn.txt. To try them, run git apply in a copy of the Dawn source with Unix line endings.

End of the sample record. No person has tested these pages by hand. All 8 NVDA rows were run by a script on saved copies of the pages, and each shows what NVDA said.