diff --git a/.claude/skills/accessibility/SKILL.md b/.claude/skills/accessibility/SKILL.md new file mode 100644 index 0000000000..bb90390f28 --- /dev/null +++ b/.claude/skills/accessibility/SKILL.md @@ -0,0 +1,174 @@ +--- +name: accessibility +description: >- + Enforce WCAG 2.2 AA for Cornerstone storefront UI: semantics, accessible names, + forms/errors, keyboard/focus, live regions, decorative hiding, contrast, and + lang/en.json ARIA strings. Use whenever creating or editing anything a shopper + sees or interacts with — Stencil/Handlebars templates (templates/**/*.html), + theme JS (assets/js/theme/**), or component SCSS (assets/scss/**) — including + forms, buttons, links, dialogs, lists, headings, icons, images, status messages, + focus, visibility, layout, color, or motion. Also use when the user mentions + accessibility, ARIA, screen readers, keyboard navigation, focus, or WCAG, even + if they did not ask for an accessibility pass explicitly. +--- + +# Accessibility (WCAG 2.2 AA) for Cornerstone storefront UI + +Cornerstone ships to merchants who inherit our accessibility. Shoppers using a +screen reader, keyboard, or magnification must complete every flow. Build +accessibility in at authoring time — automated checks only catch part of it. + +This skill is an implementation guardrail, not a substitute for +[WCAG 2.2](https://www.w3.org/WAI/WCAG22/quickref/). Deeper criteria and +high-churn surfaces: [reference.md](reference.md). Good/bad snippets: +[examples.md](examples.md). + +## Do this first + +Before editing, list in one line which areas the change affects: + +`semantics` · `names/roles/states` · `forms/errors` · `keyboard/focus` · +`dynamic updates` · `visual/reflow/motion` · `pointer/touch` · `images/media` + +Design applicable requirements into the solution, then edit. + +## Mandatory rules + +1. **Semantics before ARIA.** Use ` +``` + +**Good** — decorative glyph hidden; control keeps a real name: + +```html + +``` + +| Goal | Class / attribute | +|------|-------------------| +| Seen, not heard | `aria-hidden="true"` | +| Heard, not seen | `aria-description--hidden` | + +## 4. Announce progress; move focus for success + +**Bad** — visual-only state; nothing for AT: + +```html + +
+``` + +**Good, less urgent (`templates/components/carousel-content-announcement.html`)** +— polite status, pre-existing node, text replaced in place: + +```html + +``` + +**Good, must interrupt (`templates/pages/create-return.html` + +`assets/js/theme/create-return.js`)** — the returns flow uses `aria-live="assertive"` +instead of polite, because letting the shopper keep reading while a submit is in +flight risks a duplicate submit or navigating away mid-request: + +```html +

+``` + +```js +form.setAttribute('aria-busy', 'true'); +this.announce(this.context.submittingMessage); // updates data-new-return-status +// ...on error, clear it: +this.announce(''); +// on success: no text update — focus moves to the confirmation heading instead (see #9) +form.removeAttribute('aria-busy'); +``` + +Default to polite for progress/success text. Reach for assertive only when a +shopper acting on stale information could cause a real problem — and prefer it +to `role="alert"`, which is meant for errors, not routine progress. + +## 5. Failed submit — two valid patterns, don't mix them + +Rule 3 gives two options for a failed submit. Pick one per error box — don't +combine them on the same node. + +**Bad** — nothing happens for AT users; error is visual only: + +```html + +``` +```js +errorBox.style.display = ''; // no role, no focus move — silent to screen readers +``` + +**Option A — focus the summary** (use when you want to pull the shopper +straight to the error, e.g. it's far from the trigger control): + +```html + +``` +```js +errorBox.style.display = ''; +errorBox.focus(); // do NOT also add role="alert" here — double announcement risk +``` + +**Option B — keep focus on the control, let the alert announce it** (real +pattern, `templates/pages/create-return.html` — focus stays on Submit so the +shopper can immediately retry without navigating back to it): + +```html + +``` +```js +errorBox.style.display = ''; // pre-existing node + role="alert" announces this; no .focus() call +``` + +Field-level errors (when a flow has per-field validation) apply regardless of +which option you pick: `aria-invalid="true"` on the invalid control, +`aria-describedby` pointing at its error text, and a real `lang/en.json` key +for that message. The additional-note character-limit error +(`newReturn-additionalNote-error`) is a real example of this in `create-return.html`. + +## 6. Disabled submit that stays explainable + +**Bad** — `aria-disabled` on a wrapper (screen reader still reaches an +enabled-looking button inside it; state isn't exposed on the control): + +```html +
+ +
+``` + +**Good** (real pattern, `templates/pages/create-return.html`) — `aria-disabled` +on the control itself, kept focusable/discoverable, hint linked via +`aria-describedby`, JS blocks activation: + +```html +

+ {{lang 'account.returns.submit_hint'}} +

+ +``` + +```js +// create-return.js: removes the hint once valid so the label alone is +// announced — avoids a stale "why is this disabled" hint once it isn't. +submitBtn.setAttribute('aria-disabled', String(!isValid)); +submitBtn[isValid ? 'removeAttribute' : 'setAttribute']('aria-describedby', 'return-new-submitHint-disabled'); +``` + +```js +// gate activation in JS — aria-disabled does not block click/Enter natively +form.addEventListener('submit', event => { + event.preventDefault(); + if (submitBtn.getAttribute('aria-disabled') === 'true') return; + // ...proceed +}); +``` + +If the control can genuinely be removed from the tab order instead (no need to +explain why it's inactive), prefer plain `disabled` — it's simpler and native. +Reach for `aria-disabled` specifically when shoppers benefit from discovering +*why* a control is inactive, as here. + +## 7. Translatable ARIA — no hardcoded English + +**Bad:** + +```html + +``` + +**Good — template:** + +```html + +``` + +**Good — JS string from inject:** + +```html +{{~inject 'carouselPlayPauseButtonAriaPlay' (lang 'carousel.play_pause_button_aria_play')}} +``` + +```js +button.setAttribute('aria-label', this.context.carouselPlayPauseButtonAriaPlay); +``` + +## 8. Merchant/customer content escaping + +**Bad:** + +```html +

{{{sanitize product.name}}}

+``` + +**Good:** + +```html +

{{product.name}}

+``` + +## 9. Success / confirmation focus + +**Bad** — focus the entire confirmation container or nothing at all: + +```js +document.querySelector('.confirmation').focus(); +``` + +**Good** — focus the confirmation heading (`tabindex="-1"`): + +```html +

+ {{lang 'account.returns.from_order' id=id}} + {{lang 'account.returns.submitted_successfully'}} +

+``` + +```js +document.querySelector('[data-new-return-confirmation-heading]').focus(); +``` diff --git a/.claude/skills/accessibility/reference.md b/.claude/skills/accessibility/reference.md new file mode 100644 index 0000000000..a624d9e691 --- /dev/null +++ b/.claude/skills/accessibility/reference.md @@ -0,0 +1,109 @@ +# Accessibility reference (Cornerstone) + +Read this when a change needs deeper WCAG detail or touches a high-churn +surface. Keep [SKILL.md](SKILL.md) as the authority for mandatory rules; +validate every pattern against those rules. + +## WCAG areas → what to verify + +| Area | Verify | +|------|--------| +| Semantics / content | Correct elements; heading order; lists; landmarks; link purpose in context | +| Names, roles, values, states | Accessible name; role only if needed; `expanded`/`selected`/`pressed`/`current`/`invalid` match UI | +| Forms / errors | Labels; fieldset/legend; autocomplete; `aria-describedby` for hints/errors; values preserved | +| Keyboard / focus | No pointer-only ops; no traps; DOM order; visible focus; focus moves on submit failure / major view change | +| Dynamic updates | Polite status for progress/success by default; assertive only when acting on stale info causes harm; `aria-busy` while processing | +| Visual / reflow / motion | Zoom to 200%; narrow reflow; text spacing; forced colors; `prefers-reduced-motion` | +| Pointer / touch | ≥24×24 CSS px targets (or SC 2.5.8 exception); non-dragging alternative if drag is required | +| Images / media | Meaningful `alt`; decorative empty/`aria-hidden`; captions/transcripts where applicable | + +## High-churn Cornerstone surfaces + +If you touch one of these, also check the paired concerns: + +| Surface | Also check | +|---------|------------| +| Modals / drawers / filters | Focus trap to dialog only while open; Escape closes; restore focus to opener; initial focus to dialog (not a random control inside unless pattern requires); `aria-modal` / labelling | +| Carousels / sliders | Play/pause control; slide announcements via status region; arrow/dot names from `lang` injects; respect reduced motion | +| Product options / swatches | Selected state exposed; each option named; keyboard selection matches pointer | +| Mini-cart / cart qty | Live updates announced; qty inputs labelled per line item; remove controls named | +| Account forms / returns | Loop ids; error summary focus; disabled submit + hint; confirmation heading focus | +| Responsive show/hide | Labels not `display:none` if used with `

` text as a section heading — a second identical heading is + duplicate noise for screen-reader users. Drop it or give it distinct text. +- A **status / label is not a heading** — use ``, never `
`/`
`, for status badges. + +## Semantics +- Use `