From 81dabd022b51a869cd7d00a7aa7d560c908e0fc2 Mon Sep 17 00:00:00 2001 From: Stackwright Bot Date: Sun, 31 May 2026 10:29:39 -0400 Subject: [PATCH] feat(core): add aria-live regions to Form, SearchModal, Carousel, ErrorBoundary (stackwright-qb9) --- .beads/issues.jsonl | 10 ++-- .changeset/feat-aria-live-regions.md | 5 ++ .../components/ContentItemErrorBoundary.tsx | 1 + .../src/components/base/AriaLiveRegion.tsx | 40 ++++++++++++++++ packages/core/src/components/base/Form.tsx | 3 ++ packages/core/src/components/base/index.ts | 2 + .../narrative/Carousel/Carousel.tsx | 6 +++ .../src/components/structural/SearchModal.tsx | 15 +++++- packages/core/test/components/Form.test.tsx | 18 ++++++++ .../test/components/aria-live-region.test.tsx | 46 +++++++++++++++++++ .../content-item-error-boundary.test.tsx | 14 ++++++ 11 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 .changeset/feat-aria-live-regions.md create mode 100644 packages/core/src/components/base/AriaLiveRegion.tsx create mode 100644 packages/core/test/components/aria-live-region.test.tsx diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 56553477..b496bc99 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,19 +1,19 @@ {"_type":"issue","id":"stackwright-1ec","title":"bug: scaffold missing @stackwright/collections and lucide-react deps","description":"## What breaks\n\nA freshly-scaffolded project (via `npx launch-stackwright`) fails `next build` immediately with two module-not-found errors:\n\n```\n./app/_components/providers.tsx:6:1\nModule not found: Can't resolve '@stackwright/collections'\n\n./stackwright-generated/icons.ts:6:1\nModule not found: Can't resolve 'lucide-react'\n```\n\n## Where it breaks\n\n1. `providers.tsx` imports `FileCollectionProvider` from `@stackwright/collections`, but that package is not injected into the scaffolded project's `package.json` by the scaffold generator.\n2. The generated `stackwright-generated/icons.ts` (produced by `stackwright-prebuild`) imports from `lucide-react`, but `lucide-react` is also absent from the scaffolded project's `package.json`.\n\nBoth are runtime/build-time hard dependencies that every scaffolded project needs; neither is optional.\n\n## Fix location\n\nThe scaffold template generator — most likely `packages/launch-stackwright/src/index.ts` or the scaffold template files it uses — needs to inject both of the following as dependencies in the generated `package.json`:\n- `@stackwright/collections`\n- `lucide-react`\n\n## Evidence\n\n`scaffold-smoke-test` CI check failing on `dev` branch across 3+ consecutive runs. See PR #467 for CI logs.","status":"closed","priority":0,"issue_type":"bug","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-30T12:56:09Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T13:00:42Z","started_at":"2026-05-30T12:59:57Z","closed_at":"2026-05-30T13:00:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"stackwright-8c4","title":"A11y: Add skip-to-content landmark navigation","description":"## Problem\nPageLayout.tsx and TopAppBar.tsx have no skip-to-content link. Keyboard users must tab through the entire navigation bar (logo, menu items, dark mode toggle) before reaching page content on every page load. This is WCAG 2.4.1 Level A — the lowest accessibility bar.\n\n## Proposed Solution\n- Add a visually-hidden-until-focused Skip to main content link as the first focusable element in PageLayout\n- Wrap the content area in main id=main-content tabIndex={-1}\n- Style: invisible until focused, then appears as high-contrast banner at top of viewport\n- ~20 lines of code, massive accessibility win","acceptance_criteria":"- [ ] Skip-to-content link is the first focusable element on every page\n- [ ] Link is visually hidden until focused via Tab key\n- [ ] Clicking/activating moves focus to main content area\n- [ ] Works correctly with NavSidebar present\n- [ ] Visual style is high-contrast and clearly visible when focused\n- [ ] E2E keyboard navigation test updated to verify skip link\n- [ ] Works in both light and dark modes","status":"in_progress","priority":1,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:31:23Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:57:02Z","started_at":"2026-05-31T13:57:02Z","labels":["a11y","quality","wcag"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-8c4","title":"A11y: Add skip-to-content landmark navigation","description":"## Problem\nPageLayout.tsx and TopAppBar.tsx have no skip-to-content link. Keyboard users must tab through the entire navigation bar (logo, menu items, dark mode toggle) before reaching page content on every page load. This is WCAG 2.4.1 Level A — the lowest accessibility bar.\n\n## Proposed Solution\n- Add a visually-hidden-until-focused Skip to main content link as the first focusable element in PageLayout\n- Wrap the content area in main id=main-content tabIndex={-1}\n- Style: invisible until focused, then appears as high-contrast banner at top of viewport\n- ~20 lines of code, massive accessibility win","acceptance_criteria":"- [ ] Skip-to-content link is the first focusable element on every page\n- [ ] Link is visually hidden until focused via Tab key\n- [ ] Clicking/activating moves focus to main content area\n- [ ] Works correctly with NavSidebar present\n- [ ] Visual style is high-contrast and clearly visible when focused\n- [ ] E2E keyboard navigation test updated to verify skip link\n- [ ] Works in both light and dark modes","status":"closed","priority":1,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:31:23Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:59:12Z","started_at":"2026-05-31T13:57:02Z","closed_at":"2026-05-31T13:59:12Z","close_reason":"Closed","labels":["a11y","quality","wcag"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-rqj","title":"chore(examples): validate stackwright-docs App Router migration build","description":"## Context\nThe examples/stackwright-docs app was migrated from Pages Router to App Router (June 2026) to fix broken React.lazy() code splitting and eliminate the 110KB polyfills chunk. The migration created app/layout.tsx, app/_components/providers.tsx, app/_components/page-client.tsx, app/page.tsx, app/[...slug]/page.tsx, and app/not-found.tsx. Pages Router routing files were removed.\n\n## Tasks\n- [ ] Run `pnpm build` in examples/stackwright-docs and fix any build errors\n- [ ] Verify `out/_next/static/chunks/` structure: no polyfills chunk, framework+main+app chunks present\n- [ ] Verify Carousel React.lazy() produces a separate chunk file (code splitting works)\n- [ ] Run E2E smoke tests (`pnpm test:e2e tests/smoke.spec.ts`) — all pages render\n- [ ] Run bundle-size benchmark and confirm it passes the interim 350KB budget\n- [ ] Run visual regression tests and update screenshots if layout is identical\n\n## Acceptance\n- Build succeeds with zero errors\n- All E2E smoke tests pass\n- Bundle size benchmark passes (≤350KB gzip first-load)\n- Carousel chunk is separate from main bundle","status":"closed","priority":1,"issue_type":"task","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-30T17:35:47Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T17:58:08Z","started_at":"2026-05-30T17:39:38Z","closed_at":"2026-05-30T17:58:08Z","close_reason":"Closed","labels":["app-router","performance","validation"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-awj","title":"feat(otters): implement Collection Otter agent (Phase 2)","description":"A complete design spec exists at packages/otters/src/stackwright-collection-otter-spec.md but the otter is not yet implemented. Dependency stackwright-bls (Collections system) is now closed and complete, so this is fully unblocked. Implementation requirements (from spec): (1) Create stackwright-collection-otter.json agent config in packages/otters/src/. (2) Wire CollectionProvider interface from @stackwright/collections. (3) Update Foreman Otter (stackwright-foreman-otter.json) to detect collection needs and invoke Collection Otter. (4) Add Collection Otter to install-agents.js postinstall script. (5) Test with a real blog collection in an example site. The otter should scaffold content/\u003cname\u003e/ dirs, create sample YAML entries in brand voice, wire collection_list content types in pages, and validate all output.","status":"closed","priority":1,"issue_type":"feature","assignee":"planning-agent-931480","owner":"bot@per-aspera.dev","created_at":"2026-05-28T20:18:00Z","created_by":"Stackwright Bot","updated_at":"2026-05-28T20:24:01Z","started_at":"2026-05-28T20:19:37Z","closed_at":"2026-05-28T20:24:01Z","close_reason":"Implemented Collection Otter (stackwright-collection-otter): created agent JSON with full 5-step workflow (create collection, write sample entries, wire listing page, validate+render, nav check + prebuild reminder). Updated Foreman Otter with Phase 4 dispatch, phase skip logic, collection phase section. Updated AGENTS.md with new otter in overview table + full reference section. Updated README.md with raft table row, pipeline diagram Phase 4, package structure listing, and example sites note. Created changeset collection-otter-phase2.md (@stackwright/otters minor).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-wao","title":"checkForPlaintextSecret: add high-entropy detection for real tokens (bi-directional check)","description":"The checkForPlaintextSecret function in packages/types/src/types/secret-detection.ts currently warns when entropy is low (\u003c 3.8) — catching human-readable plaintext passwords stored in integration auth YAML fields.\n\nHowever, there are two distinct threat classes that one threshold can't catch:\n- Low entropy (\u003c 3.8): human-readable passwords like \"password123\"\n- High entropy (\u003e 4.5): real cryptographic tokens like JWTs, bearer tokens, API keys stored directly in YAML\n\nThe current check only catches the first class. A JWT stored as `token: \"eyJhbGciOiJSUzI1NiJ9...\"` passes through silently.\n\nSuggested fix: change the condition to `entropy \u003c 3.8 || entropy \u003e 4.5` to catch both classes. Update the warning message to distinguish which case triggered.\n\nAdditional context: checkForPlaintextSecret is currently exported but never called (dead code). This issue should also cover wiring it into the prebuild pipeline at the integration auth processing point in packages/build-scripts/src/prebuild.ts. Consider adding a minimum length guard (e.g., skip values \u003c 32 chars for the high-entropy check) to reduce false positives from short but random-looking strings.","notes":"Tags: security, types, build-scripts","status":"closed","priority":1,"issue_type":"task","assignee":"planning-agent-2840e9","owner":"bot@per-aspera.dev","created_at":"2026-05-19T01:40:56Z","created_by":"Stackwright Bot","updated_at":"2026-05-20T16:41:06Z","started_at":"2026-05-20T16:32:10Z","closed_at":"2026-05-20T16:41:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-a1g","title":"feat: Otter Agents AI-assisted site generation pipeline — complete remaining work","description":"Four-otter pipeline (Foreman, Brand, Theme, Page) core architecture is complete. Remaining work: end-to-end testing of full pipeline, create 3-5 example sites generated by the otter raft, refine handoff protocol between otters, design Collection Otter for Phase 2. Affects packages/otters. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/236","notes":"Session progress: Refined handoff protocol across all 4 otter JSONs (session_id passing, phase-skip logic, dev server awareness, error recovery). Expanded Brand Otter discovery (14 questions, 5 phases), enriched BRAND_BRIEF.md template. Theme Otter now includes nav config and dark mode guidance. Page Otter now covers all 18 content types + navSidebar. Added Collection Otter Phase 2 design spec (packages/otters/src/stackwright-collection-otter-spec.md). Added E2E pipeline validation checklist (packages/otters/test/e2e-pipeline-checklist.md). Remaining: create 3-5 example sites by running the otter pipeline against real projects in stackwright-tests.","status":"closed","priority":1,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:48Z","created_by":"Stackwright Bot","updated_at":"2026-05-25T19:23:07Z","started_at":"2026-05-25T17:45:24Z","closed_at":"2026-05-25T19:23:07Z","close_reason":"Created 3 reference example sites demonstrating complete otter pipeline output: examples/law-firm-example/ (4 pages, navy/gold), examples/saas-example/ (5 pages + pricing, indigo/amber), examples/restaurant-example/ (3 pages, terracotta/cream). Updated e2e-pipeline-checklist.md marking law firm, SaaS, and restaurant as tested. Updated packages/otters/README.md with Example Sites section. Remaining: Portfolio/Agency and B2B services examples for Phase 2.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-ufs","title":"feat(types,core): migrate to explicit type field discrimination in content renderer","description":"Content renderer currently uses Object.entries(item)[0] to discriminate content types — relies on JS object insertion order (not guaranteed), prevents TypeScript discriminated unions, produces poor error messages. Migrate to explicit type field on every content item (z.object({ type: z.literal('...'), ... })). Breaking change — coordinate with next major version bump. Acceptance: update Zod schemas, content renderer, TypeScript unions, all YAML files, tests, JSON schemas, AGENTS.md tables. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/344","status":"closed","priority":1,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:44Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:22:32Z","closed_at":"2026-05-19T00:22:32Z","close_reason":"Already implemented: contentRenderer.tsx uses item.type for discrimination, content.ts uses z.literal('type') on all schemas. Shipped before this triage run.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-ri2","title":"Performance: Image optimization pipeline with sharp in prebuild","description":"## Problem\nThe image co-location pipeline (prebuild.ts) copies images as-is. No resizing, no format conversion, no blur placeholder generation. NextStackwrightImage accepts placeholder and blurDataURL props but nothing generates them. sharp is already in pnpm.onlyBuiltDependencies but never actually used.\n\n## Proposed Solution\nDuring prebuild, run co-located images through sharp:\n- Generate WebP/AVIF variants alongside the original\n- Create tiny (10px wide) blur placeholder as base64 for blurDataURL\n- Emit _image-manifest.json mapping original paths → optimized paths + metadata\n- NextStackwrightImage reads manifest and auto-provides placeholder=blur + blurDataURL\n- Add imageOptimization config to stackwright.yml:\n formats: [webp, avif]\n quality: 80\n maxWidth: 1920\n blur: true\n\n## Impact\nDirectly affects Core Web Vitals (LCP) for every Stackwright site.","acceptance_criteria":"- [ ] sharp processes all co-located images during prebuild\n- [ ] WebP variants generated alongside originals\n- [ ] Blur placeholders generated as base64 data URIs\n- [ ] _image-manifest.json emitted with image metadata\n- [ ] NextStackwrightImage auto-provides blurDataURL when available\n- [ ] imageOptimization config in stackwright.yml schema\n- [ ] Opt-out via --no-image-optimization flag\n- [ ] Performance regression test (prebuild time budget)\n- [ ] Unit tests for image processing","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:30:26Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:30:26Z","labels":["feature","performance","prebuild"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"stackwright-7dv","title":"Feature: Markdown rendering in TextBlocks via micromark","description":"## Problem\nTextBlock only supports plain text strings. Content authors and AI agents cannot express bold, italic, links, or lists without resorting to separate content types. This forces verbose YAML for simple prose paragraphs.\n\n## Proposed Solution\n- Integrate micromark (~4KB gzipped, zero deps, CommonMark compliant) for inline markdown rendering\n- Add optional format: plain | markdown field to TextBlock (default: plain for backward compat)\n- At render time, sanitize HTML output (no raw HTML passthrough — maintain safe by construction guarantee)\n- Narrow scope: inline markdown only, no arbitrary HTML\n\n## Why micromark over remark\nStreaming tokenizer with no AST overhead. Zero external dependencies. Tiny bundle. Perfect for a framework that values constrained output.","acceptance_criteria":"- [ ] TextBlock schema extended with optional format field\n- [ ] micromark integrated with sanitized output (no raw HTML passthrough)\n- [ ] Bold, italic, links, inline code render correctly\n- [ ] Lists and headings within markdown blocks render correctly\n- [ ] Default format: plain maintains 100% backward compatibility\n- [ ] XSS vectors are blocked (no script injection via markdown)\n- [ ] Unit tests for markdown rendering + sanitization\n- [ ] JSON schema regenerated\n- [ ] AGENTS.md content type table updated","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:30:13Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:30:13Z","labels":["content-types","dx","feature"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"stackwright-qb9","title":"A11y: Add aria-live regions for dynamic state changes (Form, Search, Carousel)","description":"## Problem\nZero uses of aria-live, role=alert, or role=status anywhere in packages/core/src (confirmed via grep). Components with dynamic state changes:\n- Form.tsx: loading → success/error with no announcements\n- SearchModal.tsx: result count changes not announced\n- Carousel.tsx: slide changes not communicated\n- ContentItemErrorBoundary: error UI with no live region\n\nThis violates WCAG 4.1.3 (Status Messages).\n\n## Proposed Solution\n- Add AriaLiveRegion utility component to core\n- Form.tsx: announce Submitting.../success/error messages\n- SearchModal.tsx: announce result count (3 results found / No results)\n- Carousel: announce slide position (Slide 2 of 5)\n- ContentItemErrorBoundary: wrap error message in role=alert","acceptance_criteria":"- [ ] Form announces submission state changes to screen readers\n- [ ] SearchModal announces result count changes\n- [ ] Carousel announces current slide position on change\n- [ ] ContentItemErrorBoundary uses role=alert\n- [ ] E2E tests verify aria-live announcements\n- [ ] No false positives in axe-core a11y audit","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:30:00Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:30:00Z","labels":["a11y","quality","wcag"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-7dv","title":"Feature: Markdown rendering in TextBlocks via micromark","description":"## Problem\nTextBlock only supports plain text strings. Content authors and AI agents cannot express bold, italic, links, or lists without resorting to separate content types. This forces verbose YAML for simple prose paragraphs.\n\n## Proposed Solution\n- Integrate micromark (~4KB gzipped, zero deps, CommonMark compliant) for inline markdown rendering\n- Add optional format: plain | markdown field to TextBlock (default: plain for backward compat)\n- At render time, sanitize HTML output (no raw HTML passthrough — maintain safe by construction guarantee)\n- Narrow scope: inline markdown only, no arbitrary HTML\n\n## Why micromark over remark\nStreaming tokenizer with no AST overhead. Zero external dependencies. Tiny bundle. Perfect for a framework that values constrained output.","acceptance_criteria":"- [ ] TextBlock schema extended with optional format field\n- [ ] micromark integrated with sanitized output (no raw HTML passthrough)\n- [ ] Bold, italic, links, inline code render correctly\n- [ ] Lists and headings within markdown blocks render correctly\n- [ ] Default format: plain maintains 100% backward compatibility\n- [ ] XSS vectors are blocked (no script injection via markdown)\n- [ ] Unit tests for markdown rendering + sanitization\n- [ ] JSON schema regenerated\n- [ ] AGENTS.md content type table updated","status":"closed","priority":2,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:30:13Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T14:18:23Z","started_at":"2026-05-31T14:12:32Z","closed_at":"2026-05-31T14:18:23Z","close_reason":"Closed","labels":["content-types","dx","feature"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-qb9","title":"A11y: Add aria-live regions for dynamic state changes (Form, Search, Carousel)","description":"## Problem\nZero uses of aria-live, role=alert, or role=status anywhere in packages/core/src (confirmed via grep). Components with dynamic state changes:\n- Form.tsx: loading → success/error with no announcements\n- SearchModal.tsx: result count changes not announced\n- Carousel.tsx: slide changes not communicated\n- ContentItemErrorBoundary: error UI with no live region\n\nThis violates WCAG 4.1.3 (Status Messages).\n\n## Proposed Solution\n- Add AriaLiveRegion utility component to core\n- Form.tsx: announce Submitting.../success/error messages\n- SearchModal.tsx: announce result count (3 results found / No results)\n- Carousel: announce slide position (Slide 2 of 5)\n- ContentItemErrorBoundary: wrap error message in role=alert","acceptance_criteria":"- [ ] Form announces submission state changes to screen readers\n- [ ] SearchModal announces result count changes\n- [ ] Carousel announces current slide position on change\n- [ ] ContentItemErrorBoundary uses role=alert\n- [ ] E2E tests verify aria-live announcements\n- [ ] No false positives in axe-core a11y audit","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:30:00Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T14:21:12Z","started_at":"2026-05-31T14:21:12Z","labels":["a11y","quality","wcag"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-nw6","title":"A11y: Add prefers-reduced-motion support across all animated components","description":"## Problem\nZero instances of prefers-reduced-motion in the entire codebase (confirmed via grep). Yet multiple components have animations:\n- DynamicPage.tsx injects CSS keyframes (sf-shimmer, sf-drift, sf-float) for background animations\n- Carousel.tsx has autoplay with timed transitions\n- TopAppBar uses transition: background-color 0.2s\n\nUsers with vestibular disorders get no relief. This is a WCAG 2.3.3 failure.\n\n## Proposed Solution\n- Add a useReducedMotion() hook (matchMedia wrapper)\n- Wrap all @keyframes injections in @media (prefers-reduced-motion: no-preference) guard\n- Carousel: disable autoplay + instant slide changes when reduced motion preferred\n- Expose theme-level config: motionPreference: respect | always | never","acceptance_criteria":"- [ ] useReducedMotion() hook added to packages/core/src/hooks/\n- [ ] All CSS keyframe animations wrapped in prefers-reduced-motion media query\n- [ ] Carousel autoplay disabled when reduced motion is preferred\n- [ ] All transitions reduced to 0ms or removed when reduced motion preferred\n- [ ] E2E test verifying animations are suppressed with emulateMedia\n- [ ] Unit test for the hook","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:29:49Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:29:49Z","labels":["a11y","quality","wcag"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-11p","title":"SEO Autopilot: Auto-generate sitemap.xml, robots.txt, and JSON-LD in prebuild","description":"## Problem\nDespite being a content-first static site framework, Stackwright has zero SEO automation. No sitemap.xml, no robots.txt, no structured data. The prebuild already walks all pages and knows their slugs, titles, and content types.\n\n## Proposed Solution\n- Auto-generate sitemap.xml in public/ during prebuild (all page slugs + locale variants are already known)\n- Auto-generate robots.txt with sensible defaults\n- Auto-generate JSON-LD for content types that map naturally to schema.org:\n - faq → FAQPage schema\n - pricing_table → Product/Offer schemas\n - Collection entries with dates → Article/BlogPosting\n- Add optional meta field to page YAML for og:image, description, canonicalUrl\n\n## Why This Fits Stackwright\nThe constrained schema is uniquely suited — content types are enumerable and validated, so JSON-LD generation can be deterministic and correct by construction.","acceptance_criteria":"- [ ] prebuild generates public/sitemap.xml with all page slugs including locale variants\n- [ ] prebuild generates public/robots.txt referencing the sitemap\n- [ ] FAQ content items auto-generate FAQPage JSON-LD in page head\n- [ ] pricing_table content items auto-generate Product/Offer JSON-LD\n- [ ] Collection entries with date fields generate Article JSON-LD\n- [ ] All generated structured data passes Google Rich Results Test\n- [ ] Unit tests for sitemap and JSON-LD generation","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-31T13:29:30Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:29:30Z","labels":["feature","prebuild","seo"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"stackwright-pzr","title":"fix(e2e): update SSR test for App Router static export behavior","description":"The 'JavaScript disabled still shows content (SSR)' test in tests/edge-cases/error-scenarios.spec.ts:466 fails because App Router static export (output: 'export') produces client-rendered pages — the body is empty when JS is disabled. This is expected behavior for static export but the test was written for Pages Router SSR. Options: (1) update test to expect empty body for static export mode, (2) skip test when output=export, (3) remove test. Discovered during stackwright-rqj CI validation.","status":"open","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-30T22:20:01Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T22:20:01Z","labels":["app-router","e2e"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-pzr","title":"fix(e2e): update SSR test for App Router static export behavior","description":"The 'JavaScript disabled still shows content (SSR)' test in tests/edge-cases/error-scenarios.spec.ts:466 fails because App Router static export (output: 'export') produces client-rendered pages — the body is empty when JS is disabled. This is expected behavior for static export but the test was written for Pages Router SSR. Options: (1) update test to expect empty body for static export mode, (2) skip test when output=export, (3) remove test. Discovered during stackwright-rqj CI validation.","status":"closed","priority":2,"issue_type":"bug","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-30T22:20:01Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:59:12Z","started_at":"2026-05-31T13:57:03Z","closed_at":"2026-05-31T13:59:12Z","close_reason":"Closed","labels":["app-router","e2e"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-b2w","title":"perf: tree-shake MapLibre GL from stackwright-docs bundle","description":"MapLibre GL JS (36KB gzip / 118KB raw) is included in every page of stackwright-docs despite zero pages using the map content type. Discovered during App Router migration validation (stackwright-rqj). The entire @stackwright/maplibre package is pulled into the shared chunk by Turbopack because @stackwright/core imports or re-exports it unconditionally. Fix: ensure MapLibre is only loaded via React.lazy() or dynamic import when a page actually contains a map content_item. Removing this dead weight would reduce first-load JS from ~448KB to ~412KB gzip.","status":"closed","priority":2,"issue_type":"task","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-30T17:57:44Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T01:52:41Z","started_at":"2026-05-31T01:49:08Z","closed_at":"2026-05-31T01:52:41Z","close_reason":"Closed","labels":["bundle-size","performance"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-70q","title":"perf(core): bundle optimization phase 3 — lazy content types + dep cleanup","description":"## Context\nAfter the App Router migration (June 2026), React.lazy() code splitting now works correctly. The ADR (docs/adr/bundle-architecture.md) targets ≤250KB gzip first-load. Current interim budget is 350KB. This bead covers the remaining optimizations to reach the target.\n\n## Tasks\n\n### Code Splitting (biggest wins)\n- [ ] Lazy-load CodeBlock + PrismJS (~12-15KB gzip savings)\n - Add React.lazy() wrapper in componentRegistry.ts for code_block\n - Add src/components/base/CodeBlock.tsx as separate tsup entry in packages/core/tsup.config.ts\n - prismHighlighter.ts moves into the lazy chunk automatically\n- [ ] Lazy-load FAQ + @radix-ui/react-accordion (~5KB gzip savings)\n - Add React.lazy() wrapper in componentRegistry.ts for faq\n - Add Faq.tsx as separate tsup entry\n\n### Dependency Cleanup\n- [ ] Remove fuse.js from @stackwright/core dependencies (it's dynamically imported in SearchModal — move to peerDependencies or optionalDependencies)\n- [ ] Remove fuse.js from examples/stackwright-docs direct dependencies if unnecessary\n- [ ] Test removing transpilePackages from examples/stackwright-docs/next.config.js (App Router may not need it for workspace packages)\n\n### Budget Tightening\n- [ ] After above changes land, measure actual first-load size\n- [ ] Update performance-budgets.json: target 250KB max / 200KB warn\n- [ ] Update ADR Decision 5 to reflect final achieved targets\n\n## Acceptance\n- First-load JS ≤ 250KB gzip\n- CodeBlock and FAQ load as separate async chunks (visible in out/_next/static/chunks/)\n- All E2E tests pass\n- Performance benchmarks pass with tightened budgets\n\n## References\n- docs/adr/bundle-architecture.md (Decisions 2, 3, 5, 6)\n- packages/e2e/tests/performance/performance-budgets.json\n- packages/core/tsup.config.ts (splitting: true already enabled)","status":"closed","priority":2,"issue_type":"task","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-30T17:35:47Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T13:04:03Z","started_at":"2026-05-31T13:01:23Z","closed_at":"2026-05-31T13:04:03Z","close_reason":"Closed","labels":["bundle-size","code-splitting","performance"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"stackwright-jh6","title":"flaky: Rapid page navigation e2e test gets empty body intermittently","description":"## What breaks\n\nThe Playwright test `Rapid State Changes › Rapid page navigation doesn't crash` in `packages/e2e/tests/edge-cases/error-scenarios.spec.ts:410` fails intermittently in CI.\n\n## Error\n\n```\nexpect(received).not.toBe(expected) // Object.is equality\nExpected: not \"\"\n\n \u003e 417 | expect(await page.locator('body').innerText()).not.toBe('');\n```\n\n## Context\n\nThe test navigates rapidly between pages and then asserts the body is non-empty. Under CI load, the page sometimes hasn't rendered content by the time the assertion fires. This is a race condition in the test itself, not a product bug.\n\n## Suggested fix\n\nAdd a `waitFor` or `toBeVisible` assertion on a known content element instead of checking `body.innerText()` immediately after navigation. Alternatively, add a retry or increase the navigation settling timeout.\n\n## Evidence\n\nObserved failing across multiple unrelated PRs (e.g., PR #468 CI runs 26684567601 and 26684947798). Passes locally and on re-runs.","status":"open","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-30T14:31:47Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T14:31:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-jh6","title":"flaky: Rapid page navigation e2e test gets empty body intermittently","description":"## What breaks\n\nThe Playwright test `Rapid State Changes › Rapid page navigation doesn't crash` in `packages/e2e/tests/edge-cases/error-scenarios.spec.ts:410` fails intermittently in CI.\n\n## Error\n\n```\nexpect(received).not.toBe(expected) // Object.is equality\nExpected: not \"\"\n\n \u003e 417 | expect(await page.locator('body').innerText()).not.toBe('');\n```\n\n## Context\n\nThe test navigates rapidly between pages and then asserts the body is non-empty. Under CI load, the page sometimes hasn't rendered content by the time the assertion fires. This is a race condition in the test itself, not a product bug.\n\n## Suggested fix\n\nAdd a `waitFor` or `toBeVisible` assertion on a known content element instead of checking `body.innerText()` immediately after navigation. Alternatively, add a retry or increase the navigation settling timeout.\n\n## Evidence\n\nObserved failing across multiple unrelated PRs (e.g., PR #468 CI runs 26684567601 and 26684947798). Passes locally and on re-runs.","status":"closed","priority":2,"issue_type":"bug","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-30T14:31:47Z","created_by":"Stackwright Bot","updated_at":"2026-05-31T14:09:10Z","started_at":"2026-05-31T14:07:52Z","closed_at":"2026-05-31T14:09:10Z","close_reason":"Fixed: replaced bare expect(await locator.innerText()) with auto-retrying await expect(locator).toBeVisible() in rapid navigation loop. Committed on fix/stackwright-jh6-flaky-rapid-nav, push pending network access.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-pez","title":"perf/benchmarks: fix performance budget files and reset to meaningful targets post-optimization","description":"## Goal\nFix the current state where performance budgets are meaningless ('current + 10% headroom') and the comments in `bundle-size.bench.ts` are badly out of sync with `performance-budgets.json`. Set real forward-looking targets once optimization work lands.\n\n## Current problems\n1. `performance-budgets.json` first-load JS budget: 450KB max. Benchmark comments in `bundle-size.bench.ts` say baseline was ~85KB and budget was \u003c100KB. Nobody updated the comments when the budget was bumped.\n2. Benchmarks have been failing for ~1 month — the budget ceiling has likely been breached even at 450KB as new features shipped.\n3. The `performance.yml` CI workflow is the actual gate; any mismatch between it and `performance-budgets.json` creates confusion about what the real enforcement is.\n\n## Tasks\n- [ ] Sync the comments in `bundle-size.bench.ts` with `performance-budgets.json` (do this now, independent of optimization)\n- [ ] After optimization work lands (icons + code-splitting beads), set new meaningful targets in `performance-budgets.json`\n- [ ] Proposed targets post-optimization: first-load JS warn 150KB / max 200KB, all-pages JS warn 600KB / max 800KB\n- [ ] Consider adding `@next/bundle-analyzer` output as a CI artifact on every PR for visibility (not a gate, just a report)\n- [ ] Update the benchmark README comments and PERFORMANCE.md to reflect the new architecture\n\n## Can be done in two phases\n- **Phase 1 (now)**: Fix the comment/budget mismatch — pure maintenance, no optimization needed\n- **Phase 2 (after optimization)**: Set the new meaningful budget numbers\n\n## Dependencies\n- Phase 2 depends on icons bead (stackwright-zkq) and code-split bead (stackwright-mp6) landing first","status":"closed","priority":2,"issue_type":"task","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-29T15:09:49Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T01:52:21Z","started_at":"2026-05-30T01:49:42Z","closed_at":"2026-05-30T01:52:21Z","close_reason":"Phase 1 (comment sync) and Phase 2 (new meaningful targets) both done. ADR written at docs/adr/bundle-architecture.md. first-load budget: 150KB warn / 200KB max.","labels":["maintenance","performance"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-mp6","title":"perf/code-split: implement dynamic import pattern for optional heavy content types","description":"## Goal\nEstablish and implement a code-splitting pattern for content types that are heavy but not universally used (carousel, video, potentially others identified by the audit).\n\n## Background\nAll content types registered via `DefaultStackwrightComponents` and `@stackwright/core` exports are currently loaded synchronously. For sites that don't use a carousel or video, they still pay the JS cost for those components.\n\nThe map content type has already solved this problem with a registry/provider pattern — it has zero weight in the base bundle unless the provider is explicitly registered. That pattern is a good model.\n\n## Tasks\n- [ ] Define the dynamic import pattern to use (Next.js `dynamic()` wrapper, React.lazy + Suspense, or registry async-loader) — decision from RFC bead\n- [ ] Identify which content types are candidates for code-splitting based on bundle audit\n- [ ] Implement the pattern for the top 2–3 candidates first (likely: carousel, video)\n- [ ] Add to AGENTS.md: 'When adding a new content type with \u003e20KB gzipped weight, use [pattern]'\n- [ ] Measure before/after first-load JS impact\n\n## Dependencies\n- RFC bead (stackwright-y5u) must resolve the code-splitting pattern choice\n- Audit bead (stackwright-9yh) confirms which content types are worth splitting","status":"closed","priority":2,"issue_type":"task","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-29T15:09:34Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T01:48:00Z","started_at":"2026-05-30T01:35:27Z","closed_at":"2026-05-30T01:48:00Z","close_reason":"Dynamic imports implemented for Carousel and fuse.js/SearchModal. React.lazy() in componentRegistry + tsup splitting:true creates real ESM chunks. Carousel removed from main barrel, available at @stackwright/core/carousel. fuse.js dynamic import in SearchModal useEffect. Suspense boundary added in DynamicPage. No API changes. All 503 tests pass.","labels":["architecture","performance"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-zkq","title":"perf/icons: implement prebuild icon manifest — generate per-site static lucide imports","description":"## Goal\nReplace lucideAllIconsPreset with a prebuild-generated per-site icon manifest that contains ONLY the icons actually used in the site's YAML content. Eliminates ~120KB gzip from first-load JS.\n\n## Approach\nThe stackwright-prebuild script (in @stackwright/build-scripts) will:\n1. After processing all YAML content to JSON, recursively walk all page data\n2. Collect all { type: 'icon', src: string } objects across all pages\n3. Resolve legacy MUI aliases (hard-coded map, candidates for future deprecation)\n4. Write public/stackwright-content/_icon-manifest.json (debug artifact)\n5. Write stackwright-generated/icons.ts with static lucide imports + registerSiteIcons() function\n\n## Generated file shape\n```typescript\n// GENERATED by stackwright-prebuild — do not edit. Run pnpm predev to regenerate.\nimport { Star, Moon, Sun } from 'lucide-react';\nimport { registerStackwrightIcons } from '@stackwright/icons';\nimport { BlueSkyIcon } from '@stackwright/icons/icons/social/BlueSkyIcon';\nimport { StackwrightIcon } from '@stackwright/icons/icons/brand/StackwrightIcon';\n\nexport function registerSiteIcons(): void {\n registerStackwrightIcons({ Star, Moon, Sun, bluesky: BlueSkyIcon, stackwright: StackwrightIcon });\n}\n```\n\n## Wiring\n- No webpack alias needed — generated file is in user app directory, Next.js webpack processes it natively and tree-shakes lucide-react\n- defaultIcons.ts fallback: swap lucideAllIconsPreset → lucideIconPreset (curated subset, ~43 icons)\n- launch-stackwright templates: Providers.tsx calls registerSiteIcons() from generated file\n- stackwright-generated/ added to .gitignore\n\n## Tasks\n- [ ] Add collectIconSrcs() recursive walker to prebuild.ts\n- [ ] Add LEGACY_MUI_ICON_ALIASES hard-coded map and SYSTEM_ICON_NAMES set to prebuild.ts\n- [ ] Add generateIconManifest() function to prebuild.ts\n- [ ] Call generateIconManifest() in main prebuild flow after all pages processed\n- [ ] Update defaultIcons.ts: swap lucideAllIconsPreset → lucideIconPreset\n- [ ] Update launch-stackwright templates (Providers.tsx pattern)\n- [ ] Add stackwright-generated/ to .gitignore\n- [ ] Document in AGENTS.md\n- [ ] Build + measure before/after on docs example; document savings\n\n## Notes\n- lucideAllIconsPreset and registerAllLucideIcons() are KEPT as opt-in exports for power users\n- System icons (Sun, Moon, Info, AlertTriangle, CircleAlert) always included regardless of YAML content\n- Legacy MUI aliases are hard-coded in prebuild scanner (option B) and are candidates for deprecation","status":"closed","priority":2,"issue_type":"task","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-29T15:09:23Z","created_by":"Stackwright Bot","updated_at":"2026-05-30T01:13:34Z","started_at":"2026-05-30T01:09:26Z","closed_at":"2026-05-30T01:13:34Z","close_reason":"Prebuild icon manifest implemented. collectIconSrcs + generateIconManifest added to prebuild.ts. defaultIcons.ts now uses curated preset as fallback. Scaffold template updated to registerSiteIcons(). AGENTS.md and CLAUDE.md updated.","labels":["performance","quick-win"],"dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.changeset/feat-aria-live-regions.md b/.changeset/feat-aria-live-regions.md new file mode 100644 index 00000000..41c95a62 --- /dev/null +++ b/.changeset/feat-aria-live-regions.md @@ -0,0 +1,5 @@ +--- +"@stackwright/core": minor +--- + +feat(core): add AriaLiveRegion utility and wire aria-live regions into Form, SearchModal, Carousel, ContentItemErrorBoundary (WCAG 4.1.3) diff --git a/packages/core/src/components/ContentItemErrorBoundary.tsx b/packages/core/src/components/ContentItemErrorBoundary.tsx index e2589d12..48d4a879 100644 --- a/packages/core/src/components/ContentItemErrorBoundary.tsx +++ b/packages/core/src/components/ContentItemErrorBoundary.tsx @@ -33,6 +33,7 @@ export class ContentItemErrorBoundary extends React.Component< if (this.state.hasError) { return (
+ {message} +
+ ); +} diff --git a/packages/core/src/components/base/Form.tsx b/packages/core/src/components/base/Form.tsx index c8a99b61..d99563bc 100644 --- a/packages/core/src/components/base/Form.tsx +++ b/packages/core/src/components/base/Form.tsx @@ -194,6 +194,9 @@ export function Form({ {submitted ? (
{
{scrollAndButtonsEnabled && } + + {/* Announce slide position to screen readers */} + ); }; diff --git a/packages/core/src/components/structural/SearchModal.tsx b/packages/core/src/components/structural/SearchModal.tsx index bc06bf8e..f0057339 100644 --- a/packages/core/src/components/structural/SearchModal.tsx +++ b/packages/core/src/components/structural/SearchModal.tsx @@ -6,7 +6,8 @@ * during prebuild. */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { AriaLiveRegion } from '../base/AriaLiveRegion'; // fuse.js is NOT imported statically — it is dynamically imported inside the // useEffect below so webpack creates a separate async chunk for it. // The type-only import gives TypeScript the FuseResult shape without bundling. @@ -172,6 +173,15 @@ export function SearchModal({ } }, [selectedIndex]); + // Derive announcement for screen readers based on search state + // Must be before early return — React hooks must not be called conditionally + const liveMessage = useMemo(() => { + if (loading) return 'Loading search results…'; + if (!query.trim()) return ''; + if (results.length === 0) return `No results found for "${query}"`; + return `${results.length} result${results.length !== 1 ? 's' : ''} found for "${query}"`; + }, [loading, query, results.length]); + if (!isOpen) return null; return ( @@ -200,6 +210,9 @@ export function SearchModal({ }} onClick={(e) => e.stopPropagation()} > + {/* Screen reader announcement for result count changes */} + + {/* Search Input */}
{ expect(screen.getByText(/"phone" is required\./)).toBeInTheDocument(); }); }); + +// --------------------------------------------------------------------------- +// Accessibility — WCAG 4.1.3 live region announcements +// --------------------------------------------------------------------------- + +describe('Form — accessibility', () => { + it('success message has role="status" for screen reader announcement', async () => { + (global.fetch as ReturnType).mockResolvedValue({ ok: true }); + render(
); + const submitButton = screen.getByRole('button', { name: /submit/i }); + fireEvent.click(submitButton); + await waitFor(() => { + const statusEl = document.querySelector('[role="status"]'); + expect(statusEl).toBeTruthy(); + expect(statusEl?.textContent).toContain('Your message has been sent!'); + }); + }); +}); diff --git a/packages/core/test/components/aria-live-region.test.tsx b/packages/core/test/components/aria-live-region.test.tsx new file mode 100644 index 00000000..fcc7bafe --- /dev/null +++ b/packages/core/test/components/aria-live-region.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { AriaLiveRegion } from '../../src/components/base/AriaLiveRegion'; + +describe('AriaLiveRegion', () => { + it('renders a visually-hidden element with the message', () => { + const { getByRole } = render(); + const region = getByRole('status'); + expect(region).toBeTruthy(); + expect(region.textContent).toBe('Test announcement'); + }); + + it('uses role="status" and aria-live="polite" by default', () => { + const { container } = render(); + const el = container.firstChild as HTMLElement; + expect(el.getAttribute('role')).toBe('status'); + expect(el.getAttribute('aria-live')).toBe('polite'); + expect(el.getAttribute('aria-atomic')).toBe('true'); + }); + + it('uses role="alert" and aria-live="assertive" when politeness is assertive', () => { + const { container } = render( + + ); + const el = container.firstChild as HTMLElement; + expect(el.getAttribute('role')).toBe('alert'); + expect(el.getAttribute('aria-live')).toBe('assertive'); + }); + + it('is visually hidden (clip pattern applied)', () => { + const { container } = render(); + const el = container.firstChild as HTMLElement; + expect(el.style.position).toBe('absolute'); + expect(el.style.width).toBe('1px'); + expect(el.style.height).toBe('1px'); + expect(el.style.overflow).toBe('hidden'); + }); + + it('updates message content when prop changes', () => { + const { rerender, getByRole } = render(); + expect(getByRole('status').textContent).toBe('First message'); + rerender(); + expect(getByRole('status').textContent).toBe('Second message'); + }); +}); diff --git a/packages/core/test/components/content-item-error-boundary.test.tsx b/packages/core/test/components/content-item-error-boundary.test.tsx index b78025c1..73c22654 100644 --- a/packages/core/test/components/content-item-error-boundary.test.tsx +++ b/packages/core/test/components/content-item-error-boundary.test.tsx @@ -45,4 +45,18 @@ describe('ContentItemErrorBoundary', () => { expect(screen.getByText(/Error rendering "bad"/)).toBeInTheDocument(); errorSpy.mockRestore(); }); + + it('error state has role="alert" for screen reader announcement', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { container } = render( + + + + ); + // Error div should have role="alert" so screen readers announce it immediately + const alertDiv = container.querySelector('[role="alert"]'); + expect(alertDiv).toBeTruthy(); + expect(alertDiv?.textContent).toContain('Render explosion'); + errorSpy.mockRestore(); + }); });